Fill the wiki as the repository's knowledge base
One page per chart - prerequisites, installation walkthrough, the required 1Password fields, the complete values reference and the chart's operating traps - plus the long form of the workflow, the forge and its measured differences, the pipelines, versioning, Renovate, the chart style guide, the procedure for a new chart, local development, and the incidents each rule was bought with. Chart pages quote versions as snapshots; Chart.yaml stays authoritative, because Renovate moves app versions continuously and never edits the wiki.
36 files changed
+6156
No files matched your search
+159
@@ -0,0 +1,159 @@
|
||||
# CI/CD
|
||||
|
||||
Two pipelines: one that **validates** a pull request, one that **publishes** a chart after the merge. Both run on Gitea Actions and read the files under `.github/workflows/` unchanged.
|
||||
|
||||
## Publishing
|
||||
|
||||
- **`publish-<chart>.yml`** — one thin workflow per chart (23 of them). It triggers on pushes to `main` touching `<chart>/**`, or manually, and calls the reusable workflow below.
|
||||
- **A push to a chart directory on `main` publishes that chart.** The chart version must be bumped in the same change; the PR pipeline enforces it.
|
||||
- **`publish-helm-chart.yml`** — the reusable workflow: fetch dependencies, `helm package`, then a single HTTP `POST` of the `.tgz` to the registry.
|
||||
|
||||
The registry is a **classic, index-based** Helm registry (Gitea's `/api/charts` endpoint), not an OCI one: `helm push` speaks only OCI and is not used, there is no `helm registry login` session, and every request carries its own Basic auth. Credentials **and the target URL** come from 1Password Connect (item `jlab-registry-access` with `username`, `password`, `charts-url`); the workflow appends `/api/charts` to the URL. No registry address appears anywhere in the repository.
|
||||
|
||||
### Three properties of the upload step, each paid for once
|
||||
|
||||
- **Credentials stay out of the process list.** `curl --user "$U:$P"` would put them in `argv`. The step uses `curl --config -` and pipes the options in on stdin.
|
||||
- **A failed upload fails the job.** Unlike `helm push`, `curl` exits **0** on HTTP 401/403/409 — a publish would go green with nothing uploaded. `--fail-with-body` turns those into a non-zero exit and still prints the response body, so the log names the cause.
|
||||
- **The file name is built, not globbed.** `<chart>-<version>.tgz` is assembled from the chart name and the version read from `Chart.yaml`. `--upload-file` takes exactly one file, and a glob matching two silently turns the second into curl's URL argument. `helm package` keeps the `+` of `1.4.0+up1.9.2` verbatim, so the built name matches exactly.
|
||||
|
||||
### How the secrets are read
|
||||
|
||||
Values are read via **`steps.<id>.outputs.*`**, mapped into the step's own `env:` block and used as quoted shell variables — never interpolated into the command line. Each is guarded with `:?`, because an empty value used to collapse into a missing argument rather than an error.
|
||||
|
||||
`load-secrets-action` **v5 no longer exports them as environment variables** for later steps the way v4 did. After the v5 bump every `${{ env.* }}` reference resolved to an empty string while the loading step still logged `Populating variable` and stayed green.
|
||||
|
||||
> **The registry accepts a re-upload of an already published version** (HTTP 201) and replaces the stored artifact. A republish of an existing version is therefore still silent.
|
||||
|
||||
## The PR pipeline — `validate-charts.yml`
|
||||
|
||||
Five jobs. Everything except the render test follows the **changed** chart directories.
|
||||
|
||||
### 1. `detect-changed-charts`
|
||||
|
||||
Diffs the PR against its base and collects every changed top-level directory that contains a `Chart.yaml`. It emits three matrices:
|
||||
|
||||
- **`charts`** — for the build job.
|
||||
- **`scan-charts`** — normally the same list. A PR that changes the *scanner itself* touches no chart, which would leave the change untested, so in that case a fixed sample is scanned instead: `samba` (a documented deviation), `cyberchef` (fully hardened) and `matrix-synapse` (two workloads).
|
||||
- **`install-charts`** — the changed charts **minus** the excluded ones.
|
||||
|
||||
### 2. `build-chart` (per changed chart)
|
||||
|
||||
`ci/helm-dependencies.sh` → `helm lint --strict` → `helm package` → **version-bump check**.
|
||||
|
||||
The bump check compares `version:` against the base branch and fails with a message naming the file. `template-chart` is exempt: it is never published, so its version is meaningless.
|
||||
|
||||
### 3. `quantity-render` (every chart, every PR)
|
||||
|
||||
The one job that deliberately ignores the changed-chart matrix. `ci/run-quantity-render-test.sh` renders **every** chart twice, injecting a bare integer memory request at every `resources` block the chart's `values.schema.json` declares:
|
||||
|
||||
| Case | Injected | Expected limit | Catches |
|
||||
|---|---|---|---|
|
||||
| `bare-int` | `requests.memory: 1048576`, `limitFactor: 2` | `2097152` | the render aborting |
|
||||
| `fraction` | `requests.memory: 999999`, `limitFactor: 1.5` | `1499998.5` | a quantity in exponential notation |
|
||||
|
||||
Both cases exist because the underlying bug has a **loud and a silent half**. Loud: the reformatted quantity carries the suffix `e+06`, the helper rejects it, `helm template` exits non-zero. Silent: the *product* of a fractional `limitFactor` and a bare number is reformatted **after** the suffix check, so the render succeeds and the manifest carries `memory: "1.4999985e+06"` — rejected by nothing until the API server sees it at apply time. Checking the exit code alone proves only half the case, so the second case searches the **rendered text**. `999999` stays below `1e6` on purpose, isolating the second formatting site.
|
||||
|
||||
**Why all charts rather than the changed ones:** the resources helper is copied into every chart's `_helpers.tpl` rather than shared through a library chart. The regression arrives *by copy* — a new chart started from an older one, or from `template-chart` before the fix — and in that PR the copied bug is in no diff anyone reads. Running over everything also removes a list nobody would maintain: adding a chart enrolls it automatically.
|
||||
|
||||
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. A block counts when it is `#/definitions/resources` or carries `limitFactor`/`requests` itself, which keeps `minecraft-server`'s JVM-sizing `minecraft.resources` out. A chart whose templates render a resources block while its schema declares none **fails** with exactly that message: the schema is out of sync.
|
||||
- **A chart that never reaches the helper is reported, not counted as passing.** `gotenberg` sets every computable limit explicitly, so nothing is multiplied and nothing is proven. Such charts appear under "helper not reached" in the job summary rather than as a green tick for a code path never entered.
|
||||
|
||||
### 4. `trivy-config` (reporting only)
|
||||
|
||||
Renders each chart in the scan matrix and lists what `trivy config` finds. **It never fails the job** (`TRIVY_CONFIG_MODE: report`). Deliberately not a gate yet: the repo has several documented deviations, and a check that goes red on those every time is one people learn to scroll past. Switching the mode to `gate` is the intended step once the seccomp gap is closed.
|
||||
|
||||
Trivy is installed from its release tarball, not via `aquasecurity/setup-trivy` — see [below](#what-a-gitea-runner-does-differently).
|
||||
|
||||
### 5. `install-test` (per changed, non-excluded chart)
|
||||
|
||||
`ci/run-install-test.sh <chart>` against a throwaway [k3d](https://k3d.io) cluster: apply the stub `OnePasswordItem` CRD plus the chart's fixtures, `helm install` with the CI values, and wait for every Deployment and StatefulSet to roll out. Since probes and hardening are active, "Ready" means the app actually answers. On failure the job dumps pod descriptions, events and container logs.
|
||||
|
||||
**The job blocks the PR.** That stopped being negotiable when Renovate began automerging patch and minor updates: a chart that no longer starts must not reach `main` and publish unattended.
|
||||
|
||||
## Install-test fixtures
|
||||
|
||||
Fixtures live at the **repo root** under `ci/`, deliberately *not* inside the chart directories — so they are neither packaged into the chart `.tgz` nor do they trigger the publish workflows on merge.
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `ci/common/` | applied for every chart: the schema-less stub CRD for `OnePasswordItem` |
|
||||
| `ci/<chart>/test-values.yaml` | values for the CI install — **required for every installable chart** |
|
||||
| `ci/<chart>/fixtures.yaml` | dummy secrets, throwaway infrastructure (postgres/redis/dex), static hostPath PVs and storage classes, seed jobs |
|
||||
| `ci/<chart>/fixtures.env.yaml` | fixtures needing a value that must not be committed: `${VARIABLE}` placeholders only, rendered with `envsubst` and piped straight into `kubectl`, never printed |
|
||||
| `ci/<chart>/settings.env` | `INSTALL_TIMEOUT` for slow starters, `REQUIRED_ENV="VAR_A VAR_B"` declaring what `fixtures.env.yaml` needs |
|
||||
| `ci/excluded-charts.txt` | `<chart> <justification>`, one per line |
|
||||
|
||||
Substitution is restricted to the names in `REQUIRED_ENV`, so an undeclared variable is never filled in and no unrelated `$…` in the manifest is touched. **A missing or empty declared variable fails the install-test loudly, naming the variable — it never skips the chart.** Skipping would restore exactly the green-but-meaningless signal the mechanism exists to remove; the one legitimate case of absent secrets, a PR from a fork, belongs in an `if` on the job.
|
||||
|
||||
**`freesailarr` is the only chart using `fixtures.env.yaml`:** gluetun needs a real Wireguard key, or it never completes a handshake and no app container behind its kill switch becomes Ready. The key is loaded from 1Password for that chart only, with `:?` guards that name a missing `op://` field before the cluster is even created. It is a **CI-only Mullvad key**, deliberately not the production one: Mullvad ties an address to a key and routes the tunnel to the endpoint it saw last, so a shared key would make every PR run pull the tunnel out from under the running stack — symptom in the cluster, cause in a pull request.
|
||||
|
||||
Fixture images avoid Docker Hub where possible (`public.ecr.aws`, `ghcr.io`) to dodge runner rate limits.
|
||||
|
||||
### Excluded charts are dropped from the matrix, not skipped inside it
|
||||
|
||||
An excluded chart used to get a job that ran the script, printed `SKIP` and exited 0 — and the forge knows only the exit code, so the check list showed `Install-test satisfactory ✓` for a chart whose image was never pulled, indistinguishable from one that really came up. `detect-changed-charts` now removes the excluded charts **before** the matrix is built, so no job exists and nothing claims they passed.
|
||||
|
||||
Because an absence on its own says nothing, the job writes every excluded chart of the PR **with its documented justification** into the job summary. The list is parsed in exactly one place: `ci/run-install-test.sh --list-excluded` emits `<chart><TAB><reason>` and the workflow calls it rather than re-implementing the format in YAML — a second parser would be a second chance to select different charts, silently and in either direction. The script keeps its `SKIP` branch for local runs.
|
||||
|
||||
**What cannot be done instead:** deciding per chart in an `if` on the job, to get a visible `Skipped` rather than an absent entry. It is rejected with `context "matrix" is not allowed here` — `jobs.<id>.if` is evaluated *before* the matrix is expanded, so the `matrix` context does not exist there, and the whole workflow ends in failure with zero jobs created.
|
||||
|
||||
### The justification is mandatory
|
||||
|
||||
A line in `ci/excluded-charts.txt` carrying only a chart name is deliberately **not** an exclusion: a bare name would drop a chart out of CI with nothing to review.
|
||||
|
||||
## `ci/helm-dependencies.sh` — the step they all share
|
||||
|
||||
Not a workflow but the prerequisite of every helm command in this repo: it fetches a chart's declared dependencies into `charts/`, and returns silently for the charts that declare none (all but one).
|
||||
|
||||
It runs in **five** places, because every helm command exits 1 while a declared dependency is missing: before lint and package in `validate-charts.yml`, before package in `publish-helm-chart.yml`, and inside `ci/run-install-test.sh`, `ci/run-quantity-render-test.sh` and `ci/run-trivy-config.sh`. One of those call sites is the render test that covers **every** chart on **every** PR — so a dependency-carrying chart can turn a third-party Helm repository's bad day into a red pipeline on an unrelated PR.
|
||||
|
||||
It runs `helm dependency update`, not `build`: `Chart.lock` is not committed, because Renovate rewrites `Chart.yaml` and cannot regenerate the lock, so a committed one would arrive stale.
|
||||
|
||||
## A PR that touches only `ci/`
|
||||
|
||||
Both matrices follow the changed **chart directories**, so a PR that only changes the harness 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. The quantity render test is the exception to the rule: it always runs over everything.
|
||||
|
||||
## What a Gitea runner does differently
|
||||
|
||||
All measured on this instance, not taken from documentation, and the reason for every deviation in `validate-charts.yml`.
|
||||
|
||||
**Reusable workflows with `secrets: inherit` work**, so the 23 thin publish hulls run unchanged. One quirk: a *missing* secret does not fail the job, it fails job **creation** — the run then shows no steps at all.
|
||||
|
||||
**Runner labels mean different machines.** `ubuntu-latest` has **no Docker socket** here; `ubuntu-docker` has one. Exactly one job cares — the install-test, because k3d is a Docker client — so its `runs-on` is an expression rather than a fixed label, and the same file still works on a GitHub runner where `ubuntu-docker` does not exist.
|
||||
|
||||
**A matrix runs concurrently inside ONE job, under ONE status context.** Operationally: all legs share one Docker daemon, which makes a per-leg resource name mandatory. For branch protection: a required check per chart is not possible, and the context name carries the *unexpanded* matrix expression, so required-check names must be read off a real run.
|
||||
|
||||
**Expected runtimes**, so a later deviation is visible as one:
|
||||
|
||||
| | measured here | GitHub, same commit |
|
||||
|---|---|---|
|
||||
| `Create k3d cluster` | 19–25 s | 22–23 s |
|
||||
| install-test, two charts in the matrix (cyberchef + freesailarr) | 210–216 s over three runs, spread 6 s | ~145 s wall, two parallel VMs |
|
||||
| install-test, freesailarr alone, ten containers and a real tunnel | 225 s | not comparable |
|
||||
|
||||
The runner is **not slower** — cluster creation is a touch faster. The difference in total time is **structural**: two matrix legs share one runner here, while GitHub gives each leg its own VM. Reading it as "Gitea is slower" leads to the wrong conclusion.
|
||||
|
||||
**`aquasecurity/setup-trivy` cannot be used.** It runs `actions/checkout` against `aquasecurity/trivy` on github.com and hands it `github.token` — which here is a *Gitea* token. github.com answers 401 and checkout ends on `fatal: could not read Username for 'https://github.com'`, naming neither the action nor the token. Its `actions/cache` step is inert here as well, and fetching the nested actions cost 4m30s before the job even started. The workflow downloads the release tarball instead: one request, same behaviour on either forge, and the `# renovate:` comment still keeps `TRIVY_VERSION` current.
|
||||
|
||||
**Tools are installed, not inherited — and the two cases differ on purpose.** `kubectl` is **pinned** to match `K3S_IMAGE` and guarded against version skew (more than one minor apart fails the job with both versions named), because a client's distance to the API server changes what the test *means*. `envsubst` is only **checked for and fetched if absent**, because it substitutes text and the release doing so does not change the result; pinning it would be upkeep without value. That GitHub's image ships both does not make either unnecessary — it only makes the drift invisible there.
|
||||
|
||||
**Containerisation is detected, not inferred from the platform.** k3d publishes the API server port on the Docker *host*; when the job is itself a container — as every Gitea Actions job is — that host is the dind container, so the `https://0.0.0.0:<port>` k3d writes into the kubeconfig points at the job container, where nothing listens. The workflow asks the daemon about its own container instead of asking which forge it is on: a self-hosted **GitHub** runner in a container has the identical problem, and a condition that says "GitHub" while meaning "not containerised" is the kind of lie this repo exists to remove.
|
||||
|
||||
Still unmeasured, because they never stood in the way: `/dev/fuse` and user namespaces. Said as unmeasured rather than as fine.
|
||||
|
||||
### What a GitHub runner had been providing quietly
|
||||
|
||||
Getting the install-test to run on Gitea surfaced six assumptions nobody had written down. Five are about the **runner** and fail loudly, fixed on the node side: Docker bridges built at MTU 1500 against a pod network of 1450 (small responses fit in one packet, a 17 MB tarball does not, so it looked like a blocked host); `fs.inotify` limits too low for k3s, so containerd never started and the node never registered; no `kubectl` and no `envsubst` in the image; and the kubeconfig pointing at `0.0.0.0`.
|
||||
|
||||
The sixth is about **our code**, and it is the one to remember — see [Lessons learned](Lessons-Learned#a-generous-environment-is-no-friend): the k3d cluster name was hard-coded, which was wrong on GitHub too and simply could never show there.
|
||||
|
||||
## Two rules for every check added here
|
||||
|
||||
1. **A check that a broken system passes is worse than no check.** `kubectl get nodes` exits **0 on an empty list**, so a readiness check written that way went green against a cluster whose node had never registered — and the failure surfaced five minutes later, somewhere unrelated. `kubectl wait --for=condition=Ready nodes --all` is not the shorter way to write it: with zero nodes the condition is vacuously satisfied. Assert the thing you mean — *at least one node, and Ready*.
|
||||
2. **Green does not mean checked.** A passing job earns exactly as much trust as the assertions inside it. The open case of this kind: the images in `ci/<chart>/fixtures.yaml` are not followed by Renovate, so the install-test can keep passing against fixture infrastructure that is years stale (Gitea #24). **Not solved** — named so it is not mistaken for solved.
|
||||
|
||||
## `change-app-version.yml`
|
||||
|
||||
A manually dispatched app-version bump that rewrites `version:`/`appVersion:` and commits **directly to `main`**, bypassing both the validate and the install-test pipelines. It still exists for one-off bumps, but the normal path for an app update is a [Renovate](Renovate) PR. A new chart must be added to its `chart-name` choice list.
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
# Chart style guide
|
||||
|
||||
**Binding for every current and future chart.** `template-chart/` is the reference implementation; where this page and the template disagree, the template is the bug.
|
||||
|
||||
Every documented exception is named here with its reason. An exception that is not written down is a deviation.
|
||||
|
||||
## 1. Naming
|
||||
|
||||
- Resources are named `{{ .Release.Name }}-{{ .Chart.Name }}-<kind>`: `-deployment`, `-sfs`, `-service`, `-secret`, `-configmap`, `-ingress`, `-pvc`.
|
||||
- Template files are named `<app>.<kind>.yaml`. Stateful apps use a StatefulSet in `<app>.sfs.yaml` rather than a Deployment.
|
||||
|
||||
**Documented exception — existing PVC names.** PVCs of already-published charts keep their historical names even where they deviate. Renaming a PVC makes Kubernetes provision a new, empty volume, so a rename would orphan the existing data. New charts follow the scheme.
|
||||
|
||||
**StatefulSet renames** are possible — data survives because the charts use standalone PVC templates, not `volumeClaimTemplates` — but only in a major-bump PR with a one-time migration note: `kubectl delete statefulset <old> --cascade=orphan`, then delete the leftover pod, then upgrade.
|
||||
|
||||
## 2. Versioning
|
||||
|
||||
See [Versioning](Versioning) for the full rules. Short form: `version: <chart-semver>+up<appVersion>`, suffix matches `appVersion` with dashes normalized to dots, and any push to `main` touching a chart directory publishes it.
|
||||
|
||||
## 3. Images
|
||||
|
||||
- **The main app image tag comes from `{{ .Chart.AppVersion }}`** — never hardcoded.
|
||||
- **Auxiliary images** (sidecars, init containers, secondary workloads — the delegation nginx in matrix-synapse, guacd in patchmon, the busybox init/consume images in paperless-ngx) are pinned via values instead of being hardcoded in templates, and their versions are reviewed alongside every app-update round.
|
||||
|
||||
**Documented exception — freesailarr.** The chart bundles ten independently versioned upstreams, nine of them enabled by default, so none of them is "the" app version. **No** tag comes from `appVersion`; every image is pinned as `<app>.image.repository`/`<app>.image.tag`, which is also what puts them under the built-in `helm-values` manager. Its `appVersion` is the chart's own marker, raised by hand. Consequently it has no app-version block in `renovate.json5` and is not in the `change-app-version.yml` choice list. `mediathekarr` deviates once more: its tags carry no version, so it is pinned by digest and is the one image with a `customManagers` block.
|
||||
|
||||
**Documented exception — minecraft-server.** The only chart whose main image tag can be overridden, through `image.tagOverride` (empty by default). `appVersion` is the tag and therefore fixes the JVM, and only the newest Java line is maintained out of the directory; the older lines stay published as frozen chart versions. 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. It is named `tagOverride`, not `tag`, because it decouples the running image from the chart version, and it is deliberately outside Renovate's reach. The chart checks the pairing itself: the Java line of the **effective** tag against `minecraft.version`, failing the render with both values named.
|
||||
|
||||
## 4. Ingress
|
||||
|
||||
- `ingress.domain` and `ingress.clusterIssuer` are `required` **without defaults** — every installation sets both explicitly; they are the only values a default install needs.
|
||||
- `ingress.className` defaults to `nginx`.
|
||||
- `ingress.annotations` takes optional extra annotations, merged with the cert-manager annotation the chart always sets.
|
||||
- The TLS secret is named `tls-<release>-<chart>-ingress`.
|
||||
|
||||
**A shared domain with sub-paths is the second admissible shape.** Where a chart exposes several apps they may each take a host, or share one host and be told apart by path (`example.com/sonarr`, `example.com/radarr`). 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 with the standard TLS secret; an app carrying a host of its own always wins and keeps its own object, so both shapes mix without extra values. An app needing a per-path proxy annotation (`rewrite-target` is per **object**, not per path) gets its own object on the same host, repeating the TLS secret name and **omitting** the cert-manager annotation, so exactly one certificate is requested. Apps that cannot serve a base path are rendered at `/` or refused with a `fail` — never rendered somewhere they provably cannot work.
|
||||
|
||||
**Documented exception — homeassistant** keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its `className` default follows the standard.
|
||||
|
||||
**Documented exception — rss-bridge** takes `ingress.hosts` as a required list of host plus optional `tlsSecretName`, so several hosts keep their own existing certificates.
|
||||
|
||||
**Documented exception — freesailarr** has no `ingress.domain` at all and offers both shapes; see [its page](freesailarr).
|
||||
|
||||
## 5. Security hardening
|
||||
|
||||
Defaults on every workload: `automountServiceAccountToken: false`, non-root user, `readOnlyRootFilesystem: true`, all capabilities dropped, `allowPrivilegeEscalation: false`. Pod and container contexts are values-configurable via `securityContext.pod` / `securityContext.container`. Writable paths (`/tmp`, nginx cache dirs, s6-overlay's `/run`) are emptyDirs so the root filesystem stays read-only.
|
||||
|
||||
**Where an image cannot satisfy the standard, the deviation is minimal and MUST be justified with a comment in that chart's `values.yaml`.** Home Assistant must start as root for s6-overlay; paperless needs a root init container to hand over `/run`; samba needs its capabilities; calibre-web-automated must keep `readOnlyRootFilesystem: false`.
|
||||
|
||||
## 6. Probes
|
||||
|
||||
Every workload has liveness, readiness and startup probes — the one documented exception is the paperless-ngx consume CronJob. Probes are values-configurable with calibrated defaults, and the startup probe gates liveness and readiness; its budget is documented in a values comment per chart.
|
||||
|
||||
## 7. Resources (auto-limits)
|
||||
|
||||
Requests ship as chart defaults in `values.yaml` and are user-overridable. Limits are computed by a per-chart `_helpers.tpl` helper:
|
||||
|
||||
- an explicitly set limit always wins;
|
||||
- a missing **memory** or **ephemeral-storage** limit defaults to `resources.limitFactor` × the request (factor default `3`);
|
||||
- a **CPU** limit is only rendered when set explicitly — otherwise CPU stays unlimited, because it is compressible and throttles instead of OOM-killing.
|
||||
|
||||
The helper is **copied into every chart** rather than shared through a library chart, which would be a second artifact to publish, version and pull for something that changes about once a year. The [resource-quantity render test](CI-CD#3-quantity-render-every-chart-every-pr) exists because a copy can carry an old bug into a diff nobody reads.
|
||||
|
||||
## 8. Secrets
|
||||
|
||||
Secrets are **1Password `OnePasswordItem` CRs**: values expose `secrets.<name>SecretRef` holding an `op://` item path, and the 1Password operator materializes the Kubernetes Secret in-cluster.
|
||||
|
||||
The Secret's **keys are the field names of the 1Password item**. A missing field does not fail the install — the release goes through and the pod hangs in `CreateContainerConfigError` naming the key it could not find. Every chart's `NOTES.txt` therefore lists the required fields.
|
||||
|
||||
## 9. Config changes must restart the workload
|
||||
|
||||
**Every workload consuming a ConfigMap carries a `checksum/config` annotation on its pod template**, one line per ConfigMap:
|
||||
|
||||
```yaml
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/<chart>.configmap.yaml") . | sha256sum }}
|
||||
```
|
||||
|
||||
Where a workload consumes several ConfigMaps, use one annotation per file with a distinguishing suffix (`checksum/config-<name>`).
|
||||
|
||||
**Why it is mandatory:** without it a changed ConfigMap does not change the pod template, so nothing restarts and the running container keeps the old config — silently. `subPath` mounts, the usual form here, are never refreshed by the kubelet at all, and most apps read their config only at start. This produced a real outage: the router kept routing to Services that no longer existed while every bundle was green and the pod was Ready.
|
||||
|
||||
**It is content-based, not time-based.** The hash is taken over the rendered ConfigMap, so an unchanged config renders an identical hash and `helm upgrade` is a no-op. It never degenerates into a restart-on-every-upgrade.
|
||||
|
||||
Adding the annotation to an existing chart is a **major** bump: a config change now costs a restart, and some workloads take minutes to come back. Name that cost in the PR.
|
||||
|
||||
### Secrets cannot use this mechanism
|
||||
|
||||
Helm never sees a `OnePasswordItem`'s content — the operator fills the Secret in-cluster after rendering, so there is nothing to hash. A chart mounting such a Secret as a file documents the limitation in its `values.yaml`.
|
||||
|
||||
**What *can* be covered is the secret's `op://` path**, via a separately named annotation over the rendered `OnePasswordItem`:
|
||||
|
||||
```yaml
|
||||
checksum/secret-ref: {{ include (print $.Template.BasePath "/<chart>.secret.yaml") . | sha256sum }}
|
||||
```
|
||||
|
||||
Repointing a `secrets.<name>SecretRef` at a different vault item then takes effect instead of silently leaving the old credentials in the running pod. The separate name is deliberate: `checksum/config` promises "the config changed", `checksum/secret-ref` only "the config *source* changed". Reference implementations: `minecraft-server`, `homeassistant`.
|
||||
|
||||
### `operator.1password.io/auto-restart` only works for Deployments
|
||||
|
||||
Verified against onepassword-operator **1.12.0**: `restartWorkloadsWithUpdatedSecrets` enumerates exactly one workload type, `appsv1.DeploymentList`, and `getPodTemplate` accepts only `*appsv1.Deployment`. The RBAC role grants `statefulsets` and v1.11.0 generalized the restart code, but the PR that would add StatefulSets is still open — so **the annotation on a StatefulSet is a silent no-op**.
|
||||
|
||||
Setting it there is **worse than not setting it**: it advertises a guarantee that does not exist. Affected charts (`matrix-synapse`, `samba`, `homeassistant`) document the manual `kubectl rollout restart statefulset <release>-<chart>-sfs` instead. Re-check when the operator version is bumped.
|
||||
|
||||
If a Deployment-based chart ever does set it, mind the precedence: the annotation on the **`OnePasswordItem`** is copied onto the Kubernetes Secret and read first, so a `false` there **overrides** a `true` on the workload — the opposite of what the operator's usage guide suggests. Lookup order: Secret → workload → namespace → the operator-global `AUTO_RESTART`. The trigger is the 1Password item's **version counter**, not its content, so a re-save with identical values still restarts.
|
||||
|
||||
## 10. Environment variables
|
||||
|
||||
Every workload container offers an additive `env` value block, appended to the entries the chart always sets. Entries are rendered through `tpl` (so values may contain Helm templating) and support the `value`, `secretKeyRef` and `configMapKeyRef` forms.
|
||||
|
||||
**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
|
||||
|
||||
- Every Deployment/StatefulSet has a `replicas` value (default `1`, path matching its section) that also accepts an explicit `0`.
|
||||
- Every chart has a global `scaleDown` flag (default `false`). `scaleDown: true` takes precedence over all `replicas` values, renders every workload with `replicas: 0` and suspends CronJobs. It **stops** the app without uninstalling it: PVCs, Secrets, Services and Ingress stay; setting it back brings the workloads up.
|
||||
- Implemented via a per-chart `<chart>.replicas` helper using an explicit nil check (`kindIs "invalid"`), because Helm's `default` would swallow an explicit `0`.
|
||||
|
||||
## 12. Values that must not lie
|
||||
|
||||
Every chart ships a **`values.schema.json`** with `additionalProperties: false` on **every chart-owned object level** — not just the top one. The gap this closes is a key that looks like configuration and does nothing: the pilot found three of them in one chart, one nested three levels deep and quietly leaving a game world unpinned.
|
||||
|
||||
Objects passed **verbatim** into the pod spec stay open (probes, `securityContext.pod`/`.container`, `global`, free-form annotation maps): Kubernetes owns their schema, and restricting them would only reject fields that do not exist yet. The schema **adds to** `required()`, it does not replace it.
|
||||
|
||||
### 1. A value read outside its obvious template needs its own guard
|
||||
|
||||
Before putting an Ingress behind `ingress.enabled`, check whether anything else reads `ingress.domain` — **in `templates/` and in `values.yaml`**, because `env` entries are rendered through `tpl` and hide such reads. If something does, that read needs its own `required()`; the gate then frees `clusterIssuer` but *not* `domain`.
|
||||
|
||||
This happened in six charts, and matrix-synapse shows why it matters more than the count: its delegation nginx serves `ingress.domain` in the `/.well-known/matrix/{server,client}` endpoints through which **federation finds the homeserver**. Gating without the guard publishes `{"m.server": ":443"}` — rendered cleanly, pod green, federation broken. Check per chart and in both directions: in `calibre-web-automated` nothing outside the Ingress reads the domain, and there the gate frees both values.
|
||||
|
||||
### 2. Probes, security contexts and resources go through the null-safe helper, never behind an `if`
|
||||
|
||||
An override written without children is YAML null and **erases** the block: the container renders `securityContext: null` (unhardened, healthy, silent), a probe disappears, or `resources` loses its requests and computed limits.
|
||||
|
||||
The `defaultedDict` helper defines the opposite semantics — an empty block means "chart defaults apply" — while an explicitly set value still wins, **including `false` and `0`** (hence the `kindIs "invalid"` check; `default` and `mergeOverwrite` both swallow zero values).
|
||||
|
||||
Do **not** wrap these blocks in `{{- if }}`: a guard does not render a visible `null`, it removes the probe entirely — the same bug with no evidence left.
|
||||
|
||||
### 3. Do not hang liveness on an external dependency where a dependency-free path exists
|
||||
|
||||
`zipline` deliberately probes `/` for liveness and `/api/healthcheck` only for readiness and startup, so a database outage cannot restart the pod. Where no such path exists — `outline` returns 500 on `/` when the database is down — all three probes may share the endpoint, but the reason belongs in a comment on the helper, or the next reader will "fix" the inconsistency.
|
||||
|
||||
**The other half of the same rule: readiness *must* hang on the dependency, and a separate path is not proof that it does.** `urlaubsverwaltung` probed `/actuator/health/liveness` and `/actuator/health/readiness` for two years and still stayed 1/1 Ready through a total database outage, because Spring Boot's `readiness` group contains `readinessState` **alone** — the split paths were right, the group content was empty of anything external. Whatever aggregates behind a probe path has to be read off the **running** endpoint, not off the path name; the chart now names the members explicitly (`MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE=readinessState,db`).
|
||||
|
||||
### 4. A fallback reproduces *this chart's* defaults, not the repo standard
|
||||
|
||||
Five charts deviate from the hardening baseline on purpose. A "cleaned up" standard default is not tidying, it is an outage: `calibre-web-automated` must keep `readOnlyRootFilesystem: false`, because in read-only mode the image disables its PUID/PGID remapping and the runtime uid silently moves from 1000 to 911 — **making existing volume data inaccessible**.
|
||||
|
||||
The same applies to non-standard values that look like mistakes: `satisfactory` keeps `limitFactor: 2` (that is what holds the memory limit at the published 8Gi) and `fsGroupChangePolicy: OnRootMismatch` (which avoids a recursive chown of multi-GB game files on every start).
|
||||
|
||||
### 5. A probe default carries the probe's context, not just its timings
|
||||
|
||||
`teamspeak-server` addresses its TCP port by the **name** `file-transfer`, because the voice service is UDP and cannot be probed — a default without that name leaves the workload with no working probe at all. `patchmon` sends a **`Host` header**, because the server answers 403 to any request whose Host is neither loopback nor a CORS_ORIGIN host — a default without it means the pod never becomes Ready.
|
||||
|
||||
Port names, `Host` headers and `exec` commands belong in the fallback; where the context is dynamic, pass the root context in and rebuild it.
|
||||
|
||||
### 6. A value that reads as the negation of the variable it feeds must actually be negated — and `not` cannot do it
|
||||
|
||||
`satisfactory` shipped `game.seasonalEvents` straight into the image's `DISABLESEASONALEVENTS`: the default `true` switched the events **off** in every install for the chart's whole life, while the schema described the key as "true means events are enabled".
|
||||
|
||||
The fix is the loud one — **rename the key so it says what it does** (`game.enableSeasonalEvents`) rather than silently inverting the template under existing releases, because the old key then fails schema validation instead of quietly meaning the opposite.
|
||||
|
||||
Two mechanics come with it: Go's `not` treats **every non-empty string as true**, so `not "false"` is `false` and the string form these schemas deliberately accept flips the wrong way — compare explicitly (`eq (lower (printf "%v" $v)) "true"`). And a value that cannot be read as either boolean belongs in a `fail`, not in an implicit branch: `enableSeasonalEvents: "yes"` must abort the render. When such a rename lands, the new default is the one that **reproduces what the chart already rendered**; changing the meaning and the behaviour in the same release turns the loud fix back into a silent one.
|
||||
|
||||
### Two mechanics that are easy to lose
|
||||
|
||||
- **`null` reaches the schema in one case and not the other.** For a key **with** a chart default, Helm's coalescing treats the `null` as a deletion and removes it *before* validation — the schema never sees it, and only the helper can catch it. For a key **without** a default (e.g. `resources.limits`) there is nothing to delete, the `null` survives into validation, and a strict `"type": "object"` aborts the render. That is why the helper **and** the widened `["object", "null"]` types are both needed. Everything else stays strictly typed.
|
||||
- **Validate every schema against the real values, not only against chart defaults.** Defaults are valid by construction, so they prove nothing. Render each chart against its cluster bundle **and** against `helm get values` — the two differ more often than expected (four of ten bundles in one wave, always with the repo file being the worse one). This caught a schema that would have broken three live paperless releases, because all three write `tikaGotenberg.enabled` as the string `"true"` while the first draft demanded a boolean. The result per chart is a **list of rejected keys**, which is the cluster side's work order before the version is pulled up.
|
||||
|
||||
## 13. Everything else
|
||||
|
||||
- **`icon:` is mandatory** in every `Chart.yaml`. **Documented exception — freesailarr, the only chart without one:** the rule elsewhere is "the icon is this chart's one upstream's logo", and a chart bundling ten independently versioned projects has no such upstream. That is the whole criterion — a single-upstream chart without an icon is a missing icon, not an exception. The omission costs an INFO line in `helm lint --strict`, which still exits 0.
|
||||
- **`helm lint --strict` must pass without findings.**
|
||||
- **Every chart ships a `templates/NOTES.txt`**, `template-chart` included. The bar: print what an operator would otherwise have to look up in the templates — the rendered access URL, **which fields the 1Password item must carry**, the options actually active in this release, and the chart's known traps, all conditional on the values really set. A `NOTES.txt` that only repeats the values is skimmed once and never read again. **No real domain anywhere** — the URL renders from `ingress.domain`, and where an example is unavoidable it is `example.com`. Writing or reworking one renders no Kubernetes object: a minor bump.
|
||||
- **CI fixtures** under `ci/<chart>/`, or a justified entry in `ci/excluded-charts.txt`. The install-test fails if both are missing.
|
||||
|
||||
## 14. Chart dependencies
|
||||
|
||||
The former "no dependencies" rule is gone, replaced by a bar rather than a ban. `ytdlp-web-player` is the only chart using one: it needed an oauth2-proxy in the data path in front of an application that ships no authentication at all, and proxy and app are only useful wired to each other.
|
||||
|
||||
**When a dependency earns its place — all three, not two of three:**
|
||||
|
||||
1. The dependent piece would otherwise be a second Helm release that only exists to serve this one, with the wiring between them living in neither chart.
|
||||
2. Upstream is the right owner of it. **We consume their chart as it comes: we do not patch it, do not re-harden it, do not re-implement its values.** Where their defaults fall short of these guidelines, that is **documented in our `values.yaml`**, not fixed by us — and a values-level fix stays available to whoever installs it.
|
||||
3. It is **optional and off by default** (`condition:` in `Chart.yaml`), so the chart is complete without it and a default install renders none of its objects.
|
||||
|
||||
**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 watching Helm release versions — Fleet's `fleet.yaml` 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, and nothing reports the gap. Renovate's built-in `helmv3` manager opens the PR and `ci/renovate-bump-chart.sh` bumps the 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`, `package`, `template` and `install` all exit 1 while a declared dependency is missing, so `ci/helm-dependencies.sh` runs before each. One call site is the render test that runs over **every** chart on **every** PR — a dependency-carrying chart can turn a third-party Helm repository's bad day into a red pipeline on an unrelated PR.
|
||||
- **`charts/*.tgz` and `Chart.lock` are not committed.** 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`, not `build`.
|
||||
- **The repo's own mechanisms stop at the subchart boundary.** `checksum/config`, the 1Password auto-restart annotation and `scaleDown` reach our templates only. A chart rendering a `OnePasswordItem` for its subchart documents the manual `kubectl rollout restart` that replaces the missing restart-on-change.
|
||||
|
||||
**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 must spell the parent chart out literally; only `.Release.Name` is shared.
|
||||
|
||||
`template-chart` remains dependency-free: the scaffold should not carry a `dependencies:` block a new chart has to remember to delete.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Chart index
|
||||
|
||||
24 directories: 23 installable charts plus the `template-chart` scaffold. Each page holds prerequisites, an installation walkthrough, the complete list of configurable values, and the chart's operating traps.
|
||||
|
||||
**Versions are not listed here on purpose.** Renovate moves app versions continuously; a table of numbers in a wiki is wrong within days. Read the current pair off the chart:
|
||||
|
||||
```bash
|
||||
grep -E '^(version|appVersion):' <chart>/Chart.yaml
|
||||
```
|
||||
|
||||
| Chart | Deploys | Shape | Ingress | Notable |
|
||||
|---|---|---|---|---|
|
||||
| [apache-tika](apache-tika) | Apache Tika | Deployment + Service | no | content extraction for [paperless-ngx](paperless-ngx) |
|
||||
| [bentopdf](bentopdf) | BentoPDF | Deployment + ConfigMap | yes | `config.json` always rendered, `subPath`-mounted into the web root |
|
||||
| [calibre-web-automated](calibre-web-automated) | Calibre-Web Automated | StatefulSet + extra consume PVC | yes | keeps `readOnlyRootFilesystem: false` on purpose — see [§12.4](Chart-Style-Guide#12-values-that-must-not-lie) |
|
||||
| [cyberchef](cyberchef) | CyberChef | Deployment | yes | stateless; the simplest chart in the repo and a good first read |
|
||||
| [freesailarr](freesailarr) | gluetun VPN media stack, ten containers | single-pod StatefulSet | per app | the exception to many rules: no icon, no app-version-driven tags, two ingress shapes |
|
||||
| [gotenberg](gotenberg) | Gotenberg | Deployment + Service | no | PDF conversion for [paperless-ngx](paperless-ngx); sets every limit explicitly |
|
||||
| [homeassistant](homeassistant) | Home Assistant | StatefulSet | upstream-style lists | the only chart with Helm tests; starts as root for s6-overlay |
|
||||
| [matrix-synapse](matrix-synapse) | Matrix Synapse | StatefulSet + nginx delegation Deployment | yes | `ingress.domain` is served in `/.well-known/matrix/*` — federation depends on it |
|
||||
| [minecraft-router](minecraft-router) | itzg/mc-router | Deployment + ConfigMap + NodePort | no (TCP) | `mappings` required; the chart behind the `checksum/config` outage |
|
||||
| [minecraft-server](minecraft-server) | itzg/minecraft-server | StatefulSet + PVC | no | only the newest Java line is maintained; `image.tagOverride` picks an older JVM |
|
||||
| [outline](outline) | Outline | StatefulSet + upload PVC | yes | external Postgres and Redis; the reference page for this wiki |
|
||||
| [paperless-ngx](paperless-ngx) | Paperless-ngx | Deployment + consume CronJob | yes | largest chart; SMB connector; the one documented probes exception |
|
||||
| [patchmon](patchmon) | PatchMon | one Deployment, optional guacd sidecar | yes | probes must send a `Host` header or the server answers 403 |
|
||||
| [rallly](rallly) | Rallly | Deployment | yes | DB, OIDC and SMTP secrets |
|
||||
| [rss-bridge](rss-bridge) | RSS-Bridge | Deployment + ConfigMap | yes, **list of hosts** | documented ingress exception: each host may keep its own TLS secret |
|
||||
| [samba](samba) | Samba | StatefulSet | no | needs capabilities — a documented hardening deviation |
|
||||
| [satisfactory](satisfactory) | Satisfactory game server | StatefulSet | no | **excluded from the install-test**; `limitFactor: 2` and `OnRootMismatch` are deliberate |
|
||||
| [searxng](searxng) | SearXNG | Deployment + ConfigMap | yes | `ingress.domain` is read twice, so it stays required with the Ingress off |
|
||||
| [teamspeak-server](teamspeak-server) | TeamSpeak server | StatefulSet | no | probes address the TCP port by name; the voice service is UDP and unprobeable |
|
||||
| [template-chart](template-chart) | — | scaffold | — | **not installable**; the reference implementation of every convention |
|
||||
| [urlaubsverwaltung](urlaubsverwaltung) | Urlaubsverwaltung | Deployment | yes | readiness names its health-group members explicitly after a silent outage |
|
||||
| [voucher-vault](voucher-vault) | VoucherVault | StatefulSet | yes | DB and OIDC secrets |
|
||||
| [ytdlp-web-player](ytdlp-web-player) | ytdlp_web_player | Deployment | yes | **the only chart with a dependency**: an optional oauth2-proxy subchart, off by default |
|
||||
| [zipline](zipline) | Zipline | StatefulSet | yes | liveness deliberately avoids the database — the positive example of [§12.3](Chart-Style-Guide#12-values-that-must-not-lie) |
|
||||
|
||||
## What every chart page contains
|
||||
|
||||
The same sections in the same order, so two charts can be compared by scrolling to the same place:
|
||||
|
||||
1. **At a glance** — workloads, storage, ingress, external dependencies, secrets, CI status
|
||||
2. **Prerequisites** — what must exist before `helm install`, including the 1Password item fields
|
||||
3. **Installation** — `helm repo add` / `helm install` with a minimal values file
|
||||
4. **Configurable values** — one table per top-level section, every chart-owned key
|
||||
5. **Operating notes** — the traps, each with the reason it exists
|
||||
6. **Upgrades and backup**
|
||||
7. **CI** — which fixtures cover the chart
|
||||
|
||||
## Charts excluded from the install-test
|
||||
|
||||
Two, both with a written justification in `ci/excluded-charts.txt`:
|
||||
|
||||
- **`satisfactory`** — the game-server image is multiple GB and Steam downloads 10 GB+ of game files on first start.
|
||||
- **`template-chart`** — a scaffold whose image is the placeholder `ghcr.io/ORG/IMAGE:xxx`; it is never published.
|
||||
|
||||
An excluded chart gets **no** install-test job at all, and the reason is printed into the job summary. See [CI/CD](CI-CD#excluded-charts-are-dropped-from-the-matrix-not-skipped-inside-it).
|
||||
@@ -0,0 +1,89 @@
|
||||
# Forge and tooling
|
||||
|
||||
Where this repository lives, which CLI talks to it, and the handful of things that are different from GitHub in a way that costs time if you find out late.
|
||||
|
||||
## Gitea is the forge, GitHub is a mirror
|
||||
|
||||
Everything runs on the Gitea instance **JLab-Git**: issues, pull requests, CI and Renovate. `main` there is the leading copy.
|
||||
|
||||
github.com is a **push mirror with Actions and Renovate switched off**, and it lags until the mirror runs. Nothing there is authoritative, and a green or absent check there means nothing.
|
||||
|
||||
The workflow files under `.github/workflows/` are read by Gitea unchanged — the directory name is a GitHub convention Gitea adopted, not a sign that the workflows run there.
|
||||
|
||||
## `tea`, not `gh`
|
||||
|
||||
Issues and pull requests are driven with `tea`, and only through 1Password:
|
||||
|
||||
```bash
|
||||
op plugin run -- tea pr list
|
||||
```
|
||||
|
||||
A bare `tea` invocation falls back to the wrong identity. Every call costs a 1Password confirmation, so **bundle calls and never poll**.
|
||||
|
||||
## Remotes are named, and `origin` is not one of them
|
||||
|
||||
```
|
||||
github the push mirror
|
||||
JLab-Git the leading copy
|
||||
```
|
||||
|
||||
There is no `origin`, so `git push` without a remote is ambiguous here. Say which remote you mean, in commands and in prose.
|
||||
|
||||
## Signed commits are enforced
|
||||
|
||||
Branch protection on `main` requires a signature, and signatures verify on Gitea. A commit that is not signed does not reach `main`.
|
||||
|
||||
Check before opening the PR, not at merge time:
|
||||
|
||||
```bash
|
||||
git log --show-signature -1
|
||||
```
|
||||
|
||||
If `op-ssh-sign` fails, stop and report. Pushing unsigned work only moves the failure to a place where it costs a review.
|
||||
|
||||
## No merge queue — auto-merge takes its place
|
||||
|
||||
Gitea has no merge queue. Its place is taken by **auto-merge on green checks**: a PR is armed once and merges itself when the required checks pass.
|
||||
|
||||
This does not change who merges. **The user merges.** An agent never merges its own PR and never arms auto-merge on it.
|
||||
|
||||
## A required check per chart is not possible
|
||||
|
||||
Gitea runs a whole matrix **as one job under one status context**. The install-test therefore reports once for all charts, and the context name carries the *unexpanded* matrix expression:
|
||||
|
||||
```
|
||||
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request)
|
||||
```
|
||||
|
||||
Required-check names have to be read off a real run; they cannot be copied from GitHub. Relevant whenever branch protection is touched.
|
||||
|
||||
## Issue numbers changed with the move
|
||||
|
||||
- **`#n` inside this repo's files means the GitHub tracker.** Most of those numbers name *closed* issues, which were not migrated and exist only there.
|
||||
- **The issues open at the time are Gitea #5–#21 and #24.** The mapping is a comment on Gitea #14.
|
||||
- **Gitea silently resolves a bare `#n` against its own counter.** An old reference can therefore render as a link to something unrelated — on Gitea, `#22` is an unrelated throwaway pull request, not the install-test issue the README means.
|
||||
- **When you write a new reference, use the Gitea number.**
|
||||
|
||||
## Renovate runs outside this repository
|
||||
|
||||
As the shared `jlab-renovate-bot` instance, which also serves the other repositories there. It reads our `renovate.json5`, and its `postUpgradeTasks` hook is allow-listed in the *instance* config.
|
||||
|
||||
**Do not add a Renovate workflow here.** Gitea has no app model, so a scheduled job in the repo looks like the only way to run the bot — it is not, and a second runner against the same repo produces duplicate PRs and two schedulers rewriting each other's branches. Details and the evidence: [Renovate](Renovate).
|
||||
|
||||
## What a Gitea runner does differently
|
||||
|
||||
All of it measured on this instance, not taken from documentation. The full list with numbers is in [CI/CD](CI-CD#what-a-gitea-runner-does-differently); the short version:
|
||||
|
||||
- **Reusable workflows with `secrets: inherit` work.** But a *missing* secret fails **job creation**, not the job — the run then shows no steps at all, which looks like something else entirely.
|
||||
- **Runner labels mean different machines.** `ubuntu-latest` has no Docker socket here; `ubuntu-docker` has one. Only the install-test cares.
|
||||
- **A matrix runs concurrently inside one job**, sharing one Docker daemon. That is what makes per-leg resource names mandatory.
|
||||
- **`aquasecurity/setup-trivy` cannot be used** — it checks out from github.com with a Gitea token and fails with a message naming neither the action nor the token.
|
||||
- **Every job is itself a container**, so a published port on the Docker host is not reachable as written.
|
||||
|
||||
## The wiki is a separate git repository
|
||||
|
||||
```bash
|
||||
git clone ssh://git@git.jk-dev.de:30022/jlab-cluster/helm-charts.wiki.git
|
||||
```
|
||||
|
||||
Flat layout, one Markdown file per page, `_Sidebar.md` for navigation. Editing it is a required step of the [workflow](Workflow#the-wiki-is-part-of-the-work), not an afterthought.
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# helm-charts — knowledge base
|
||||
|
||||
Everything about this repository that is not the code itself: how to install each chart, what every value does, how the pipelines work, which rules are binding, and which of them were learned the hard way.
|
||||
|
||||
The repo lives on the Gitea instance **JLab-Git**. Issues, pull requests, CI and Renovate all run there; github.com is a push mirror with Actions and Renovate switched off.
|
||||
|
||||
## Start here
|
||||
|
||||
| I want to… | Page |
|
||||
|---|---|
|
||||
| install or configure one of the charts | [Chart index](Charts) |
|
||||
| know how work gets done here (issues → plan → agent → PR → merge) | [Workflow](Workflow) |
|
||||
| know which forge, which CLI, which remote, signed commits | [Forge and tooling](Forge-and-Tooling) |
|
||||
| understand the PR pipeline, the install-test, the render test | [CI/CD](CI-CD) |
|
||||
| understand how chart versions are numbered and bumped | [Versioning](Versioning) |
|
||||
| write or review a chart | [Chart style guide](Chart-Style-Guide) |
|
||||
| add a new chart | [Creating a new chart](New-Chart) |
|
||||
| know how dependency updates arrive | [Renovate](Renovate) |
|
||||
| render, lint or install a chart on my own machine | [Local development](Local-Development) |
|
||||
| read what went wrong before, so it does not go wrong again | [Lessons learned](Lessons-Learned) |
|
||||
|
||||
## What this repo is
|
||||
|
||||
A collection of self-made Helm charts — **one directory per chart** — published to a private, classic (index-based) Helm registry by Gitea Actions. Publishing is an HTTP `POST` of the packaged `.tgz`, not an OCI `helm push`. The registry URL and its credentials live in 1Password and appear nowhere in the repository.
|
||||
|
||||
Two properties shape almost everything else:
|
||||
|
||||
- **A push to `main` that touches a chart directory publishes that chart.** The chart version must therefore be bumped in the same change, and the PR pipeline enforces it.
|
||||
- **Secrets are never in the repo.** Charts render `OnePasswordItem` CRs; the 1Password operator materializes the Kubernetes Secrets in-cluster. Helm never sees their content, which is why some restart mechanics do not work — see [style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload).
|
||||
|
||||
## Two conventions about this wiki
|
||||
|
||||
- **`Chart.yaml` is authoritative for versions.** Version numbers quoted on chart pages are snapshots from the day the page was written; Renovate moves app versions continuously and does not edit this wiki.
|
||||
- **`#n` in the repo's files means the GitHub tracker**, not Gitea. Numbers changed with the move — see [Forge and tooling](Forge-and-Tooling#issue-numbers-changed-with-the-move).
|
||||
|
||||
## Keeping this wiki current is a required step
|
||||
|
||||
Not a nice-to-have: it is step 8 of the [workflow](Workflow) and is written into `CLAUDE.md`. A change that alters values, behaviour, conventions or pipelines updates the affected page in the same round of work — see [what obliges an edit and what does not](Workflow#the-wiki-is-part-of-the-work).
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
# Lessons learned
|
||||
|
||||
Every rule in the [style guide](Chart-Style-Guide) and every check in [CI](CI-CD) was bought. This page is the receipt.
|
||||
|
||||
They share a shape: **the broken state looked exactly like the working one.** That is why the fixes are structural — a rule, a check, a rename — rather than "be careful next time".
|
||||
|
||||
## The config that changed nothing
|
||||
|
||||
A ConfigMap was updated. Nothing restarted, because a changed ConfigMap does not change the pod template — and `subPath` mounts, the usual form here, are never refreshed by the kubelet at all. `minecraft-router` kept routing to Services that no longer existed. Every bundle was green, the pod was Ready, and the routing table in memory was from the previous week.
|
||||
|
||||
**Now:** every pod template consuming a ConfigMap carries `checksum/config`, and adding it to an existing chart is a major bump. See [§9](Chart-Style-Guide#9-config-changes-must-restart-the-workload).
|
||||
|
||||
## The readiness probe that could not fail
|
||||
|
||||
`urlaubsverwaltung` probed `/actuator/health/liveness` and `/actuator/health/readiness` for **two years**. The split was textbook. Then the database went down and the pod stayed **1/1 Ready** through the whole outage, serving errors.
|
||||
|
||||
Spring Boot's `readiness` group contains `readinessState` **alone**. The paths were right; the group behind one of them was empty of anything external.
|
||||
|
||||
**Now:** whatever aggregates behind a probe path — Spring health groups, a framework-assembled `/healthz` — is read off the **running** endpoint, not off the path name. The chart names the members explicitly, so the composition lives in the template rather than in an upstream default. See [§12.3](Chart-Style-Guide#12-values-that-must-not-lie).
|
||||
|
||||
## The value that meant its own opposite
|
||||
|
||||
`satisfactory` shipped `game.seasonalEvents`, passed straight into the image's `DISABLESEASONALEVENTS`. The default `true` switched the events **off** — in every install, for the chart's entire life — while the schema described the key as "true means events are enabled".
|
||||
|
||||
The tempting fix is to invert the template. That would silently change behaviour under existing releases. The loud fix is a **rename**: the old key then fails schema validation instead of quietly meaning the opposite.
|
||||
|
||||
Two mechanics come with it: Go's `not` treats **every non-empty string as true**, so `not "false"` is `false`; and a value readable as neither boolean belongs in a `fail`, not in an implicit "off". See [§12.6](Chart-Style-Guide#12-values-that-must-not-lie).
|
||||
|
||||
## The quantity that was silently unusable
|
||||
|
||||
Resource quantities were formatted with `printf "%v"`, and the `float64` the fallback helpers produce flips to exponential notation from about `1e6`. The bug had a loud half and a silent half.
|
||||
|
||||
Loud: the reformatted quantity carries `e+06`, the helper rejects it, the render exits non-zero. Silent: the *product* of a fractional `limitFactor` and a bare number is reformatted **after** the suffix check, so the render succeeds and the manifest carries `memory: "1.4999985e+06"` — rejected by nothing until the API server sees it at apply time.
|
||||
|
||||
**Now:** the [quantity render test](CI-CD#3-quantity-render-every-chart-every-pr) runs two cases over **every** chart on **every** PR, and the second one searches the rendered text rather than the exit code. It covers all charts because the helper is copied per chart, so the regression arrives by copy — in a PR where the copied bug is in no diff anyone reads.
|
||||
|
||||
## The override that deleted what it was supposed to configure
|
||||
|
||||
An override written without children is YAML null, and null **erases** the block: `securityContext: null` renders an unhardened container that is healthy and silent; a probe simply disappears; `resources` loses its requests and its computed limits.
|
||||
|
||||
Wrapping the block in `{{- if }}` is the same bug with the evidence removed — a guard does not render a visible `null`, it removes the probe entirely.
|
||||
|
||||
**Now:** probes, security contexts and resources go through the null-safe helper, whose semantics are the opposite — an empty block means "chart defaults apply", while an explicitly set value still wins, `false` and `0` included. See [§12.2](Chart-Style-Guide#12-values-that-must-not-lie).
|
||||
|
||||
## The strict schema that had to meet reality first
|
||||
|
||||
A schema draft demanded a boolean for `tikaGotenberg.enabled`. All three live paperless releases write it as the string `"true"`. Rolling that out would have broken them at upgrade time.
|
||||
|
||||
**Now:** every schema is validated against the **real** values — the cluster bundle *and* `helm get values` — not only against chart defaults, which are valid by construction and prove nothing. The result per chart is a list of rejected keys: the cluster side's work order before the version is pulled up.
|
||||
|
||||
## The green tick for a chart nothing was attempted on
|
||||
|
||||
Excluded charts used to get an install-test job that ran the script, printed `SKIP` and exited 0. The forge knows only the exit code, so the check list showed `Install-test satisfactory ✓` — indistinguishable from a chart that really came up in a cluster.
|
||||
|
||||
**Now:** excluded charts are removed **before** the matrix is built, so no job exists. And because an absence says nothing on its own, the excluded charts of a PR are written into the job summary **with their documented justification** — "why" is the question a review actually asks. See [CI/CD](CI-CD#excluded-charts-are-dropped-from-the-matrix-not-skipped-inside-it).
|
||||
|
||||
## The check a broken cluster passed
|
||||
|
||||
`kubectl get nodes` exits **0 on an empty list**. A readiness check written that way went green against a cluster whose node had never registered; the failure surfaced five minutes later, somewhere unrelated.
|
||||
|
||||
`kubectl wait --for=condition=Ready nodes --all` is not the shorter way to write it — with zero nodes the condition is vacuously satisfied.
|
||||
|
||||
**Now:** assert the thing you mean — *at least one node, and Ready*. Generalized: **a check that a broken system passes is worse than no check**, because it also buys confidence.
|
||||
|
||||
## A generous environment is no friend
|
||||
|
||||
The k3d cluster name was hard-coded as `install-test`. On GitHub each matrix leg gets its own VM with its own Docker daemon, so two legs never saw each other's cluster and the bug could not show. On Gitea all legs share one daemon: one leg logs `Re-using existing network`, the other dies on `The container name /k3d-install-test-server-0 is already in use`, and the first leg's rollback then deletes the shared network out from under the second. Two red legs, and neither message names the cause.
|
||||
|
||||
**The bug was wrong on GitHub too.** For two years the roomier environment carried it instead of showing it.
|
||||
|
||||
**Now:** one cluster per matrix leg, named after the chart, with a length check. And the general form: **when you want to know whether something is correct, a generous environment is no friend.**
|
||||
|
||||
## The publish that uploaded nothing and went green
|
||||
|
||||
`curl` exits **0** on HTTP 401, 403 and 409. Unlike `helm push`, a failed upload therefore left the job green with nothing published.
|
||||
|
||||
**Now:** `--fail-with-body` — non-zero exit, and the response body still printed so the log names the cause. Two neighbours of the same step: credentials go in via `--config -` rather than `--user` so they stay out of `argv`, and the file name is **built** rather than globbed, because `--upload-file` takes one file and a glob matching two silently turns the second into curl's URL argument.
|
||||
|
||||
## The secrets that were loaded and then empty
|
||||
|
||||
After the bump to `load-secrets-action` **v5**, every `${{ env.* }}` reference in later steps resolved to an empty string — while the loading step still logged `Populating variable` and stayed green. v5 no longer exports the values as environment variables for later steps the way v4 did.
|
||||
|
||||
**Now:** values are read via `steps.<id>.outputs.*`, mapped into the consuming step's own `env:`, quoted, and guarded with `:?` — because an empty value used to collapse into a *missing argument* rather than an error.
|
||||
|
||||
## The update rule that was valid but matched nothing
|
||||
|
||||
A Renovate rule that is syntactically fine and matches nothing produces no PRs — and no PRs is also what "everything is current" looks like. The `mediathekarr` digest rule sharpens it: **while the pin is current, a matching rule and a broken one produce the identical empty result.**
|
||||
|
||||
**Now:** a new rule is verified with two dry-run fixtures — the real value (expect: no update) and a deliberately stale one (expect: exactly one update back to the pinned value). Anchored patterns are documented as what they are: reordering or renaming the keys they match fails nothing, it silently stops matching.
|
||||
|
||||
## The comparison that read a hash as a number
|
||||
|
||||
Renovate's regex versioning parses `major`, `minor`, `patch`, `build` and `revision` with `Number.parseInt`. A commit hash captured as `build` therefore becomes whatever digits lead it: `9fea41204` is `9`, `777ba8fa4` is `777`, a hash starting with a letter is `NaN`. Renovate offered an image pushed *earlier the same day* as an update to a newer one — unattended, under the patch/minor automerge rule.
|
||||
|
||||
**Now:** **a capture group is a claim about how the value is compared.** Parts of a tag that carry no ordering stay outside every group. `prerelease` is not the escape hatch — it sorts correctly but marks every tag unstable, handing the chart to `ignoreUnstable`.
|
||||
|
||||
## The hook that was skipped without a word
|
||||
|
||||
`postUpgradeTasks` only runs when the command is allow-listed in the **instance** config. A missing entry is skipped **silently**, and every chart PR then stays red on the version check with nothing pointing at the cause.
|
||||
|
||||
**Now:** it is verified rather than assumed — the proof is a Renovate PR whose diff carries a `version:` change, because nothing but `ci/renovate-bump-chart.sh` writes that line. The allow-list is not visible from this repo, so if it is ever dropped the symptom here is red chart PRs with no explanation.
|
||||
|
||||
## The annotation that promised a restart it cannot perform
|
||||
|
||||
`operator.1password.io/auto-restart` enumerates exactly one workload type in the operator's source: `DeploymentList`. On a StatefulSet it is a **silent no-op** — verified against operator 1.12.0, with the generalized restart code present since 1.11.0 but the PR that would add StatefulSets still open.
|
||||
|
||||
Setting it there is **worse than not setting it**: it advertises a guarantee that does not exist. The affected charts document the manual `kubectl rollout restart` instead.
|
||||
|
||||
## The environment variable that was lost on upgrade, quietly
|
||||
|
||||
An `env` entry repeating a name the chart already sets behaves differently depending on **when** it arrives. At install both reach the pod and the user's wins. On an existing release Kubernetes merges `env` by name, the pair collapses to the chart's own value — and `helm upgrade` still reports success.
|
||||
|
||||
Measured: `helm get manifest` shows the variable twice while the live Deployment shows it once, carrying the chart's value.
|
||||
|
||||
**Now:** a chart whose `NOTES.txt` tells operators to override one of its own variables says that the override only takes at install time, and that a green upgrade proves nothing — the value belongs read back off the Deployment.
|
||||
|
||||
## The indexer that answered every search with nothing
|
||||
|
||||
MediathekArr fetches its ruleset once at start and never retries. Started before gluetun's tunnel was up, it answered every Newznab search with **0 results for over eleven minutes** — container up, Ready, nothing in the log. The failure case looked exactly like the healthy one.
|
||||
|
||||
Kubernetes cannot order two regular containers, and an init container waiting for gluetun would deadlock, since gluetun only starts once the init containers are done.
|
||||
|
||||
**Now:** a start gate in front of the image entrypoint polls gluetun's health server and **exits** when the timeout passes instead of starting anyway — a crash-looping container is visible, a silently empty indexer is not.
|
||||
|
||||
## The recurring shape
|
||||
|
||||
- **Green is a claim about the assertions that ran, not about the system.**
|
||||
- **A failure that looks like success is more expensive than a loud one** — which is why several of these fixes deliberately turn a silent state into a crash.
|
||||
- **The environment can hide a bug for years.** Portability work is also archaeology.
|
||||
- **When a rename and a silent behaviour change are both available, take the rename.** Schema validation failing loudly beats a value that quietly means the opposite.
|
||||
- **Write down what was measured, and say when something was not.** Half of this page exists because someone recorded a number instead of an impression.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Local development
|
||||
|
||||
Three levels of validation, cheapest first. The pipeline runs all three; doing them locally is how you avoid finding out in a PR.
|
||||
|
||||
## 1. Render and lint
|
||||
|
||||
```bash
|
||||
helm lint --strict <chart>
|
||||
helm template <chart> --set ingress.domain=example.com --set ingress.clusterIssuer=letsencrypt
|
||||
```
|
||||
|
||||
**Required values vary per chart**, so the render usually needs more `--set` flags than these two — `minecraft-router` needs `mappings`, `samba` needs shares and a storage class, `freesailarr` needs media claims. The fastest way to find out is to run it: `required()` fails with the values path in the message.
|
||||
|
||||
A shortcut that keeps you honest: render with the chart's own CI values, which are a realistic input rather than the defaults.
|
||||
|
||||
```bash
|
||||
helm template <chart> -f ci/<chart>/test-values.yaml
|
||||
```
|
||||
|
||||
**If the chart declares a dependency**, every helm command exits 1 until `charts/` is filled:
|
||||
|
||||
```bash
|
||||
ci/helm-dependencies.sh <chart>
|
||||
```
|
||||
|
||||
## 2. Run the repo's own checks
|
||||
|
||||
```bash
|
||||
ci/run-quantity-render-test.sh <chart> # no argument = every chart
|
||||
ci/run-trivy-config.sh <chart>
|
||||
ci/run-install-test.sh <chart> # needs a cluster, see below
|
||||
ci/run-install-test.sh --list-excluded # the excluded charts and their reasons
|
||||
```
|
||||
|
||||
The quantity render test is worth running on any chart you touched the resources helper in — it is the one check that covers every chart on every PR, so a regression there is found by someone else's PR otherwise.
|
||||
|
||||
## 3. Runtime verification in k3d
|
||||
|
||||
Rendering proves the manifests are well-formed. It does not prove the workload starts, the probes pass, or the hardening is survivable — and in this repo those are the parts that break.
|
||||
|
||||
```bash
|
||||
k3d cluster create <name>
|
||||
ci/run-install-test.sh <chart>
|
||||
k3d cluster delete <name>
|
||||
```
|
||||
|
||||
The script does the rest: it applies the stub `OnePasswordItem` CRD and the chart's fixtures, installs with `ci/<chart>/test-values.yaml`, and waits for every Deployment and StatefulSet to roll out.
|
||||
|
||||
Doing it by hand is the same four steps:
|
||||
|
||||
1. `kubectl apply -f ci/common/` — the schema-less stub CRD for `OnePasswordItem`
|
||||
2. `kubectl apply -f ci/<chart>/fixtures.yaml` — dummy secrets, throwaway postgres/redis, hostPath PVs
|
||||
3. `helm install <release> <chart> -f ci/<chart>/test-values.yaml`
|
||||
4. watch pod readiness and events, then read the container log
|
||||
|
||||
**Note what the stub CRD does and does not do.** It accepts `OnePasswordItem` objects so the render applies; it does not materialize any Secret. The fixtures supply the Kubernetes Secrets under the names the chart expects. A chart that works in CI therefore proves its wiring, not its 1Password paths.
|
||||
|
||||
## What CI does that your machine does not
|
||||
|
||||
- **`kubectl` is pinned** in the pipeline to stay within one minor of the k3s image. Locally, a client far from the API server changes what the test *means* — check `kubectl version` before trusting a surprising result.
|
||||
- **The install-test job is containerised**, which is why the workflow rewrites the kubeconfig server address. A local k3d cluster on your own Docker host needs none of that.
|
||||
- **Timeouts differ per chart** via `ci/<chart>/settings.env` (`INSTALL_TIMEOUT`). `freesailarr` needs 1200s — ten containers, several GB of images and a real Wireguard handshake.
|
||||
|
||||
## What cannot be verified locally
|
||||
|
||||
- **The publish step.** It uploads to the private registry; there is no dry run. The pipeline is the first place a packaging mistake shows.
|
||||
- **1Password resolution.** Charts render `OnePasswordItem` CRs, and only a cluster with the operator turns them into Secrets. A wrong `op://` path renders fine and fails as a pod in `CreateContainerConfigError`.
|
||||
- **Anything about the real cluster's storage.** RWX, storage classes and `fsGroup` behaviour differ between k3d's local-path provisioner and the production cluster. A chart that mounts an existing claim is only half-tested by CI.
|
||||
|
||||
## Tooling you need
|
||||
|
||||
| Tool | Why |
|
||||
|---|---|
|
||||
| `helm` | render, lint, package |
|
||||
| `k3d` + `docker` | throwaway clusters |
|
||||
| `kubectl` | everything after the install |
|
||||
| `python3` | the quantity render test reads `values.schema.json` |
|
||||
| `op` + `tea` | issues and PRs — see [Forge and tooling](Forge-and-Tooling) |
|
||||
| `op-ssh-sign` | signed commits, which branch protection requires |
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# Creating a new chart
|
||||
|
||||
Copy `template-chart/`, replace every `xxx`, adapt what the template cannot know, then wire up CI. The order matters: the wiring steps are the ones that fail silently if forgotten.
|
||||
|
||||
## 1. Copy the scaffold and replace `xxx`
|
||||
|
||||
```bash
|
||||
cp -r template-chart <chart-name>
|
||||
```
|
||||
|
||||
`xxx` appears in five places, and missing one of them leaves a chart that renders but does not do what it says:
|
||||
|
||||
- **template file names** — `templates/xxx.deployment.yaml` → `templates/<app>.deployment.yaml`
|
||||
- **`Chart.yaml`** — name, description, `icon:` (mandatory), version, appVersion
|
||||
- **`values.yaml`** — the top-level keys
|
||||
- **the templates** — every `.Values.xxx.*` reference
|
||||
- **`values.schema.json`** — both the values paths **and** the `xxx.` helper prefixes
|
||||
|
||||
## 2. Adapt what the template cannot know
|
||||
|
||||
The schema and the null-safe fallback helpers come with the template, but their **content is chart-specific and wrong until adapted**. Walk the six points of [§12 Values that must not lie](Chart-Style-Guide#12-values-that-must-not-lie):
|
||||
|
||||
1. Does anything outside the Ingress read `ingress.domain`? Then that read needs its own `required()`.
|
||||
2. Probes, security contexts and resources go through the null-safe helper — never behind an `if`.
|
||||
3. Liveness must not hang on an external dependency where a dependency-free path exists; readiness **must** hang on it, and a separate path is not proof that it does.
|
||||
4. The fallbacks reproduce **this** chart's defaults, deviations included, not the repo standard.
|
||||
5. Probe defaults carry the probe's context — port names, `Host` headers, `exec` commands.
|
||||
6. A value that reads as the negation of the variable it feeds must actually be negated, and Go's `not` cannot do it.
|
||||
|
||||
**Validate the schema against the chart's real bundle values, not only against its defaults.** Defaults are valid by construction, so they prove nothing.
|
||||
|
||||
`templates/NOTES.txt` also comes as a worked example and is placeholder text until replaced. Its leading `{{/* … */}}` block explains the bar and is meant to be deleted. What it must print: the rendered access URL, **which fields the 1Password item must carry**, the options actually active in this release, and the chart's known traps — all conditional on the values really set.
|
||||
|
||||
## 3. Wire up CI — four steps, each silent when skipped
|
||||
|
||||
1. **`.github/workflows/publish-<chart-name>.yml`** — copy an existing one (e.g. `publish-outline.yml`). It triggers on pushes to `main` touching `<chart-name>/**` and calls the reusable `publish-helm-chart.yml`. Without it the chart is never published, and nothing complains.
|
||||
2. **`.github/workflows/change-app-version.yml`** — add the chart to the `chart-name` choice list.
|
||||
3. **`ci/<chart-name>/`** — install-test fixtures: `test-values.yaml` (required), plus `fixtures.yaml`, `fixtures.env.yaml` and `settings.env` where the chart needs them. Alternatively an entry **with justification** in `ci/excluded-charts.txt`. The install-test fails if both are missing. See [CI/CD](CI-CD#install-test-fixtures).
|
||||
4. **`renovate.json5`** — a `customManagers` block for the chart's main image. Without it the app version is never updated. Check the image's tag list first: a flavour, date or prerelease suffix needs a `versioningTemplate` **with a comment saying why**, and floating tags like `latest`/`stable` belong outside the pattern. See [Renovate](Renovate#a-capture-group-is-a-claim-about-how-the-value-is-compared).
|
||||
|
||||
## 4. Only if the chart needs a dependency
|
||||
|
||||
Read [§14 Chart dependencies](Chart-Style-Guide#14-chart-dependencies) first — it is a bar, not a formality. Nothing has to be added to the pipelines (`ci/helm-dependencies.sh` already runs everywhere and keys off the `dependencies:` block), but the subchart's updates then depend entirely on Renovate's built-in `helmv3` manager plus a chart-version bump. **Write that down in a comment at the pinned version.**
|
||||
|
||||
## 5. Validate before opening the PR
|
||||
|
||||
```bash
|
||||
helm lint --strict <chart-name>
|
||||
helm template <chart-name> --set ingress.domain=example.com --set ingress.clusterIssuer=letsencrypt
|
||||
```
|
||||
|
||||
Required values vary per chart, so the second command usually needs more `--set` flags. Then verify at runtime in a throwaway k3d cluster — see [Local development](Local-Development).
|
||||
|
||||
## 6. Document it
|
||||
|
||||
A new chart needs a [wiki page](Workflow#the-wiki-is-part-of-the-work) in the same round of work: copy the structure of [outline](outline), add a row to the [chart index](Charts) and an entry to `_Sidebar.md`. A chart that exists without a page is one nobody else can install without reading its templates.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] every `xxx` replaced, file names included
|
||||
- [ ] `icon:` set in `Chart.yaml`
|
||||
- [ ] `values.schema.json` adapted, `additionalProperties: false` on every chart-owned level
|
||||
- [ ] null-safe fallbacks reproduce this chart's own defaults
|
||||
- [ ] probes: liveness, readiness, startup, with calibrated budgets
|
||||
- [ ] hardening defaults, every deviation commented in `values.yaml`
|
||||
- [ ] `replicas` per workload and the global `scaleDown`
|
||||
- [ ] `checksum/config` on every pod template consuming a ConfigMap
|
||||
- [ ] additive `env` block per container, rendered through `tpl`
|
||||
- [ ] `NOTES.txt` rewritten, no real domain
|
||||
- [ ] publish workflow added
|
||||
- [ ] `change-app-version.yml` choice list extended
|
||||
- [ ] CI fixtures or a justified exclusion
|
||||
- [ ] `renovate.json5` block added
|
||||
- [ ] `helm lint --strict` clean, render verified, k3d install verified
|
||||
- [ ] wiki page, chart index row, sidebar entry
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# Renovate
|
||||
|
||||
## Where it runs
|
||||
|
||||
As the shared **`jlab-renovate-bot`** instance on the Gitea side, which also serves the other repositories there. It reads our `renovate.json5` and its `postUpgradeTasks` hook is allow-listed in the *instance* config.
|
||||
|
||||
> **Do not add a Renovate workflow to this repository.** It is the obvious thing to reach for — Gitea has no app model, so a scheduled job in the repo looks like the only way to run the bot — and it is wrong here: a second runner against the same repo produces duplicate PRs and two schedulers rewriting each other's branches. This paragraph exists so the next reader who finds no workflow knows why instead of building one.
|
||||
|
||||
The `jlab-renovate` GitHub App that used to do this is switched off along with the rest of GitHub Actions.
|
||||
|
||||
**There must be exactly one config file.** `renovate.json5` in the repo root — JSON5 for the inline reasoning, and it requires Renovate ≥ 41 for `managerFilePatterns`/`customType`. Do not add a second `renovate.json` or `.github/renovate.json`.
|
||||
|
||||
## What it keeps current
|
||||
|
||||
| What | How |
|
||||
|---|---|
|
||||
| Chart app versions (`appVersion:` in `Chart.yaml`) | one `customManagers` entry per chart, hard-coding the image repository |
|
||||
| Sidecar and init images in `values.yaml` (nginx, guacd, busybox, alpine) | built-in `helm-values` manager |
|
||||
| **All** images of `freesailarr`, its main one included | built-in `helm-values` manager — the chart pins every tag in `values.yaml` and has no app-version block. Exception: `mediathekarr` is pinned by **digest** and has its own `customManagers` block |
|
||||
| The oauth2-proxy subchart of `ytdlp-web-player` | built-in `helmv3` manager — repository URL and version sit together in `Chart.yaml`, so no custom manager is needed |
|
||||
| GitHub Actions | built-in `github-actions` manager |
|
||||
| Tool versions pinned as workflow env vars (`TRIVY_VERSION`) | a `# renovate: datasource=… depName=…` comment above the line |
|
||||
|
||||
**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 Renovate will not follow — bump it by hand when the chart moves a major.
|
||||
|
||||
## Why the mapping lives in `renovate.json5`
|
||||
|
||||
Each chart renders its main image as `<repo>:{{ .Chart.AppVersion }}` — repository in the template, version in `Chart.yaml`. Renovate cannot join two files, so the mapping is one block per chart in the config.
|
||||
|
||||
It is deliberately *not* a `# renovate:` comment inside each `Chart.yaml`: that would touch all chart directories and force a version bump plus a publish per chart for a pure comment change.
|
||||
|
||||
## A capture group is a claim about how the value is compared
|
||||
|
||||
The rule every `versioningTemplate` override shares, learned from `searxng` and worth reading before writing the next one.
|
||||
|
||||
Renovate's regex versioning parses `major`, `minor`, `patch`, `build` and `revision` with `Number.parseInt`, 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 — unattended, under the patch/minor automerge rule.
|
||||
|
||||
**Parts of a tag that carry no ordering belong outside every capture 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`.
|
||||
|
||||
Charts whose tags carry a flavour or date suffix override `versioning` in their block, with the reasoning next to each: `apache-tika`, `minecraft-server`, `rss-bridge`, `searxng`, `teamspeak-server`.
|
||||
|
||||
**Floating tags belong outside the pattern.** `ytdlp-web-player` anchors its regex on the `v` prefix precisely so `latest` and `stable` — both of which move — can never be selected.
|
||||
|
||||
## The one digest pin
|
||||
|
||||
`freesailarr`'s `mediathekarr` image is the only one pinned by digest, because its tags carry no version at all: `latest` is a 2024 build and the upstream README points at the moving `beta`. The built-in `helm-values` manager reads `repository`/`tag` and skips it (`skipReason: invalid-value`), so the two managers cannot produce competing PRs.
|
||||
|
||||
**A digest rule needs the same verification as a versioning rule, for a sharper reason: while the pin is current, a matching rule and a broken one produce the identical empty result.** It was 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.
|
||||
|
||||
The pattern is anchored on the three keys in their `values.yaml` order (`repository`, `tag`, `digest`). Reordering or renaming them fails nothing — it silently stops matching, and the image then never gets an update again.
|
||||
|
||||
## 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 version check. **`ci/renovate-bump-chart.sh <chart-dir>`** closes that gap as a `postUpgradeTasks` command:
|
||||
|
||||
- **app major changed** → chart major (`6.0.0` → `7.0.0`)
|
||||
- **anything else** (plain app update, sidecar image) → chart patch
|
||||
- the `+up` suffix is recomputed from the new `appVersion`, dashes normalized to dots
|
||||
|
||||
Everything is derived from the base commit, so it is idempotent across the multiple updates a single Renovate branch can carry.
|
||||
|
||||
> **`postUpgradeTasks` only runs when the command is allow-listed in the *instance* config** (`allowedCommands: ["^ci/renovate-bump-chart\\.sh"]`). Without that entry Renovate skips the task **without an error** and every chart PR stays red on the version check.
|
||||
|
||||
On the Gitea instance that entry **exists and was verified rather than hoped for**: the first run on the migrated repo produced a `searxng` PR whose diff carries `version: 1.0.4+up… → 1.0.5+up…` next to the `appVersion` change. Nothing but this hook writes `version:`, so that line can only come from it. The allow-list is not visible from this repo, though — if it is ever dropped, the symptom here is red chart PRs with no explanation.
|
||||
|
||||
A consequence of `freesailarr` pinning every tag in `values.yaml`: the script sees an unchanged `appVersion` and bumps the chart **patch**. That holds for all ten images alike, the digest-pinned one included, and it is the intended outcome — `appVersion` there is the chart's own marker rather than a tracked upstream version.
|
||||
|
||||
## Constrained dependencies
|
||||
|
||||
**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 normally. The constraint tolerates a variant suffix (`1.30.4-alpine`) on purpose: docker versioning only 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 — with no error and no warning.
|
||||
|
||||
**linuxserver/qbittorrent** 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. 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 guard was run: from `currentValue 4.6.4` the lookup still offers `5.2.3` as a major, so the filter does not silently empty the update list. The same fixture shows the cost of leaving it alone — without the rule the identical lookup offers `20.04.1` and never mentions the real `5.2.3`, since Renovate fills the major bucket only once.
|
||||
|
||||
## PR policy
|
||||
|
||||
- **Automerge** for `patch`, `minor` and `digest`, after a `minimumReleaseAge` of 3 days and only once the PR pipeline is green — install-test included, which is why that job no longer runs with `continue-on-error`. Minor is in scope because the sidecar images barely move in patch steps (`alpine 3.22` → `3.24`, `nginx 1.30.4` → `1.31.4` are both minor), so a patch-only rule would practically never fire.
|
||||
- **Renovate merges itself** (`platformAutomerge: false`) rather than handing the PR to the forge's own auto-merge, which on **both** GitHub and Gitea would merge immediately while `main` has no required status checks. That is a statement about the branch protection, not about the platform: once required checks exist, `platformAutomerge: true` becomes the better setting on either side.
|
||||
- **Majors are merged by hand**, and arrive one line at a time (`separateMultipleMajor`) — see [Versioning](Versioning#app-major-jumps-are-taken-one-line-at-a-time).
|
||||
- `prConcurrentLimit: 10` and `prHourlyLimit: 4` keep the k3d install-tests from all starting at once.
|
||||
|
||||
## `GITHUB_COM_TOKEN`
|
||||
|
||||
The instance needs a **read-only PAT** as `GITHUB_COM_TOKEN` for github.com datasource lookups. Every `github-releases`/`github-tags` lookup and the whole built-in `github-actions` manager resolve against github.com; the platform token is a Gitea one and is worth nothing there, and unauthenticated lookups hit the rate limit inside a single pass.
|
||||
|
||||
The symptom is a `Failed to look up github-releases package …: no-result` line on the dependency dashboard, **not an error** — so it is easy to read as a broken config here rather than as missing credentials there.
|
||||
|
||||
## Adding a chart to Renovate
|
||||
|
||||
A new chart needs its own `customManagers` block, or its app version is never updated. Check the image's tag list first: if it carries a flavour, date or prerelease suffix, add a `versioningTemplate` **with a comment saying why**. See [Creating a new chart](New-Chart).
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# Versioning
|
||||
|
||||
## The scheme
|
||||
|
||||
```yaml
|
||||
version: 1.4.0+up1.9.2
|
||||
appVersion: 1.9.2
|
||||
```
|
||||
|
||||
`version: <chart-semver>+up<appVersion>`. The `+up` suffix always matches `appVersion`, with **dashes normalized to dots**:
|
||||
|
||||
| `appVersion` | suffix |
|
||||
|---|---|
|
||||
| `1.9.2` | `+up1.9.2` |
|
||||
| `v1.159.0` | `+upv1.159.0` |
|
||||
| `0.0.36-beta` | `+up0.0.36.beta` |
|
||||
| `2026.8.2-java25` | `+up2026.8.2.java25` |
|
||||
|
||||
`appVersion` is the **exact image tag** of the main app image. That is what makes the suffix mechanical rather than a judgement call.
|
||||
|
||||
## Bump levels for the chart part
|
||||
|
||||
| Level | When |
|
||||
|---|---|
|
||||
| **Patch** | bug fixes without a behaviour or values-schema change; plain app-version updates |
|
||||
| **Minor** | additive, backward-compatible changes — a new optional value whose default leaves the render unchanged; a new or reworked `NOTES.txt` (it renders no Kubernetes object) |
|
||||
| **Major** | runtime behaviour changes or breaking values changes: probes, security hardening, computed limits, resource renames, a value that becomes required, adding `checksum/config` to an existing chart |
|
||||
|
||||
The major cases share one property: after the upgrade the cluster does something it did not do before. Adding a `checksum/config` annotation is the least obvious of them — from then on a config change costs a restart, and some workloads take minutes to come back. Name that cost in the PR.
|
||||
|
||||
## Every chart change must bump the version
|
||||
|
||||
A push to `main` touching a chart directory **publishes** that chart. The [`build-chart` job](CI-CD#2-build-chart-per-changed-chart) compares `version:` against the base branch and fails the PR if it is unchanged. `template-chart` is exempt — it is never published.
|
||||
|
||||
The registry accepts a re-upload of an existing version and replaces the artifact, so the version check is the only thing standing between a forgotten bump and a silently overwritten release.
|
||||
|
||||
## App-version updates go through PRs
|
||||
|
||||
So that the validate and install-test pipelines actually run. The `change-app-version.yml` dispatch workflow commits directly to `main` and bypasses both; it exists for one-off cases, not as the normal path. The normal path is a [Renovate](Renovate) PR.
|
||||
|
||||
## App-major jumps are taken one line at a time
|
||||
|
||||
A v1 → v3 jump is **two** PRs: first to the last release of the old major line, merged and published, then to the newest major. Renovate produces exactly that shape itself via `separateMultipleMajor`.
|
||||
|
||||
The reason is migrations: most apps support an upgrade from the previous major, not from any major. Skipping the intermediate release means the app's own migration path never runs, and the failure arrives inside a database that the new version has already started rewriting.
|
||||
|
||||
## Who writes the version
|
||||
|
||||
- **A human**, in any PR that changes the chart.
|
||||
- **`ci/renovate-bump-chart.sh`**, wired up as a Renovate `postUpgradeTasks` command: app major changed → chart major; anything else (plain app update, sidecar image) → chart patch; the `+up` suffix recomputed from the new `appVersion`. Everything is derived from the base commit, so it is idempotent across the several updates one Renovate branch can carry.
|
||||
|
||||
Nothing else writes `version:`. That is also how one can tell whether the hook ran at all — see [Renovate](Renovate#the-version-bump-hook).
|
||||
|
||||
## Frozen lines
|
||||
|
||||
`minecraft-server` is the one chart with published versions that will never be changed again: only the newest Java line is maintained out of the directory, and the older lines stay as frozen artifacts (java8 and java17 at `4.0.0`, java21 at `4.1.0`). Publishing several lines out of one directory is what made them drift in the first place. To run an older JVM with the current chart, set `image.tagOverride` rather than installing a frozen version — see [minecraft-server](minecraft-server).
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# Workflow
|
||||
|
||||
How work gets from an issue to a published chart. The binding short form lives in `CLAUDE.md`; this page is the long form with the reasons.
|
||||
|
||||
## The roles
|
||||
|
||||
The main Claude session is the **task manager**. It does not implement issues itself. Implementation happens in a dedicated **development agent** that owns one issue, one branch and one PR from start to merge.
|
||||
|
||||
The split exists so that planning stays reviewable: a plan that is written, approved and recorded before any code is written can be argued with. A plan discovered afterwards in a diff cannot.
|
||||
|
||||
## The eight steps
|
||||
|
||||
1. **Plan first, get approval.** The manager presents a plan for the issue and waits for explicit approval. No implementation before that.
|
||||
2. **Document the final plan on the issue.** Once approved, it is posted as a comment before implementation starts — skipped only if the issue body already contains the full plan. Later decisions go there as comments too, so the issue stays the record.
|
||||
3. **Delegate the implementation.** A development agent implements the issue completely on its own branch and opens a PR.
|
||||
4. **The agent handles PR comments.** It watches its PR, addresses new review comments with code changes, replies on the thread and marks the thread **resolved**.
|
||||
5. **The agent terminates itself** once its PR has been merged or closed.
|
||||
6. **The user merges.** Never merge yourself, and never arm auto-merge on your own PR. Only after the merge may work on the next issue begin.
|
||||
7. **Verify the linked issue was closed** after the merge. Gitea auto-closes on the same keywords as GitHub, but only for an issue in the **same repository** — a migrated issue whose number changed is closed by hand.
|
||||
8. **Update the wiki.** See below. It is part of the work, not a follow-up.
|
||||
|
||||
## The wiki is part of the work
|
||||
|
||||
This wiki is the knowledge base. A change that lands in `main` while the wiki still describes the old state has not been finished — it has been half-finished in a way that is invisible, because nothing fails.
|
||||
|
||||
**What obliges an edit, in the same round of work as the change:**
|
||||
|
||||
| Change | Page to update |
|
||||
|---|---|
|
||||
| a value added, removed, renamed, or its default changed | the chart's page — the values table and, if the behaviour moved, the operating notes |
|
||||
| runtime behaviour changed (probes, hardening, restart semantics, required values) | the chart's page, plus [Lessons learned](Lessons-Learned) if the reason is one others could repeat |
|
||||
| a new chart | a new chart page, plus a row in the [chart index](Charts) and an entry in `_Sidebar.md` |
|
||||
| a convention added or changed | [Chart style guide](Chart-Style-Guide) |
|
||||
| pipeline, fixture or runner behaviour changed | [CI/CD](CI-CD) |
|
||||
| the issue workflow itself changed | this page — **and** `CLAUDE.md`, which is the binding copy |
|
||||
| a Renovate rule added or changed | [Renovate](Renovate) |
|
||||
| something broke in a way that a rule now prevents | [Lessons learned](Lessons-Learned) |
|
||||
|
||||
**What does not oblige an edit:** a plain app-version bump. Renovate automerges those continuously and does not touch the wiki; chart pages therefore quote versions as snapshots and point at `Chart.yaml` as the source of truth. If an app-version bump *does* change behaviour or values, it is no longer a plain bump and the table above applies.
|
||||
|
||||
**Where the duty sits in the workflow:** the development agent updates the wiki as part of its issue, before asking for the merge, so that the reviewer sees code and documentation together. The manager checks it at step 7, along with the issue closure.
|
||||
|
||||
**How to edit.** The wiki is a separate git repository:
|
||||
|
||||
```bash
|
||||
git clone ssh://git@git.jk-dev.de:30022/jlab-cluster/helm-charts.wiki.git
|
||||
# edit the .md files, one page per file, flat layout
|
||||
git -C helm-charts.wiki commit -am "outline: document the new upload ceiling"
|
||||
git -C helm-charts.wiki push
|
||||
```
|
||||
|
||||
Page names are file names: `outline.md` is the page `outline`. Links between pages are relative page names without the extension — `[CI/CD](CI-CD)`. `_Sidebar.md` is the navigation shown beside every page; a new page that is not listed there is reachable only by search.
|
||||
|
||||
## Rules for every commit, PR, issue and comment
|
||||
|
||||
- **No `Co-Authored-By` trailer and no "Generated with Claude Code" footer.**
|
||||
- **Commits must be signed.** Verify the signature before opening the PR. If `op-ssh-sign` fails, stop and report rather than pushing unsigned work — branch protection rejects unsigned commits on `main`, and discovering that at merge time wastes the review.
|
||||
- **Write new issue references with the Gitea number.** `#n` inside the repo's files means the old GitHub tracker, and Gitea silently resolves a bare `#n` against its own counter — so an old reference can render as a link to something unrelated. See [Forge and tooling](Forge-and-Tooling#issue-numbers-changed-with-the-move).
|
||||
|
||||
## Documentation that is not the wiki
|
||||
|
||||
- **`CLAUDE.md`** — the binding short form of this workflow. Changes to the workflow go here first; this page follows.
|
||||
- **`README.md`** — repository information for someone reading the code: what the charts are, how the pipelines work, the style guide. Any PR that changes repo-wide behaviour updates it in the same PR, and it links here for the long form.
|
||||
- **`NOTES.txt` per chart** — what an operator needs *after* an install, rendered against the values actually set. It is the per-release version of a chart page and is required for every chart.
|
||||
|
||||
The division: `CLAUDE.md` binds the agent, `README.md` explains the repository, the wiki explains the system, `NOTES.txt` explains one release.
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
### helm-charts wiki
|
||||
|
||||
- [Home](Home)
|
||||
- [Chart index](Charts)
|
||||
|
||||
**How we work**
|
||||
- [Workflow](Workflow)
|
||||
- [Forge and tooling](Forge-and-Tooling)
|
||||
- [Local development](Local-Development)
|
||||
|
||||
**How it is built**
|
||||
- [CI/CD](CI-CD)
|
||||
- [Versioning](Versioning)
|
||||
- [Renovate](Renovate)
|
||||
|
||||
**How charts are written**
|
||||
- [Chart style guide](Chart-Style-Guide)
|
||||
- [Creating a new chart](New-Chart)
|
||||
- [Lessons learned](Lessons-Learned)
|
||||
|
||||
**Charts**
|
||||
- [apache-tika](apache-tika)
|
||||
- [bentopdf](bentopdf)
|
||||
- [calibre-web-automated](calibre-web-automated)
|
||||
- [cyberchef](cyberchef)
|
||||
- [freesailarr](freesailarr)
|
||||
- [gotenberg](gotenberg)
|
||||
- [homeassistant](homeassistant)
|
||||
- [matrix-synapse](matrix-synapse)
|
||||
- [minecraft-router](minecraft-router)
|
||||
- [minecraft-server](minecraft-server)
|
||||
- [outline](outline)
|
||||
- [paperless-ngx](paperless-ngx)
|
||||
- [patchmon](patchmon)
|
||||
- [rallly](rallly)
|
||||
- [rss-bridge](rss-bridge)
|
||||
- [samba](samba)
|
||||
- [satisfactory](satisfactory)
|
||||
- [searxng](searxng)
|
||||
- [teamspeak-server](teamspeak-server)
|
||||
- [template-chart](template-chart)
|
||||
- [urlaubsverwaltung](urlaubsverwaltung)
|
||||
- [voucher-vault](voucher-vault)
|
||||
- [ytdlp-web-player](ytdlp-web-player)
|
||||
- [zipline](zipline)
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
# apache-tika
|
||||
|
||||
Deploys the [Apache Tika](https://tika.apache.org/) server, a content-extraction service that turns documents into text and metadata. Deployment and Service only — **no Ingress, and none should be added**. In this cluster it is a backend for `paperless-ngx`, which points `PAPERLESS_TIKA_ENDPOINT` at its Service.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `10.0.1+up3.3.1.0.full` |
|
||||
| App version | `3.3.1.0-full` (image `apache/tika`) |
|
||||
| Workloads | Deployment (1 replica) |
|
||||
| Storage | none — one emptyDir on `/tmp` |
|
||||
| Ingress | **no** — ClusterIP Service only |
|
||||
| External dependencies | none |
|
||||
| Secrets | none |
|
||||
| Install-test | `ci/apache-tika/` (empty test values, no fixtures) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
None beyond a cluster. No vault item, no database, no volume, no ingress controller. The chart is installable with zero values.
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install tika jlab/apache-tika \
|
||||
--namespace documents --create-namespace
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information — including the effective memory limit and the heap derived from it — against the values actually set. Read it — it is the per-release version of this page.
|
||||
|
||||
### How it is reached, and by whom
|
||||
|
||||
There is no Ingress and no certificate. The server is cluster-internal, on a ClusterIP Service:
|
||||
|
||||
```
|
||||
http://<release>-apache-tika-service.<namespace>.svc.cluster.local
|
||||
```
|
||||
|
||||
**Mind the port.** The Service listens on **80** and forwards to the container's **9998**, so consumers are configured *without* a port — e.g. paperless-ngx:
|
||||
|
||||
```yaml
|
||||
PAPERLESS_TIKA_ENDPOINT: http://<release>-apache-tika-service.<namespace>.svc.cluster.local
|
||||
```
|
||||
|
||||
From your own machine:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> port-forward svc/<release>-apache-tika-service 9998:80
|
||||
curl http://localhost:9998/version
|
||||
curl http://localhost:9998/parsers
|
||||
```
|
||||
|
||||
`/parsers` lists every parser the running server actually has — the honest answer to "why did this file come back empty".
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0` without uninstalling. Takes precedence over `tika.replicas`. The Service keeps its ClusterIP but loses its endpoints, so callers fail with "connection refused" rather than a timeout. |
|
||||
| `global` | object | unset | Helm's conventional cross-chart values. Unread by this chart; kept open in the schema so an umbrella install cannot break on it. |
|
||||
|
||||
### `tika`
|
||||
|
||||
Note the key: the values section is `tika`, not the chart name `apache-tika`.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `tika.replicas` | int | `1` | Replica count; `0` is allowed. The server is stateless, so raising it is safe and is the way to serve concurrent extractions. |
|
||||
| `tika.securityContext.pod` | object | non-root `35002:35002`, `seccompProfile: RuntimeDefault` | Passed verbatim into the pod spec; `35002` is the dedicated user of the official image. The profile was verified against a **real extraction**, not just a reachable port: the `-full` image loads its parsers dynamically, so a filter that blocked one would show up as HTTP 200 with empty text rather than as an error. |
|
||||
| `tika.securityContext.container` | object | `readOnlyRootFilesystem: true`, `runAsNonRoot: true`, all caps dropped, no privilege escalation | Passed verbatim into the container spec. Tika writes temporary files while parsing, so `/tmp` is a writable emptyDir. |
|
||||
| `tika.livenessProbe` | object | `GET /` on port `tika-port`, period 10s, timeout 5s, 3 failures | Tika answers `GET /` on its server port with 200; no external dependency hangs on it. |
|
||||
| `tika.readinessProbe` | object | `GET /` on port `tika-port`, period 10s, timeout 5s, 3 failures | |
|
||||
| `tika.startupProbe` | object | `GET /` on port `tika-port`, period 5s, timeout 5s, 30 failures (= 150s) | Gates liveness and readiness. |
|
||||
| `tika.env` | list | `[]` | Extra environment variables. Rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. **Additive, not overriding** — see [Environment variables](Chart-Style-Guide#10-environment-variables). |
|
||||
| `tika.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. The memory limit is also what sizes the JVM heap — see below. |
|
||||
| `tika.resources.requests` | object | `cpu: 0.1`, `memory: 512Mi`, `ephemeralStorage: 10Mi` | |
|
||||
| `tika.resources.limits` | object | unset (computed: `memory: 1536Mi`, `ephemeral-storage: 30Mi`) | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
|
||||
An erased block (`tika.securityContext:` with no children, a `null` probe, a `null` `resources`) falls back to the chart's own defaults instead of disappearing — [style guide §12.2](Chart-Style-Guide#12-values-that-must-not-lie).
|
||||
|
||||
This chart has no `ingress` section at all: there is no `ingress.domain` and no `ingress.clusterIssuer` to set.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### No authentication — and it parses whatever it is sent
|
||||
|
||||
Tika has no authentication and this chart adds none. It is a parser for untrusted input by nature, and its parsers have a long history of being the soft spot. **Anything that can reach the Service can make this pod open a file.** Keep it cluster-internal; do not put an Ingress in front of it.
|
||||
|
||||
### The image tag has to keep its `-full` suffix
|
||||
|
||||
`appVersion` is pinned to `3.3.1.0-full`. The `-full` variant is the one that ships Tesseract OCR (5.5.0, with the languages `deu`, `eng`, `fra`, `ita`, `jpn`, `osd`, `spa`) plus the extra parsers; the plain `apache/tika:<version>` image has no tesseract binary at all. Overriding the tag without `-full` **does not fail loudly** — OCR simply stops happening and scanned documents come back with empty text.
|
||||
|
||||
That is also why Renovate's block for this chart overrides `versioning`: the four-part version plus flavour suffix is parsed by an explicit regex whose `compatibility` group keeps the chart on `-full`. See [Renovate](Renovate).
|
||||
|
||||
### The JVM only gets a quarter of the memory limit
|
||||
|
||||
The server starts without `-Xmx`, so the JVM derives its maximum heap from the **container limit** at `MaxRAMPercentage=25`. With the default limit of `1536Mi` that is roughly **384Mi** of usable heap — the number to reason about when sizing, not the limit itself. Raising `tika.resources.requests.memory` raises the limit (`limitFactor` × request) and the heap along with it.
|
||||
|
||||
Text extraction streams rather than buffering, so ordinary documents stay cheap regardless. The heap matters for parsers that have to build a whole document in memory, and for OCR.
|
||||
|
||||
### An empty result is a parser question, not a server question
|
||||
|
||||
A 200 with empty text is the normal shape of every failure mode here: a missing parser, a tag without `-full`, a scanned file without OCR. `curl /parsers` against the running pod lists what it actually has; `/version` confirms which image is behind the Service.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
Nothing to back up and nothing to migrate: no volume, no database, no state that survives a pod. An app-version bump is an image swap, and `helm rollback` carries no data risk. The one thing to check after a bump is that the new tag still carries `-full`, and that `/parsers` still lists what the consumers rely on.
|
||||
|
||||
## CI
|
||||
|
||||
`ci/apache-tika/test-values.yaml` is literally `{}` — no secrets, no ingress, no persistence — and there is no `fixtures.yaml`. The install-test waits for the Deployment to roll out; since the probes are the server's own port, "Ready" means the server answered. See [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements
|
||||
- [Versioning](Versioning) — what a bump of `10.0.1+up3.3.1.0.full` means, and why the `+up` suffix normalizes the dash
|
||||
- [gotenberg](gotenberg) — the second half of the paperless-ngx document pipeline
|
||||
- [Chart index](Charts)
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
# bentopdf
|
||||
|
||||
Deploys [BentoPDF](https://github.com/alam00000/bentopdf), a browser-side PDF toolbox, as a stateless Deployment behind nginx. The pod serves static files and nothing else — every one of the roughly 130 tools runs in the visitor's browser.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `1.0.2+up2.8.8` |
|
||||
| App version | `2.8.8` (image `ghcr.io/alam00000/bentopdf-simple`) |
|
||||
| Workloads | Deployment (1 replica) |
|
||||
| Storage | none — two emptyDirs for nginx, no PVC |
|
||||
| Ingress | yes, `ingress.domain` + `ingress.clusterIssuer` required while enabled |
|
||||
| External dependencies | none in the cluster; the browser fetches some WASM modules from the internet |
|
||||
| Secrets | none — no `OnePasswordItem` |
|
||||
| Install-test | `ci/bentopdf/` (test values only, no fixtures) |
|
||||
|
||||
The `-simple` image variant is deliberate: it is built from the same Dockerfile as `ghcr.io/alam00000/bentopdf` with the `SIMPLE_MODE` build argument, which strips the marketing surface of the commercial site.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller. Both are only needed while `ingress.enabled` is `true` — unlike most charts here, nothing outside the Ingress template reads them.
|
||||
- **An ingress controller that passes the two cross-origin headers through.** Without them the Office conversions break; see [Two headers decide whether Office conversion works](#two-headers-decide-whether-office-conversion-works).
|
||||
- No database, no cache, no 1Password item, no volume. This chart has no secrets at all.
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install bentopdf jlab/bentopdf \
|
||||
--namespace bentopdf --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
domain: pdf.example.com
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0` without uninstalling. Takes precedence over `bentopdf.replicas`. Service and Ingress stay, so the certificate is not re-issued. |
|
||||
| `global` | object | unset | Helm's conventional cross-chart values. Unused by this chart, kept open in the schema so an umbrella install cannot break on it. |
|
||||
|
||||
### `bentopdf`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `bentopdf.replicas` | int | `1` | Replica count; `0` is allowed. The app is stateless, so raising it is safe. |
|
||||
| `bentopdf.config` | object / null | `{}` | Body of `/config.json`, passed through verbatim as JSON. Today upstream reads `disabledTools` and `editorDisabledCategories`; the chart does not interpret either, so a key upstream adds tomorrow is a values change here, not a chart change. See [Disabling tools](#disabling-tools-is-the-one-runtime-lever). A scalar is rejected with an error naming the values path. |
|
||||
| `bentopdf.securityContext.pod` | object | non-root 101, `runAsGroup: 101`, `seccompProfile: RuntimeDefault` | Passed into the pod spec. The seccomp profile sits here on purpose: it covers every container including init containers. |
|
||||
| `bentopdf.securityContext.container` | object | `readOnlyRootFilesystem: true`, `runAsNonRoot: true`, all caps dropped, no privilege escalation | Passed into the container spec. `readOnlyRootFilesystem: false` is the deliberate escape hatch for the three image env switches — see [Three environment variables that kill the pod](#three-environment-variables-that-kill-the-pod). |
|
||||
| `bentopdf.livenessProbe` | object | `GET /:http`, period 10s, timeout 5s, 3 failures | The image has no health endpoint; `/` is the only path answering 200 without a special rule. |
|
||||
| `bentopdf.readinessProbe` | object | `GET /:http`, period 10s, timeout 5s, 3 failures | |
|
||||
| `bentopdf.startupProbe` | object | `GET /:http`, period 5s, timeout 5s, 30 failures (= 150s) | Gates liveness and readiness. |
|
||||
| `bentopdf.env` | list | `[]` | Extra environment variables. Rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. **The served bundle reads none of them** — they reach only nginx and its entrypoint scripts. See [Environment variables](Chart-Style-Guide#10-environment-variables). |
|
||||
| `bentopdf.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `bentopdf.resources.requests` | object | `cpu: 0.1`, `memory: 128Mi`, `ephemeralStorage: 16Mi` | Measured in k3d at 1m CPU / 2Mi working set, idle and while serving the 48 MB LibreOffice WASM asset. The requests sit far above that on purpose: `gzip_comp_level 9` compresses at request time for anything without a pre-compressed twin, and that cost scales with concurrency, which a one-pod run cannot show. |
|
||||
| `bentopdf.resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
|
||||
Probes, security contexts and resources are null-safe: an erased block means "chart defaults apply", not "no probe" or "unhardened container".
|
||||
|
||||
### `ingress`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | Gates the whole Ingress template. Switching it off frees `domain` and `clusterIssuer` — this chart has no second reader of either (upstream's `BASE_URL` counterpart is a build-time argument and empty in the published image). |
|
||||
| `ingress.domain` | string | `null` | **Required while `ingress.enabled` is true.** Host of the Ingress and of its TLS entry. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. The place for a `X-Robots-Tag` header — see [A public instance gets indexed](#a-public-instance-gets-indexed). |
|
||||
|
||||
TLS is stored in `tls-<release>-bentopdf-ingress`.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### A green pod only proves delivery
|
||||
|
||||
All three probes hit `/`, and what they prove is that nginx is handing out `index.html`. They say nothing about the tools: BentoPDF computes entirely in the visitor's browser, and not one of its tools touches this pod. A healthy release is a delivery receipt, not a statement about whether PDF processing works.
|
||||
|
||||
### Two headers decide whether Office conversion works
|
||||
|
||||
The image sends `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: credentialless`. Together they make the page `crossOriginIsolated`, which is the precondition for `SharedArrayBuffer` — and without `SharedArrayBuffer` the LibreOffice-backed conversions (Word, Excel, PowerPoint) do not work. An ingress controller is a reverse proxy and therefore a place where those headers get lost or overwritten. The chart cannot enforce them; it only names the failure picture, because that picture points at the application while the cause sits in the ingress: Office conversion fails, everything else keeps working. Check through the ingress, not at the pod:
|
||||
|
||||
```bash
|
||||
curl -sI https://pdf.example.com/ | grep -i cross-origin
|
||||
```
|
||||
|
||||
The second trap of the same kind: some controllers override the MIME type for `.mjs` files, which leaves the app stuck on a module loading error. The image itself maps `mjs` to `application/javascript`.
|
||||
|
||||
### Disabling tools is the one runtime lever
|
||||
|
||||
`bentopdf.config` is rendered into a ConfigMap and `subPath`-mounted at `/usr/share/nginx/html/config.json`. Four things are worth knowing before debugging it:
|
||||
|
||||
- **The ConfigMap is always rendered**, even for an empty config, which serializes to `{}` — byte-identical to the `config.json` the image ships. A file that sometimes exists and sometimes does not would mean the path through checksum, mount and `subPath` is normally never exercised and first fails when someone actually uses it.
|
||||
- **The mount replaces the image's file.** Whatever stands under `bentopdf.config` is the complete file, not a patch on top of it.
|
||||
- **`disabledTools` takes tool IDs**, and a tool id is its page URL without the `.html` — `merge-pdf`, `encrypt-pdf`, `word-to-pdf`. The full list is deliberately not copied into the chart, because a copy is wrong from the next upstream release on; it lives in `docs/self-hosting/docker.md` upstream. **A misspelled id disables nothing and reports nothing** — there is no validation anywhere, so an unknown id is simply an id that matches no tool.
|
||||
- **`editorDisabledCategories` is hierarchical.** Disabling a parent disables all its children, so `annotation` also takes out `annotation-highlight`, `annotation-ink`, `annotation-stamp` and the rest. That is where more disappears than expected.
|
||||
|
||||
The pod template carries `checksum/config`, so a changed config rolls the pods ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)). That is mandatory rather than nice to have: `subPath` mounts are never refreshed by the kubelet.
|
||||
|
||||
What the chart cannot control is the browser. The image's nginx serves `.json` with `Cache-Control: max-age=604800`, so a visitor who already fetched the old file can keep using it for up to seven days. Where a change has to be visible immediately, reload hard, or override the caching for that one path in the ingress. To see what is actually being served:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> exec deploy/<release>-bentopdf-deployment -- wget -qO- localhost:8080/config.json
|
||||
```
|
||||
|
||||
### Three environment variables that kill the pod
|
||||
|
||||
`PORT`, `DISABLE_IPV6` and `ROBOTS_NOINDEX` are all implemented as `sed -i` against files in the image — the first two against `/etc/nginx/nginx.conf`, the third against every `.html` file in the web root. The scripts run under `set -e` inside `/docker-entrypoint.d/`, and the base image's entrypoint aborts when one fails. With `readOnlyRootFilesystem: true` each of them produces `sed: can't create temp file ...: Read-only file system` and exit 1, which in Kubernetes is a `CrashLoopBackOff` without a single line from nginx. Measured against 2.8.7.
|
||||
|
||||
None of the three is offered as a first-class value, because a value that looks like it works and instead kills the pod is worse than no value. Whoever really needs one sets it under `bentopdf.env` **and** `bentopdf.securityContext.container.readOnlyRootFilesystem: false` — a deliberate, visible decision. The container port stays 8080 either way; the Service maps onto it by name.
|
||||
|
||||
### A public instance gets indexed
|
||||
|
||||
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 publicly reachable instance gets indexed, and its pages carry a canonical link to the vendor's site (`SITE_URL` is a build-time argument defaulting to the vendor domain). `ROBOTS_NOINDEX` cannot run with a read-only root filesystem, so the ingress is the only lever left, for example:
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/configuration-snippet: |
|
||||
add_header X-Robots-Tag "noindex, nofollow";
|
||||
```
|
||||
|
||||
Documented as an example, not set as a default. Mind that ingress-nginx disables snippet annotations by default since v1.9 (`allow-snippet-annotations: false`); where that stands, the controller-wide `add-headers` ConfigMap is the alternative.
|
||||
|
||||
### "Runs in the browser" is not "no outbound connections"
|
||||
|
||||
Several tools fetch their WASM modules from `cdn.jsdelivr.net` at runtime (pymupdf-wasm, gs-wasm, coherentpdf), and the navbar fetches a star count from `api.github.com` on page load. The image's own CSP allows exactly these sources. The PDFs themselves never leave the device — what is loaded is program code, not documents.
|
||||
|
||||
### What this chart cannot do
|
||||
|
||||
Almost everything else about BentoPDF is a **build-time** argument and therefore out of reach for any chart: `BASE_URL` (running under a sub-path), `SIMPLE_MODE`, `VITE_BRAND_NAME` / `VITE_BRAND_LOGO` / `VITE_FOOTER_TEXT`, `VITE_DEFAULT_LANGUAGE`, `SITE_URL`, `DISABLE_GITHUB_STARS` and all the WASM/OCR source URLs. Changing any of them needs a self-built image; this repository publishes charts, not images.
|
||||
|
||||
### Non-root without sysctls
|
||||
|
||||
The image is based on `quay.io/nginx/nginx-unprivileged`: nginx runs as uid/gid 101 and listens on 8080, so the workload is fully non-root with no sysctl tweaks (same situation as `cyberchef`). Two emptyDirs keep the root filesystem read-only: `/etc/nginx/tmp` (the pid file) and `/var/cache/nginx` (**not** optional — nginx creates its temp directories at startup and the image ships the directory without them, so the master process aborts with `mkdir() "/var/cache/nginx/client_temp" failed`). `/var/run` is deliberately not mounted, unlike in the upstream chart. The container port name `http` is load-bearing: Service and all three probes select it by name.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
Nothing to back up and nothing to migrate. No database, no volumes, no uploads — documents never leave the visitor's device, so app-version upgrades and `helm rollback` carry no data risk. Renovate tracks `ghcr.io/alam00000/bentopdf-simple` and pins the suffix-free tag family (`2.8.8`, not `v2.8.8` or `v2.8.8-amd64`), because that is the form `appVersion` can carry unchanged — see [Renovate](Renovate).
|
||||
|
||||
## CI
|
||||
|
||||
`ci/bentopdf/test-values.yaml` holds the two ingress values (`bentopdf.ci.local`, issuer `ci`) and — deliberately — a **non-empty** `bentopdf.config`. An empty config renders `{}`, which is byte-identical to the file the image already ships, so the install-test would pass without ever proving that the ConfigMap, the `subPath` mount into the web root and the `checksum/config` annotation work. There is no `fixtures.yaml`: nothing to stand up, no secrets (`cyberchef` is the precedent). The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements
|
||||
- [Versioning](Versioning) — what a bump of `1.0.2+up2.8.8` means
|
||||
- [Renovate](Renovate) — how the app version is tracked
|
||||
- [Lessons learned](Lessons-Learned)
|
||||
- [Chart index](Charts)
|
||||
@@ -0,0 +1,190 @@
|
||||
# calibre-web-automated
|
||||
|
||||
Deploys [Calibre-Web Automated](https://github.com/crocodilestick/Calibre-Web-Automated), a Calibre-Web fork that ingests, converts and files ebooks by itself, as a single-replica StatefulSet with two volumes: one ReadWriteOnce claim for config, library and plugins, and a small ReadWriteMany drop box for the ingest folder.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `6.1.0+upv4.0.6` |
|
||||
| App version | `v4.0.6` (image `ghcr.io/crocodilestick/calibre-web-automated`) |
|
||||
| Workloads | StatefulSet (1 replica) |
|
||||
| Storage | one RWO PVC (`cwa.storage`, mounted three times by `subPath`) + one **RWX** consume PVC fixed at `1Gi` |
|
||||
| Ingress | optional; `ingress.domain` + `ingress.clusterIssuer` required only while enabled |
|
||||
| External dependencies | none |
|
||||
| Secrets | 1 × `OnePasswordItem` (two fields) |
|
||||
| Install-test | `ci/calibre-web-automated/` (fixtures + test values, `INSTALL_TIMEOUT=600`) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **1Password operator** in the cluster, plus one 1Password item — see [the fields below](#what-the-1password-item-must-contain).
|
||||
- **A storage class that can provision ReadWriteMany.** Both PVCs share `cwa.storageClass`, and the ingest claim is RWX. The default is `longhorn`; on a cluster without it, set the value — a class that cannot serve RWX leaves the consume claim `Pending` and the pod with it.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller — only if the Ingress stays enabled.
|
||||
- **Time for the first boot.** Against an empty volume the app builds a Calibre library from scratch while the s6 init fixes ownership of `/config`; that first start takes minutes where every later one takes seconds.
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install cwa jlab/calibre-web-automated \
|
||||
--namespace cwa --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
cwaSecretRef: op://vault/cwa
|
||||
|
||||
cwa:
|
||||
storageClass: longhorn
|
||||
storage: 100Gi
|
||||
|
||||
ingress:
|
||||
domain: books.example.com
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
|
||||
|
||||
**The default login is public.** Username `admin`, password `admin123` — upstream's defaults, which this chart does not change and the 1Password item does not carry. Until it is changed under *Admin → Users*, everyone who reaches the address is one guess away from being administrator.
|
||||
|
||||
### What the 1Password item must contain
|
||||
|
||||
The chart renders one `OnePasswordItem`; the operator turns it into a Kubernetes Secret whose **keys are the field names of the 1Password item**. The field names are decided by this chart and appear nowhere in 1Password.
|
||||
|
||||
| Values key | Kubernetes Secret | Fields |
|
||||
|---|---|---|
|
||||
| `secrets.cwaSecretRef` | `<release>-calibre-web-automated-secret` | `hardcover-api-token` → the `HARDCOVER_TOKEN` environment variable; `calibre-plugins` → mounted as `/config/.config/calibre/customize.py.json`, Calibre's plugin registry (`{}` is a valid empty value) |
|
||||
|
||||
**Both fields are required even if Hardcover is never used**: the CR, the environment reference and the volume are created unconditionally. The two missing-field failures differ — a missing `hardcover-api-token` parks the pod in `CreateContainerConfigError`, a missing `calibre-plugins` fails the *mount* (`couldn't find key calibre-plugins`) and the container is never created at all. With `secrets.cwaSecretRef` unset the CR is created with an empty `itemPath`, the operator has nothing to resolve, the Secret never appears and the pod cannot start.
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `global` | object | unset | Helm's conventional cross-chart values. Unused by this chart; the schema keeps it open so an umbrella install cannot break on it. |
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0` without uninstalling. Takes precedence over `cwa.replicas`. Both PVCs, the Secret, the Service and the Ingress stay, so no data is lost and the certificate is not re-issued. |
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.cwaSecretRef` | string | `null` | **Required.** `op://` path of the item carrying `hardcover-api-token` and `calibre-plugins`. Not guarded by a `required` in the template — an unset value renders and installs, and the pod then never starts. |
|
||||
|
||||
### `cwa`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `cwa.replicas` | int | `1` | Replica count; `0` is allowed. `scaleDown` wins over it. Raising it above `1` buys nothing — see [Single-instance by construction](#this-release-is-single-instance-by-construction). |
|
||||
| `cwa.storage` | string | `10Gi` | Size of the library PVC `<chart>-<release>-cwa-pvc`. Deliberately **not** null-safe: an erased value yields an invalid PVC rather than a silently degraded workload. |
|
||||
| `cwa.storageClass` | string | `longhorn` | Storage class of **both** PVCs. The consume claim is RWX, so the class has to serve that mode. |
|
||||
| `cwa.timezone` | string | `"UTC"` | Passed to the image as `TZ`; decides log timestamps and when CWA's scheduled tasks run. |
|
||||
| `cwa.maxUploadFileSize` | string | `"10m"` | Rendered into the `nginx.ingress.kubernetes.io/proxy-body-size` and `nginx.org/client-max-body-size` annotations. It is an **Ingress** limit, not an application one: a bigger book is rejected with 413 by the controller and nothing about it reaches the app log. Both annotations are understood by nginx-family controllers only, and the value does nothing at all while `ingress.enabled` is `false`. |
|
||||
| `cwa.livenessProbe` | object | `GET /` on port `http`, period 10s, timeout 5s, 3 failures | See [What a Ready pod does not prove](#what-a-ready-pod-does-not-prove). |
|
||||
| `cwa.readinessProbe` | object | `GET /` on port `http`, period 10s, timeout 5s, 3 failures | |
|
||||
| `cwa.startupProbe` | object | `GET /` on port `http`, period 10s, timeout 5s, 30 failures (= 300s) | Gates liveness and readiness and is what times the first boot. On a slow volume the container is killed mid-scaffold and presents as `CrashLoopBackOff` — raise `failureThreshold` and watch the boot. |
|
||||
| `cwa.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `cwa.resources.requests` | object | `cpu: 0.1`, `memory: 512Mi`, `ephemeralStorage: 100Mi` | |
|
||||
| `cwa.resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
| `cwa.securityContext.pod` | object | `fsGroup: 1000`, `fsGroupChangePolicy: Always`, `seccompProfile: RuntimeDefault` | Passed verbatim into the pod spec. `fsGroup: 1000` is what matches the `PUID`/`PGID` the template pins — changing it on its own takes write access away from the app. The seccomp profile sits in the pod context on purpose (it covers init containers too) and is orthogonal to the read-only deviation below: it restricts syscalls, not mount flags. |
|
||||
| `cwa.securityContext.container` | object | `allowPrivilegeEscalation: false`, **`readOnlyRootFilesystem: false`**, caps drop `ALL` and add `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `KILL`, `SETGID`, `SETUID` | Passed verbatim into the container spec. A **documented deviation** from the repo hardening baseline — do not "restore" the standard here, see below. |
|
||||
| `cwa.env` | list | `[]` | Extra environment variables appended to the chart's own (`TZ`, `PUID`, `PGID`, `HARDCOVER_TOKEN`, `CALIBRE_CONFIG_DIR`, `NETWORK_SHARE_MODE`). Rendered through `tpl`; `value`, `secretKeyRef`, `configMapKeyRef` forms. **Additive, not overriding** — see [Environment variables](Chart-Style-Guide#10-environment-variables). |
|
||||
|
||||
The whole `resources` and `securityContext` blocks, and each probe, may also be `null` or empty: the chart then falls back to exactly the defaults above — **including the deviation**, which the fallbacks reproduce verbatim ([style guide §12](Chart-Style-Guide#12-values-that-must-not-lie)).
|
||||
|
||||
There is no value for the size of the ingest claim: it is fixed at `1Gi` inside the chart.
|
||||
|
||||
### `ingress`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | Gates the whole Ingress template. `ingress.domain` is read **nowhere else** in this chart, so switching the Ingress off makes both `domain` and `clusterIssuer` unnecessary. |
|
||||
| `ingress.domain` | string | `null` | **Required while `ingress.enabled` is true.** Host and TLS subject. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager and the two body-size annotations the chart always sets. |
|
||||
|
||||
TLS is stored in `tls-<release>-calibre-web-automated-ingress`. Without an Ingress the app is reachable in-cluster as `<release>-calibre-web-automated-service:80`, or locally via `kubectl -n <namespace> port-forward svc/<release>-calibre-web-automated-service 8083:80`.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### The hardening here is deliberately not the repo standard
|
||||
|
||||
The image boots through the s6-overlay init as **root** and only then drops the application to `PUID`/`PGID` 1000. Hence no `runAsNonRoot`/`runAsUser`, a writable root filesystem, and a `capabilities.add` that is not empty. Two of those look like oversights and are not:
|
||||
|
||||
- **`readOnlyRootFilesystem` must stay `false`.** In read-only mode the image switches its PUID/PGID remapping off. The application then runs as the built-in uid **911** instead of 1000 and no longer has access to what uid 1000 wrote — **an existing library goes quietly unreadable**, and the init additionally fails to write the ImageMagick policy under `/etc`. This is the worked example behind [style guide §12.4](Chart-Style-Guide#12-values-that-must-not-lie): a fallback reproduces *this chart's* defaults, not the repo standard, and "cleaning up" the deviation is an outage rather than tidying.
|
||||
- **`KILL` must stay in the capability set.** Without it the root supervisor cannot signal its own uid-1000 services; the pod stops shutting down cleanly and is SIGKILLed when the grace period ends.
|
||||
|
||||
`PUID` and `PGID` are pinned to `1000` in the template, not in the values. The pod's `fsGroup` default of `1000` is what matches them.
|
||||
|
||||
### Everything else shares one claim
|
||||
|
||||
`<chart>-<release>-cwa-pvc` (`cwa.storage` on `cwa.storageClass`) is mounted three times by `subPath`:
|
||||
|
||||
| `subPath` | Mount | Holds |
|
||||
|---|---|---|
|
||||
| `config/` | `/config` | application state, the CWA database |
|
||||
| `calibre-library/` | `/calibre-library` | the books and `metadata.db` |
|
||||
| `calibre-plugins/` | `/config/.config/calibre/plugins` | Calibre plugins |
|
||||
|
||||
A growing library and the application's own database therefore draw on one quota, and a full volume does not merely stop imports — it takes the app down. That claim is also the entire backup: `metadata.db` without the book files is an index of nothing.
|
||||
|
||||
Both PVC names are a **deliberate naming exception** to [§1 of the style guide](Chart-Style-Guide#1-naming): they keep the historical `<chart>-<release>-cwa-pvc` / `<chart>-<release>-cwa-consume-pvc` order, because renaming a PVC provisions a new empty volume and orphans the existing data.
|
||||
|
||||
### The ingest folder deletes what you put into it
|
||||
|
||||
`/cwa-book-ingest` is the ReadWriteMany claim `<chart>-<release>-cwa-consume-pvc`. Books copied in are imported, converted and then **removed** — upstream: "All files within this folder will be DELETED after being processed." It is a drop box, never a second copy of the library; put nothing there that does not exist somewhere else.
|
||||
|
||||
Downloading straight into it is unsupported for the reason it sounds convenient: a half-written file gets picked up, which upstream warns "can cause duplicate imports and potentially a corrupt database". Copy finished files in. The claim's size is fixed at `1Gi` in the chart — an import batch larger than that fills the volume, and raising it means changing the chart, not the values file.
|
||||
|
||||
### This release is single-instance by construction
|
||||
|
||||
The StatefulSet mounts both claims **by fixed name** instead of using `volumeClaimTemplates`, so every replica would mount the same volumes — and the library claim is ReadWriteOnce. A second pod stays `Pending` unless it happens to land on the node holding the volume, and where it does start, two CWA processes write one SQLite `metadata.db`.
|
||||
|
||||
### `NETWORK_SHARE_MODE` is pinned to `false`
|
||||
|
||||
That leaves SQLite write-ahead logging switched on. Upstream's instruction for the other case is explicit — "If you are deploying on a network share, set the following environment 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. Overriding it through `cwa.env` repeats a name the chart already sets, so it only takes effect at **install** time ([style guide §10](Chart-Style-Guide#10-environment-variables)).
|
||||
|
||||
### Changing the item's content restarts nothing
|
||||
|
||||
Helm never sees what is inside a `OnePasswordItem`, so it cannot hash it into the pod template ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)). Editing the item leaves `helm upgrade` with no change to make. Pointing `secrets.cwaSecretRef` at a **different** item does not help either here: the Kubernetes Secret's name is built from the release and chart name, so the pod spec stays byte-identical. Roll it by hand — and note that the operator's `operator.1password.io/auto-restart` annotation would be no substitute, since it only ever restarts Deployments and this is a StatefulSet:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout restart statefulset/<release>-calibre-web-automated-sfs
|
||||
```
|
||||
|
||||
For the plugin file that is the only way: it is mounted with `subPath`, and a `subPath` mount is never refreshed by the kubelet even when the Secret has been updated. `HARDCOVER_TOKEN` is an environment variable and is likewise read once, at start.
|
||||
|
||||
### What a Ready pod does not prove
|
||||
|
||||
All three probes `GET /`, and CWA answers that with its login page no matter what else is wrong. Ready therefore says the web server is up — not that the library was found, not that one import has ever succeeded, and not that the Hardcover token is accepted. Those show in the log, and only there:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> logs <release>-calibre-web-automated-sfs-0 -f
|
||||
```
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **Back up the library claim before an app-version bump.** It carries the books, `metadata.db` and the application state together; there is no external database to fall back on.
|
||||
- **`helm uninstall` deletes both PVCs** — they are ordinary resources of the release with no resource policy. With a `Delete` reclaim policy the library goes with them.
|
||||
- **The first start after a bump may exceed the startup budget** if the new version migrates its database or rebuilds the library; raise `cwa.startupProbe.failureThreshold` before, not after the crash loop.
|
||||
- Changing `cwa.securityContext.container.readOnlyRootFilesystem` to `true` is not an upgrade, it is data loss by uid change — see above.
|
||||
- App **major** jumps go stepwise: last release of the old major line first, then the newest major — see [Versioning](Versioning).
|
||||
|
||||
## CI
|
||||
|
||||
`ci/calibre-web-automated/` holds `test-values.yaml` (`op://` ref against the stub CRD, host `cwa.ci.local`, `storageClass: ci-static`, `storage: 2Gi`), `fixtures.yaml` (the dummy secret with both fields plus a no-provisioner StorageClass and two pre-bound static hostPath PVs — k3d's `local-path` provisioner cannot serve the RWX consume claim, and the PVs' `claimRef`s carry the historical PVC names) and `settings.env` with `INSTALL_TIMEOUT=600` for the library scaffold on first start. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements, and §12.4, whose worked example it is
|
||||
- [Lessons learned](Lessons-Learned) — the uid-911 finding in general form
|
||||
- [Versioning](Versioning) — what a bump of `6.1.0+upv4.0.6` means
|
||||
- [Renovate](Renovate) — how `appVersion` gets updated
|
||||
- [Chart index](Charts)
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# cyberchef
|
||||
|
||||
Deploys [CyberChef](https://github.com/gchq/CyberChef), the "Cyber Swiss Army Knife", as a stateless Deployment behind an Ingress. Everything the app does happens in the visitor's browser; the pod only serves static files. This is the smallest chart in the repo and the shortest path to seeing every convention of the [chart style guide](Chart-Style-Guide) in one place.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `9.0.1+up11.4.0` |
|
||||
| App version | `11.4.0` (image `ghcr.io/gchq/cyberchef`) |
|
||||
| Workloads | Deployment (1 replica) |
|
||||
| Storage | none — one emptyDir on `/tmp` for nginx |
|
||||
| Ingress | yes, `ingress.domain` + `ingress.clusterIssuer` required while `ingress.enabled` |
|
||||
| External dependencies | none |
|
||||
| Secrets | none |
|
||||
| Install-test | `ci/cyberchef/` (test values only, no fixtures) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller. With `ingress.enabled: false` neither is needed.
|
||||
- Nothing else. No database, no vault item, no volume — the two ingress values are the whole input of an installation.
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install cyberchef jlab/cyberchef \
|
||||
--namespace cyberchef --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
domain: cyberchef.example.com
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0` without uninstalling. Takes precedence over `cyberchef.replicas`. Service and Ingress stay, so the certificate is not re-issued. |
|
||||
| `global` | object | unset | Helm's conventional cross-chart values. Unread by this chart; kept open in the schema so an umbrella install cannot break on it. |
|
||||
|
||||
### `cyberchef`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `cyberchef.replicas` | int | `1` | Replica count; `0` is allowed. Nothing in this chart is stateful, so raising it is safe. |
|
||||
| `cyberchef.securityContext.pod` | object | non-root `101:101`, `seccompProfile: RuntimeDefault` | Passed verbatim into the pod spec. The seccomp profile sits here on purpose: it covers every container including init containers. |
|
||||
| `cyberchef.securityContext.container` | object | `readOnlyRootFilesystem: true`, `runAsNonRoot: true`, all caps dropped, no privilege escalation | Passed verbatim into the container spec. |
|
||||
| `cyberchef.livenessProbe` | object | `GET /` on port `http`, period 10s, timeout 5s, 3 failures | Port addressed by **name**, so a rebuilt default needs no further context. |
|
||||
| `cyberchef.readinessProbe` | object | `GET /` on port `http`, period 10s, timeout 5s, 3 failures | |
|
||||
| `cyberchef.startupProbe` | object | `GET /` on port `http`, period 5s, timeout 5s, 30 failures (= 150s) | Gates liveness and readiness. |
|
||||
| `cyberchef.env` | list | `[]` | Extra environment variables. Rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. **Additive, not overriding** — see [Environment variables](Chart-Style-Guide#10-environment-variables). Note [it changes nothing about the app](#cyberchefenv-reaches-nginx-not-cyberchef). |
|
||||
| `cyberchef.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `cyberchef.resources.requests` | object | `cpu: 0.1`, `memory: 512Mi`, `ephemeralStorage: 10Mi` | |
|
||||
| `cyberchef.resources.limits` | object | unset (computed: `memory: 1536Mi`, `ephemeral-storage: 30Mi`) | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
|
||||
An erased block (`cyberchef.securityContext:` with no children, a `null` probe, a `null` `resources`) falls back to the chart's own defaults instead of disappearing — [style guide §12.2](Chart-Style-Guide#12-values-that-must-not-lie).
|
||||
|
||||
### `ingress`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | `false` omits the Ingress entirely. Nothing outside the Ingress template reads `domain` or `clusterIssuer`, so both become unnecessary with it — the rare chart where the gate frees *both* values. |
|
||||
| `ingress.domain` | string | `null` | **Required while `ingress.enabled` is true.** Ingress host and TLS SAN. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. The place to bolt authentication on — see below. |
|
||||
|
||||
TLS is stored in `tls-<release>-cyberchef-ingress`.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### Anyone who reaches the URL can use it
|
||||
|
||||
CyberChef has no login and this chart adds none. If the instance must not be open to the internet, put authentication in front of it through `ingress.annotations` (ingress-nginx external auth or basic auth), or leave `ingress.enabled: false` and reach it with a port-forward:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> port-forward svc/<release>-cyberchef-service 8080:80
|
||||
```
|
||||
|
||||
### `cyberchef.env` reaches nginx, not CyberChef
|
||||
|
||||
The served bundle is static JavaScript: recipes and inputs are processed in the visitor's browser and never reach the server. Environment variables land in the nginx process that serves the files and are read by nothing else. The `NOTES.txt` says so explicitly whenever the list is non-empty, because the setting looks like it should work.
|
||||
|
||||
### Unprivileged nginx, and why `/tmp` is a volume
|
||||
|
||||
Since app version 11.0.0 the upstream image is built on `nginxinc/nginx-unprivileged`: nginx runs as its own `nginx` user (uid 101) and listens on **8080**, so no sysctl or capability is needed to run fully non-root. The Service maps 80 → the named container port `http`. nginx keeps its pid file and temp/cache directories under `/tmp`, which is mounted as an emptyDir because the root filesystem is read-only.
|
||||
|
||||
### Scaled to zero
|
||||
|
||||
With `scaleDown: true` (or `cyberchef.replicas: 0`) the Deployment runs at zero and the URL stops answering, while Service and Ingress remain — the certificate is not re-issued when you scale back up.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
There is nothing to back up and nothing to migrate. The pod holds no state, so an app-version bump is a plain image swap and `helm rollback` carries no data risk. Renovate follows `ghcr.io/gchq/cyberchef` through this chart's `customManagers` block with default docker versioning — no suffix, no override — see [Renovate](Renovate).
|
||||
|
||||
## CI
|
||||
|
||||
`ci/cyberchef/test-values.yaml` sets only `ingress.domain: cyberchef.ci.local` and `ingress.clusterIssuer: ci`; the Ingress merely has to apply, since the CI cluster runs neither ingress-nginx nor cert-manager. No `fixtures.yaml` exists — there is nothing to fake. Because it installs fast and depends on nothing, cyberchef is also the chart the README's measured install-test runtimes are taken against. See [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements, in their plainest form
|
||||
- [Versioning](Versioning) — what a bump of `9.0.1+up11.4.0` means
|
||||
- [Chart index](Charts)
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
# freesailarr
|
||||
|
||||
Deploys a Wireguard-tunneled media automation stack as a **single StatefulSet pod with ten containers**: [gluetun](https://github.com/qdm12/gluetun) as VPN gateway plus qBittorrent, Prowlarr, FlareSolverr, Radarr, Recyclarr, Sonarr, Seerr, Bazarr and (off by default) MediathekArr. Every app container shares gluetun's network namespace, they talk to each other over `127.0.0.1`, and their only route to the internet is the tunnel — gluetun's firewall is the kill switch.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `3.1.0+up1.0.0` |
|
||||
| App version | `1.0.0` — a hand-maintained marker, **not** the source of any image tag (see [Ten upstreams, no app version](#ten-upstreams-no-app-version)) |
|
||||
| Workloads | one StatefulSet, 1 replica, 10 containers (9 by default) + one init container |
|
||||
| Storage | one RWO `volumeClaimTemplate` per enabled app (config, 1Gi each) + up to three **pre-existing** media claims |
|
||||
| Ingress | two shapes that mix: one host per app (default) or one shared `ingress.host` with sub-paths. There is **no** `ingress.domain` |
|
||||
| External dependencies | a VPN account (Mullvad Wireguard by default), the media PVCs, optionally a Jellyfin for seerr |
|
||||
| Secrets | 1 × `OnePasswordItem` (gluetun credentials) |
|
||||
| Install-test | `ci/freesailarr/` — the only chart in the repo with a `fixtures.env.yaml` and a real tunnel |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **1Password operator** in the cluster and one item with the two Wireguard fields — see [the fields below](#what-the-1password-item-must-contain).
|
||||
- **A VPN subscription** whose Wireguard key and interface address are in that item. Defaults target Mullvad with an exit in Zurich.
|
||||
- **`/dev/net/tun` on the node.** gluetun mounts it as a `hostPath` CharDevice and runs as root with `NET_ADMIN`; a cluster policy that forbids either makes the pod unschedulable.
|
||||
- **The media claims exist already.** `media.<category>.existingClaim` is mounted, never created, and the claim's **root directory must be writable for `puid`/`pgid`** before the first start — see [The claim root, not fsGroup](#the-claim-root-not-fsgroup).
|
||||
- **The cluster pod and service CIDRs**, for `gluetun.vpn.firewallOutboundSubnets`. Without them the kill switch also blocks cluster-internal egress, starting with DNS.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for every host used, pointing at the ingress controller.
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install media jlab/freesailarr \
|
||||
--namespace media --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml` — one shared domain, one library claim with three sub-directories:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
gluetunSecretRef: op://vault/freesailarr-gluetun
|
||||
|
||||
gluetun:
|
||||
vpn:
|
||||
firewallOutboundSubnets: "10.42.0.0/16,10.43.0.0/16" # k3s pod + service CIDRs
|
||||
|
||||
media:
|
||||
downloads: { existingClaim: media-library, subPath: downloads }
|
||||
movies: { existingClaim: media-library, subPath: movies }
|
||||
tv: { existingClaim: media-library, subPath: tv-shows }
|
||||
|
||||
ingress:
|
||||
host: media.example.com
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set, including the computed startup budgets, the bazarr base-URL step and the warning when `firewallOutboundSubnets` is still empty. Read it — it is the per-release version of this page.
|
||||
|
||||
### What the 1Password item must contain
|
||||
|
||||
The chart renders one `OnePasswordItem` CR; the operator turns it into a Kubernetes Secret whose **keys are the field names of the 1Password item**. A missing field does not fail the install: gluetun starts with the variable empty and never completes a handshake, and every app container then sits behind a kill switch that blocks everything.
|
||||
|
||||
| Values key | Kubernetes Secret | Fields |
|
||||
|---|---|---|
|
||||
| `secrets.gluetunSecretRef` | `<release>-freesailarr-gluetun-secret` | `wireguard-private-key` (the Wireguard private key), `wireguard-addresses` (the interface address, e.g. `10.x.y.z/32`) |
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> get onepassworditem <release>-freesailarr-gluetun-secret
|
||||
```
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders the StatefulSet with `replicas: 0` without uninstalling. Takes precedence over `replicas`. PVCs, Secrets, Services and Ingresses stay. |
|
||||
| `replicas` | int | `1` | Replica count for the whole stack; `0` is allowed. See [One pod, not a stack of pods](#one-pod-not-a-stack-of-pods) before raising it. |
|
||||
| `puid` | int | `1000` | User the five linuxserver.io containers drop to after their s6 init (`PUID`), and `runAsUser` for seerr, recyclarr, mediathekarr and the qBittorrent init container. |
|
||||
| `pgid` | int | `1000` | The same as group (`PGID` / `runAsGroup`). Keep `securityContext.pod.fsGroup` in sync. |
|
||||
| `timezone` | string | `Europe/Zurich` | `TZ` in every container. |
|
||||
| `securityContext.pod` | object | `fsGroup: 1000`, `fsGroupChangePolicy: OnRootMismatch`, `seccompProfile: RuntimeDefault` | Merged onto those defaults and passed verbatim into the pod spec. Deliberately **no** pod-level `runAsNonRoot`/`runAsUser`: the pod mixes root containers (gluetun, linuxserver.io) with non-root ones; users are set per container. The seccomp profile sits here so every container, init containers included, inherits it. |
|
||||
| `storage.storageClass` | string | `null` | StorageClass for all config `volumeClaimTemplates`; `null` uses the cluster default. Not null-safe — an erased block yields an invalid claim template and fails loudly. |
|
||||
| `global` | object | `{}` | Helm's conventional cross-chart values. Unread by this chart, kept open so an umbrella install cannot break on it. |
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.gluetunSecretRef` | string | `null` | **Required, always** — gluetun renders unconditionally. `op://` path of the Wireguard item. |
|
||||
|
||||
### `media`
|
||||
|
||||
Mounted, never created. Each claim is **required as soon as an app that mounts it is enabled**; a category no enabled app mounts is not rendered at all and demands nothing.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `media.downloads.existingClaim` | string | `null` | **Required while qbittorrent, radarr, sonarr or mediathekarr is enabled.** Mounted at `/downloads` in all four. |
|
||||
| `media.downloads.subPath` | string | `null` | Sub-directory of the claim to mount instead of its root. |
|
||||
| `media.movies.existingClaim` | string | `null` | **Required while radarr or bazarr is enabled.** Mounted at `/movies`. |
|
||||
| `media.movies.subPath` | string | `null` | As above. |
|
||||
| `media.tv.existingClaim` | string | `null` | **Required while sonarr or bazarr is enabled.** Mounted at `/tv`. |
|
||||
| `media.tv.subPath` | string | `null` | As above. |
|
||||
|
||||
The same `existingClaim` may back two or all three categories — that is the intended one-library layout. The pod then gets **exactly one** volume entry for that claim, owned by the first *enabled* category in the fixed order `downloads, movies, tv`, and the categories differ only in their `subPath`. See [One claim, one volume entry](#one-claim-one-volume-entry) and [Hardlinks need one mount](#hardlinks-need-one-mount-not-one-claim).
|
||||
|
||||
### `ingress`
|
||||
|
||||
Defaults for all apps; `clusterIssuer`, `className` and `annotations` are overridable per app, where an explicitly set per-app value always wins (even an empty one). The shared object uses the global values only — it belongs to no single app.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | Master switch: `false` suppresses every app Ingress regardless of the per-app toggles. |
|
||||
| `ingress.host` | string | `""` | Empty = one host per app (shape 1). Set = shared domain with sub-paths (shape 2) for every app without a host of its own. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required for every Ingress object that renders** and is not overridden by an `<app>.ingress.clusterIssuer`. cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. The shared-domain qBittorrent object relies on nginx's `rewrite-target`. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. |
|
||||
|
||||
TLS secrets: `tls-<release>-freesailarr-<app>-ingress` per own-host object, `tls-<release>-freesailarr-ingress` for the shared one.
|
||||
|
||||
### Per-app keys
|
||||
|
||||
Nine app blocks (`qbittorrent`, `prowlarr`, `flaresolverr`, `radarr`, `recyclarr`, `sonarr`, `seerr`, `bazarr`, `mediathekarr`) plus `gluetun` repeat the same shape. What differs per app is in [the next table](#what-differs-per-app); gluetun's own extras and the two apps with additional keys have their own sections below.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `<app>.enabled` | bool | `true` (mediathekarr: `false`) | Renders this container, its config `volumeClaimTemplate`, its Service and its Ingress. gluetun has no such key — it always runs. Changing it after the first install needs [one manual step](#toggling-an-app-on-or-off-later). |
|
||||
| `<app>.image.repository` | string | per app | All ten images are pinned here, gluetun included; no tag comes from `appVersion`. This is also what puts them under Renovate's built-in `helm-values` manager. |
|
||||
| `<app>.image.tag` | string | per app | |
|
||||
| `<app>.ingress.enabled` | bool | `true` (mediathekarr: `false`) | Present only for apps that have an ingress at all. |
|
||||
| `<app>.ingress.host` | string | `null` | **Required while this app's ingress is enabled AND the shared `ingress.host` is not set.** Never demanded for a disabled ingress, never for an app served under the shared domain. Setting it always wins over the shared domain. |
|
||||
| `<app>.ingress.path` | string | `/<app>` | Sub-path in the shared-domain mode; ignored while this app has a host of its own. Null or empty falls back to `/<app>` — and to `/` for seerr and mediathekarr, which cannot be served anywhere else. |
|
||||
| `<app>.ingress.clusterIssuer` | string | unset | Overrides `ingress.clusterIssuer` for this app's own object. |
|
||||
| `<app>.ingress.className` | string | unset | Overrides `ingress.className`. |
|
||||
| `<app>.ingress.annotations` | object | unset | **Replaces** the global `ingress.annotations` for this app (it is an override, not a merge); the cert-manager annotation is always added. |
|
||||
| `<app>.storage.size` | string | `1Gi` | Size of this app's config `volumeClaimTemplate` (RWO). Not null-safe: an erased size fails the render. |
|
||||
| `<app>.env` | list | `[]` | Extra environment variables **appended** to the ones the chart sets. Rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. **Additive, not overriding** — see [Environment variables](Chart-Style-Guide#10-environment-variables). |
|
||||
| `<app>.livenessProbe` | object | per app, see table | Merged onto this container's own defaults; `null` restores them rather than dropping the probe. |
|
||||
| `<app>.readinessProbe` | object | per app | |
|
||||
| `<app>.startupProbe` | object | per app | Gates liveness and readiness. |
|
||||
| `<app>.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `<app>.resources.requests` | object | per app | `cpu`, `memory`, `ephemeralStorage` (camelCase is normalized to `ephemeral-storage`). |
|
||||
| `<app>.resources.limits` | object | unset | An explicit limit always wins. A **CPU limit is only rendered when set here.** |
|
||||
| `<app>.securityContext` | object | per app | Merged onto this container's hardening defaults. The defaults reproduce this chart's deliberate deviations — see [Hardening is not uniform here](#hardening-is-not-uniform-here). |
|
||||
|
||||
Probes, resources and security contexts are the "silent class": erasing one of those blocks with `key:` (i.e. `null`) falls back to the chart's own per-container defaults instead of rendering a container without a probe, without hardening or without resource accounting.
|
||||
|
||||
### What differs per app
|
||||
|
||||
| App | Port(s) | Config volume | Ingress | Default path | Probes (liveness / readiness / startup) | Requests (cpu / mem / eph) | Hardening |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `gluetun` | 9999 (health, not a Service) | yes, always | no | — | `GET /:9999` ×3; liveness `failureThreshold: 6`, readiness `3`, startup 5s × 30 = **150s** | 0.1 / 128Mi / 50Mi | root, `NET_ADMIN`, writable root fs |
|
||||
| `qbittorrent` | 8080 | yes | yes, on | `/qbittorrent` | `GET /:8080`; startup 5s × 30 = 150s | 0.2 / 512Mi / 100Mi | linuxserver.io set |
|
||||
| `prowlarr` | 9696 | yes | yes, on | `/prowlarr` | `GET /ping:9696`; startup 150s | 0.1 / 256Mi / 100Mi | linuxserver.io set |
|
||||
| `flaresolverr` | 8191 | **no** | **no ingress block at all** — consumed by prowlarr over localhost | — | `GET /health:8191`; startup 150s | 0.2 / 512Mi / 200Mi | non-root uid/gid 1000, writable root fs |
|
||||
| `radarr` | 7878 | yes | yes, on | `/radarr` | `GET /ping:7878`; startup 150s | 0.1 / 256Mi / 100Mi | linuxserver.io set |
|
||||
| `recyclarr` | **none** (daemon mode) → **no Service** | yes | **no ingress block** | — | `exec recyclarr --version`, period 30s / 30s / 5s, timeout 10s; startup 150s | 0.05 / 128Mi / 50Mi | non-root from `puid`/`pgid`, read-only root fs |
|
||||
| `sonarr` | 8989 | yes | yes, on | `/sonarr` | `GET /ping:8989`; startup 150s | 0.1 / 256Mi / 100Mi | linuxserver.io set |
|
||||
| `seerr` | 5055 | yes | yes, on | **`/` — pinned**, any other path fails the render | `GET /api/v1/settings/public:5055`; startup 10s × 30 = **300s** (first-boot migrations) | 0.1 / 256Mi / 100Mi | non-root from `puid`/`pgid`, read-only root fs |
|
||||
| `bazarr` | 6767 | yes | yes, on | `/bazarr` | `GET /:6767` (no unauthenticated health endpoint); startup 150s | 0.1 / 256Mi / 100Mi | linuxserver.io set |
|
||||
| `mediathekarr` | 5007 (primary, SABnzbd + UI) **and** 5008 (Newznab) on one Service | yes | yes, **off** by default | **`/` — pinned**, see above | liveness `exec` checking **both** ports, period 30s, timeout 15s; readiness/startup `GET /download/:5007`; startup 5s × 60 = **300s** | 0.1 / 256Mi / 100Mi | non-root from `puid`/`pgid`, read-only root fs |
|
||||
|
||||
"linuxserver.io set" = `allowPrivilegeEscalation: false`, `readOnlyRootFilesystem: false`, all capabilities dropped except `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `SETGID`, `SETUID`.
|
||||
|
||||
### `gluetun` extras
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `gluetun.vpn.serviceProvider` | string | `mullvad` | `VPN_SERVICE_PROVIDER`. |
|
||||
| `gluetun.vpn.type` | string | `wireguard` | `VPN_TYPE`. |
|
||||
| `gluetun.vpn.serverCities` | string | `Zurich` | `SERVER_CITIES`. Rendered only when non-empty. Also decides the geo answer for mediathekarr — see [The geo caveat](#the-geo-caveat-and-how-to-check-it-yourself). |
|
||||
| `gluetun.vpn.serverCountries` | string | `Switzerland` | `SERVER_COUNTRIES`. |
|
||||
| `gluetun.vpn.dnsUpstreamResolvers` | string | `quad9` | `DNS_UPSTREAM_RESOLVERS`. |
|
||||
| `gluetun.vpn.firewallOutboundSubnets` | string | `null` | Comma-separated CIDRs the firewall allows **outside** the tunnel. Not enforced by the chart, but effectively required in-cluster: without it cluster DNS lookups and seerr→Jellyfin traffic are dropped, looking like an application bug. `NOTES.txt` warns while it is empty. |
|
||||
|
||||
The chart additionally sets, always: `TZ`, `WIREGUARD_PRIVATE_KEY` / `WIREGUARD_ADDRESSES` from the Secret, `FIREWALL_INPUT_PORTS` (the enabled apps' ports plus 9999), and `HEALTH_SERVER_ADDRESS=:9999` so kubelet probes, which connect to the pod IP, reach the health server. gluetun's command first removes a stale `ip rule`/`tun0` left behind by an unclean exit, then execs the entrypoint.
|
||||
|
||||
### `qbittorrent` extras
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `qbittorrent.networkInterface` | string | `tun0` | Interface the BitTorrent session binds to. **Enforced on every start** (an existing value is replaced), because a deviating value is a leak: without the binding libtorrent announces on every interface of the shared namespace, gluetun's kill switch drops those packets and torrents stall at 0 bytes in `stalledDL`. `null`/`""` drops the binding. |
|
||||
| `qbittorrent.savePath` | string | `/downloads` | Default save path, **seeded only when the key is absent**, never replaced — a path changed in the WebUI stays. `null`/`""` leaves qBittorrent on its built-in `/config/Downloads`, i.e. the 1Gi config volume; see [The save path trap](#the-save-path-trap). |
|
||||
| `qbittorrent.configInit.image.repository` | string | `docker.io/busybox` | Init container that seeds those two keys into the persisted config. |
|
||||
| `qbittorrent.configInit.image.tag` | string | `1.38.0` | |
|
||||
| `qbittorrent.configInit.resources.limitFactor` | number | `3` | |
|
||||
| `qbittorrent.configInit.resources.requests` | object | `cpu: 10m`, `memory: 16Mi`, `ephemeralStorage: 1Mi` | |
|
||||
| `qbittorrent.configInit.resources.limits` | object | unset | |
|
||||
| `qbittorrent.configInit.securityContext` | object | non-root from `puid`/`pgid`, `readOnlyRootFilesystem: true`, all caps dropped, no privilege escalation | |
|
||||
|
||||
Setting **both** `networkInterface` and `savePath` to `null` removes the init container entirely.
|
||||
|
||||
### `mediathekarr` extras
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `mediathekarr.image.digest` | string | `sha256:3ed7e148…` | **Required while mediathekarr is enabled** — the template refuses to render without it. The only digest-pinned image in the repo: `latest` is a 2024 build (v0.2.5) and `beta` moves, so tag + digest keep both a readable label and an immutable reference. The image is **single-arch linux/amd64**. |
|
||||
| `mediathekarr.startGate.enabled` | bool | `true` | Delays the container until gluetun reports healthy. See [The start gate](#the-start-gate-and-why-a-green-pod-proves-nothing). Not null-safe on purpose. |
|
||||
| `mediathekarr.startGate.intervalSeconds` | int | `3` | Poll interval against `127.0.0.1:9999`. |
|
||||
| `mediathekarr.startGate.timeoutSeconds` | int | `180` | Total wait; on timeout the container **exits** instead of starting. Keep the startup budget (`periodSeconds × failureThreshold`, 300s by default) above it. |
|
||||
| `mediathekarr.startGate.settleSeconds` | int | `10` | Extra pause after gluetun is healthy. Not a measured requirement, a cheap buffer; `0` disables it. |
|
||||
| `mediathekarr.downloads.directory` | string | `mediathekarr` | Sub-directory on the shared downloads claim. The chart derives `DOWNLOAD_INCOMPLETE_PATH=/downloads/<dir>/incomplete` and `DOWNLOAD_COMPLETE_PATH=/downloads/<dir>/complete` from it. Do **not** set either variable under `env` — the chart always sets both. |
|
||||
|
||||
## Operating notes
|
||||
|
||||
### Nothing is Ready until the tunnel is up
|
||||
|
||||
gluetun's readiness probe is its health server, which answers 200 only while the Wireguard tunnel actually works. "Pod not Ready" therefore almost always means "no tunnel", not "app broken", and the app containers behind the kill switch reach nothing in the meantime — they are up and simply have no egress.
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> logs <release>-freesailarr-sfs-0 -c gluetun -f
|
||||
```
|
||||
|
||||
Liveness is more tolerant (60s) than readiness (30s) on purpose: during a short re-negotiation gluetun recovers by itself and a kubelet restart would only prolong the outage, while the kill switch keeps blocking all egress the whole time.
|
||||
|
||||
### Two ingress shapes, and they mix
|
||||
|
||||
1. **One host per app** (default): each app with `<app>.ingress.enabled` gets its own object `<release>-freesailarr-<app>-ingress`, its own host and its own certificate. The hosts need not share a domain.
|
||||
2. **One shared domain**: set `ingress.host`, leave the per-app hosts empty. Every such app moves into **one** object as a sub-path — one DNS name, one certificate.
|
||||
|
||||
A per-app host always wins, so a mixed setup needs no extra values. The cost per app in sub-path mode, measured rather than assumed:
|
||||
|
||||
- **radarr, sonarr, prowlarr — nothing.** The chart sets `<APP>__SERVER__URLBASE` itself as soon as the app renders under a path other than `/`, unless that app's own `env` block already carries the variable (then yours wins and the chart stays out). Side effect worth knowing: with a UrlBase set, these apps answer the bare `/ping` with `307` and a `Location` under the base. The probes keep passing — the kubelet counts 200–399 as success — so the probe paths stay `/ping` in both shapes.
|
||||
- **bazarr — one manual step.** It reads its base path only from its own configuration; there is no environment variable. After the first start set *Settings → General → Base URL* to the same value as `bazarr.ingress.path` and restart the pod. Until then the UI is broken **without announcing it**: bazarr answers every unknown path with its index page, so `/bazarr/` and `/bazarr/<asset>` both return `200 text/html` of identical length. Check the content type, not the status code. Deliberately not automated — seeding it would mean writing into bazarr's config database.
|
||||
- **qbittorrent — its own object with an nginx `rewrite-target`**, on the same host and sharing the main object's TLS secret (the cert-manager annotation sits on the main object, or on this one when it is the only app on the shared host). Two upstream traps, both of which look like a login failure: the session cookie is marked `Secure`, so over plain HTTP the browser never sends it back and every click bounces to the login form; and qBittorrent bans the client IP for a while after a few failed attempts, after which even correct credentials fail until the ban expires. Fallback without a chart change: set `qbittorrent.ingress.host`.
|
||||
- **seerr and mediathekarr — pinned to `/`.** seerr has no base-path support ([upstream issue open since May 2022](https://github.com/Fallenbagel/jellyseerr/issues/97)); mediathekarr links its UI assets with root-absolute paths, which a prefix-stripping rewrite cannot fix. Any other path **fails the render** rather than producing a broken UI, and two apps claiming the same path on the shared host fail the render too — one of them then needs its own host. With seerr off the shared domain's root answers 404; that is correct and left alone.
|
||||
|
||||
### Every Service points at the same pod IP
|
||||
|
||||
One ClusterIP Service per enabled app that listens on a port (recyclarr has none). Because all containers share gluetun's namespace, the Services differ only in their port: they give stable per-app DNS names, **not** network separation. mediathekarr's Service carries both its ports; only the primary (5007) is ever addressed by an ingress.
|
||||
|
||||
### One pod, not a stack of pods
|
||||
|
||||
`replicas > 1` is not meaningful: every replica would run its own isolated stack on its own config volumes, all of them fighting over the same media claims and the same VPN credentials. `NOTES.txt` says so at render time.
|
||||
|
||||
### Toggling an app on or off later
|
||||
|
||||
The config volumes come from `volumeClaimTemplates`, and those are immutable. Changing any `<app>.enabled` after the first install makes the upgrade fail until the StatefulSet is recreated — the data survives:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> delete statefulset <release>-freesailarr-sfs --cascade=orphan
|
||||
```
|
||||
|
||||
This is exactly why mediathekarr is **off by default**: enabling it adds a claim template, and nobody's existing release should grow one on an upgrade.
|
||||
|
||||
### One claim, one volume entry
|
||||
|
||||
Several categories may point at the same `existingClaim`. A pod may reference such a claim only **once**: two volume entries with the same `claimName` leave the pod in `Init:0/1` forever, without a single kubelet event (measured on RKE2/Longhorn, RWO as well as RWX). The chart therefore renders one entry, owned by the first *enabled* category in the fixed order `downloads, movies, tv`, and the other categories mount it with their own `subPath`. The order is fixed rather than derived from the values, because a volume name that moves when an unrelated flag changes is a pod restart.
|
||||
|
||||
### Hardlinks need one mount, not one claim
|
||||
|
||||
Radarr and Sonarr do **not** hardlink between categories, not even on a single claim. Kubernetes makes every `volumeMount` its own bind mount, and `link()` across two mount points fails with `EXDEV` regardless of `subPath`: measured on Longhorn/NFS, both mount points report `st_dev 0:56`, the mount table shows two mount IDs on that one device, a cross-mount `link()` fails and a `link()` inside one mount succeeds with link count 2. The device number is the trap — `stat` suggests the opposite of what happens, which makes this look like an application bug.
|
||||
|
||||
Consequence: an import copies, so download and library each hold a full copy until the torrent is removed. The layout that *would* hardlink is one claim with exactly **one** mount and the categories as sub-directories inside it (the "single `/data` mount" the *arr guides recommend). This chart cannot express that today — named as a possibility, not as instructions.
|
||||
|
||||
### The claim root, not fsGroup
|
||||
|
||||
A `subPath` directory does not have to exist — the kubelet creates it — but it is created **root-owned and with the mode of the claim root**. So the requirement is: *the root directory of the claim must be writable for `puid`/`pgid`.*
|
||||
|
||||
Do not count on `fsGroup` for that. Whether the kubelet applies it at all is the CSI driver's `fsGroupPolicy`, not the pod's: Longhorn ships `ReadWriteOnceWithFSType`, so `fsGroup` applies to RWO volumes and is silently ignored on an RWX media claim. It works out on Longhorn only because Longhorn hands out its volume root as mode 777. Measured in k3d against a 0755 claim root with no `fsGroup` in effect, the kubelet creates `/movies` as `root:root 0755` and the container's `touch` fails with `EPERM`. On such a driver, prepare the claim root once (`chown 1000:1000`, or `chmod g+w` with the right group) before the first start — the chart deliberately chowns nothing, because a multi-TB library must not be walked on every pod start. `fsGroupChangePolicy: OnRootMismatch` exists for the same reason.
|
||||
|
||||
```bash
|
||||
kubectl get csidriver driver.longhorn.io -o jsonpath='{.spec.fsGroupPolicy}'
|
||||
```
|
||||
|
||||
### The save path trap
|
||||
|
||||
Without `Session\DefaultSavePath`, qBittorrent falls back to `/config/Downloads` — the 1Gi **config** volume. It fills within minutes, every torrent goes to "errored", and qBittorrent can no longer write its own metadata to `BT_backup`, so a restart in that state costs the torrent list. The failure looks like a network, tracker or VPN problem and needs a `df` to recognise. The init container seeds the value **only when the key is absent**, so check which one is actually in effect:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> exec <release>-freesailarr-sfs-0 -c qbittorrent -- \
|
||||
grep SavePath /config/qBittorrent/qBittorrent.conf
|
||||
```
|
||||
|
||||
If that shows a path under `/config`, fix it under *Options → Downloads → Default Save Path* — the running app owns the file, and editing it underneath the app is overwritten on exit.
|
||||
|
||||
Related, and also looking like a login failure: 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 fails — forward `8080:8080`, or go through the ingress.
|
||||
|
||||
### Hardening is not uniform here
|
||||
|
||||
Each container carries its own baseline, and the deviations are image-forced and documented in `values.yaml`:
|
||||
|
||||
- **gluetun** runs as root with `NET_ADMIN` and a writable root filesystem — it creates the tun device, programs iptables and rewrites `/etc/resolv.conf`. Its `/tmp/gluetun` is an emptyDir because it creates that directory mode 0644 and, with `DAC_OVERRIDE` dropped, could not enter it. It still logs a `chown … operation not permitted` line per VPN restart; the file is written, and granting `CHOWN` just to silence a log line would widen the container for nothing.
|
||||
- **The five linuxserver.io containers** keep a writable root filesystem and no `runAsNonRoot`: their s6-overlay init must start as root to apply `PUID`/`PGID`. With a read-only root filesystem they still start but silently keep the image's built-in uid 911 — which makes the shared media volumes inaccessible.
|
||||
- **flaresolverr** pins uid/gid 1000 numerically (the image declares the user by name only, which the kubelet's `runAsNonRoot` check cannot verify) and needs a writable root filesystem for its chromedriver patching; `/tmp` and a memory-backed `/dev/shm` (512Mi) are emptyDirs.
|
||||
- **recyclarr, seerr, mediathekarr** run fully hardened: non-root from `puid`/`pgid`, read-only root filesystem, all capabilities dropped.
|
||||
|
||||
The pod-level `seccompProfile: RuntimeDefault` covers all of it, gluetun's tunnel and iptables included — verified against a real tunnel.
|
||||
|
||||
### The start gate, and why a green pod proves nothing
|
||||
|
||||
MediathekArr fetches its ruleset **once** at start and never retries. Started before the tunnel, it answered every Newznab search with 0 results for over eleven minutes — container up, Ready, nothing in the log. Kubernetes cannot express "start after that container" (gluetun is a regular container, and an initContainer waiting for it would deadlock), so the gate lives in mediathekarr's command: poll gluetun's health server on `127.0.0.1:9999`, then exec the image entrypoint. **On timeout the container exits instead of starting anyway** — a crash-looping container is visible, a silently empty indexer is not.
|
||||
|
||||
If you lower `startGate.timeoutSeconds` or raise it, keep `startupProbe.periodSeconds × failureThreshold` above it, or the kubelet kills the container while the gate is still waiting.
|
||||
|
||||
### MediathekArr: two ports, and the SABnzbd API is not at `/api`
|
||||
|
||||
| Consumer | Role | Endpoint |
|
||||
|---|---|---|
|
||||
| Prowlarr | Newznab indexer | `http://127.0.0.1:5008` |
|
||||
| Sonarr / Radarr | SABnzbd download client | host `127.0.0.1`, port `5007`, **API path `/download/api`** |
|
||||
|
||||
A request to `/api` on 5007 answers with a Newznab validation error and sends you looking at the wrong half of the app; upstream documentation mentions only port 5007 at all. Sonarr and Radarr must import from `/downloads/<directory>/complete` — the identical absolute path the chart hands MediathekArr as the SABnzbd `complete_dir`. Any deviation (a remote path mapping, a different mount) imports **nothing, without an error**. `incomplete/` lives on the media claim rather than an emptyDir on purpose: an emptyDir counts against the container's ephemeral-storage limit, and one large download would make the kubelet evict the whole pod — all ten containers.
|
||||
|
||||
The UI is only needed for the setup wizard; a port-forward is enough:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> port-forward svc/<release>-freesailarr-mediathekarr-service 5007:5007
|
||||
```
|
||||
|
||||
### A failed MediathekArr download never says why
|
||||
|
||||
MediathekArr does not check the HTTP status code. Measured A/B with one identical NZB: direct (DE) → `Completed`, 94,235,525 bytes, `fail_message: null`; through Zurich → `Failed`, 0 bytes, `fail_message` **also** `null`. On a geo-blocked fetch it writes the ~476-byte HTML error page out as `.mp4` and logs "Download completed"; only mkvmerge then refuses the file and flips the status. Two consequences: Sonarr is told "failed" without a reason, so a geo block is indistinguishable from a broken server, an expired URL or a full disk; and that protection is an accident — anything a geo endpoint returns that mkvmerge accepts is reported as `Completed` and walks into the library.
|
||||
|
||||
**Explicitly unmeasured:** part of the catalogue is served as an HLS playlist instead of a progressive MP4. The image carries an ffmpeg-based M3U8 path, but whether such an entry completes end to end has not been tested. If it does not, those entries fail regardless of any geo block — and, per the above, without a reason.
|
||||
|
||||
### The geo caveat, and how to check it yourself
|
||||
|
||||
The tunnel exits in Zurich by default, and ARD and ZDF block parts of their catalogue outside Germany. Every container hangs in gluetun's namespace; there is no per-container exit. Measured over 480 URLs across 12 channels: 104 of 473 loadable URLs blocked through Zurich (22.0%), none on the German side — but the spread is very uneven (ONE 97.5%, ZDFneo 47.5%, ZDFinfo 37.5%, Das Erste 20.5%, ZDF 10.3%, BR 0%), so one number says little about the show you want.
|
||||
|
||||
A list of titles goes stale; the marker in the media URL does not — it predicted the outcome in 584 of 584 measured cases:
|
||||
|
||||
| Marker in the media URL | Outcome |
|
||||
|---|---|
|
||||
| `/de/`, `/geo/de/`, `/deutschlandweit/`, `geoavailability_D` | blocked outside DE |
|
||||
| `/weltweit/` | open |
|
||||
| `/none/` (ZDF) | open |
|
||||
| `/dach/`, `deChAt` (WDR variant) | open — Switzerland is part of DACH |
|
||||
|
||||
The rule of thumb "own production = available worldwide" is **measurably wrong** (an ARD own production was blocked, a licensed series was open). Only the marker counts. The escape route is one value — `gluetun.vpn.serverCountries`/`serverCities` set to a German exit — and its price is that it moves the **whole** stack, qBittorrent included, into that jurisdiction and onto those servers.
|
||||
|
||||
### Rotating the VPN credentials
|
||||
|
||||
The pod template carries `checksum/secret-ref`, a hash of the rendered `OnePasswordItem` — not of the secret's content, which Helm never sees ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)). Pointing `secrets.gluetunSecretRef` at a **different** item therefore rolls the pod; editing a value **inside** the same item does not:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout restart statefulset/<release>-freesailarr-sfs
|
||||
```
|
||||
|
||||
### `FIREWALL_INPUT_PORTS` is a statement of intent, not what opens the ports
|
||||
|
||||
The chart declares the enabled apps' ports (plus 9999) to gluetun's firewall, but inside a pod network those rules never match: gluetun detects the pod's own subnet and inserts an `ACCEPT` rule ahead of them, and the packet counters on the `--dport` rules stayed at 0. The declaration is kept because that subnet detection is gluetun's behaviour, not a guarantee of the chart's — a CNI handing out a `/32`, or a future gluetun dropping the rule, would make these the only thing keeping probes and ingress alive.
|
||||
|
||||
## Ten upstreams, no app version
|
||||
|
||||
A documented exception to two repo-wide rules ([Versioning](Versioning), [Chart style guide](Chart-Style-Guide)):
|
||||
|
||||
- **No image tag comes from `Chart.AppVersion`.** All ten images are pinned in `values.yaml` as `<app>.image.repository`/`tag`, because ten independently versioned projects have no single "the" app version. `appVersion` is the chart's own marker, raised **by hand** when a new edition of the chart is marked as a whole; the chart is deliberately **not** in the `change-app-version.yml` choice list and has **no** app-version block in `renovate.json5`.
|
||||
- **No `icon:` in `Chart.yaml`.** The rule everywhere else is "the icon is this chart's one upstream's logo", and picking one of ten would imply a main app the stack does not have. It costs an INFO line in `helm lint --strict`, which still exits 0. This applies to no other chart — a single-upstream chart without an icon is a missing icon, not an exception.
|
||||
- **No ConfigMap**, so no `checksum/config`; the pod template carries `checksum/secret-ref` instead, which promises only "the config *source* changed".
|
||||
|
||||
Renovate reaches every image through the built-in `helm-values` manager, which reads exactly the `repository`/`tag` pairs. `mediathekarr` is the exception on top: pinned by digest, matched by its own `customManagers` block anchored on the three keys **in their `values.yaml` order** — renaming or reordering them does not fail anything, it silently stops matching. `linuxserver/qbittorrent` additionally excludes the legacy tags `14.3.9` and `20.04.1`, which docker versioning sorts above every real release. See [Renovate](Renovate).
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **Back up the config volumes before an app-version bump.** Each app's database lives on its own RWO claim; `helm uninstall` leaves `volumeClaimTemplates`-created PVCs behind, but a StatefulSet recreation plus a mistaken PVC delete does not.
|
||||
- **The media claims are yours, the config volumes are the chart's.** Nothing in the chart creates, chowns or deletes a media claim.
|
||||
- **Update images one at a time via Renovate PRs**, not by hand-editing several tags at once: the install-test is the only place where a real tunnel and ten containers come up together, and a single failing container is much easier to attribute.
|
||||
- **After enabling or disabling an app**, expect the `--cascade=orphan` step above. Plan it — the upgrade fails first and the reason is in the StatefulSet's error, not in Helm's.
|
||||
- **seerr's first boot runs migrations** (300s startup budget). Raise `seerr.startupProbe.failureThreshold` before a large import, not after the crash loop has started.
|
||||
|
||||
## CI
|
||||
|
||||
`ci/freesailarr/` holds:
|
||||
|
||||
- `test-values.yaml` — release name `ci`, the three media claims, `firewallOutboundSubnets` for k3s, shared-domain ingress on `freesailarr.example.com`, and **mediathekarr enabled** so the install-test actually covers the tenth container, its digest-pinned image, its start gate and its two-port probes (its ingress stays off — the shared root belongs to seerr).
|
||||
- `fixtures.yaml` — the three media PVCs on the default StorageClass (k3d's local-path, mode 0777, so the claim roots are writable for PUID/PGID without a chown job).
|
||||
- `fixtures.env.yaml` — the only one in the repo: a Secret template with variable **names** only, rendered with `envsubst` and piped into `kubectl`. gluetun needs a real Wireguard key, or no app container behind its kill switch ever becomes Ready.
|
||||
- `settings.env` — `INSTALL_TIMEOUT=1200` (ten containers, several GB of images, startup budgets that only start once the tunnel is up) and `REQUIRED_ENV` for the two credential variables, whose absence fails the job loudly instead of skipping it.
|
||||
|
||||
The key in 1Password is a **CI-only** Mullvad key, deliberately not the production one: Mullvad ties an address to a key and routes the tunnel to the endpoint it saw last, so a shared key would let every PR pull the tunnel out from under the running stack. The install-test runs on every PR that touches the chart and takes ~225 s alone — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements, and the four it is a documented exception to
|
||||
- [Versioning](Versioning) — why `3.1.0+up1.0.0` has a hand-set suffix here
|
||||
- [Renovate](Renovate) — the `helm-values` manager, the digest block, the qBittorrent tag exclusions
|
||||
- [Lessons learned](Lessons-Learned) — the `EXDEV` hardlink measurement, the duplicate-claim hang, the `fail_message: null` trap
|
||||
- [Chart index](Charts)
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# gotenberg
|
||||
|
||||
Deploys [Gotenberg](https://gotenberg.dev/), a stateless HTTP API that converts HTML, Markdown and office documents to PDF via Chromium and LibreOffice. Deployment and Service only — **no Ingress, and none should be added**. In this cluster it is a backend for `paperless-ngx`, which points `PAPERLESS_TIKA_GOTENBERG_ENDPOINT` at its Service.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `8.0.2+up8.37.0` |
|
||||
| App version | `8.37.0` (image `gotenberg/gotenberg`) |
|
||||
| Workloads | Deployment (1 replica) |
|
||||
| Storage | none — emptyDirs on `/tmp` and `/home/gotenberg` |
|
||||
| Ingress | **no** — ClusterIP Service only |
|
||||
| External dependencies | none |
|
||||
| Secrets | none |
|
||||
| Install-test | `ci/gotenberg/` (empty test values, no fixtures) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
None beyond a cluster. No vault item, no database, no volume, no ingress controller. The chart is installable with zero values.
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install gotenberg jlab/gotenberg \
|
||||
--namespace documents --create-namespace
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information — including the limits this release actually got — against the values actually set. Read it — it is the per-release version of this page.
|
||||
|
||||
### How it is reached, and by whom
|
||||
|
||||
There is no Ingress and no certificate. The API is cluster-internal, on a ClusterIP Service:
|
||||
|
||||
```
|
||||
http://<release>-gotenberg-service.<namespace>.svc.cluster.local
|
||||
```
|
||||
|
||||
**Mind the port.** The Service listens on **80** and forwards to the container's **3000**, so consumers are configured *without* a port — e.g. paperless-ngx:
|
||||
|
||||
```yaml
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://<release>-gotenberg-service.<namespace>.svc.cluster.local
|
||||
```
|
||||
|
||||
From your own machine:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> port-forward svc/<release>-gotenberg-service 3000:80
|
||||
curl http://localhost:3000/health
|
||||
```
|
||||
|
||||
`/health` reports Chromium and LibreOffice **separately** — the quickest way to tell a broken converter from a broken caller.
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0` without uninstalling. Takes precedence over `gotenberg.replicas`. The Service keeps its ClusterIP but loses its endpoints, so callers fail with "connection refused" rather than a timeout. |
|
||||
| `global` | object | unset | Helm's conventional cross-chart values. Unread by this chart; kept open in the schema so an umbrella install cannot break on it. |
|
||||
|
||||
### `gotenberg`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `gotenberg.replicas` | int | `1` | Replica count; `0` is allowed. The app is stateless, so raising it is safe and is the way to serve concurrent conversions. |
|
||||
| `gotenberg.securityContext.pod` | object | non-root `1001:1001`, `seccompProfile: RuntimeDefault` | Passed verbatim into the pod spec. The profile was verified against **both** engines — a browser sandbox is the classic case where a syscall filter bites; Chromium HTML→PDF and LibreOffice document→PDF both still return real PDFs and `/health` reports both as up. |
|
||||
| `gotenberg.securityContext.container` | object | `readOnlyRootFilesystem: true`, `runAsNonRoot: true`, all caps dropped, no privilege escalation | Passed verbatim into the container spec. Chromium and LibreOffice need writable `/tmp` and `/home/gotenberg`, which the chart mounts as emptyDirs. |
|
||||
| `gotenberg.livenessProbe` | object | `GET /health` on port `gotenberg-port`, period 10s, timeout 5s, 3 failures | Gotenberg's documented health route; no external dependency hangs on it. |
|
||||
| `gotenberg.readinessProbe` | object | `GET /health` on port `gotenberg-port`, period 10s, timeout 5s, 3 failures | |
|
||||
| `gotenberg.startupProbe` | object | `GET /health` on port `gotenberg-port`, period 5s, timeout 5s, 30 failures (= 150s) | Gates liveness and readiness. |
|
||||
| `gotenberg.env` | list | `[]` | Extra environment variables. Rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. **Additive, not overriding** — see [Environment variables](Chart-Style-Guide#10-environment-variables). Note that Gotenberg's own knobs are *flags*, not variables — see below. |
|
||||
| `gotenberg.resources.limitFactor` | number | `3` | Only nominally in effect here: both computable limits are already set explicitly, so nothing is multiplied. |
|
||||
| `gotenberg.resources.requests` | object | `cpu: 0.1`, `memory: 512Mi`, `ephemeralStorage: 10Mi` | Deliberately small: Gotenberg idles at a few dozen MB, so reserving conversion-sized capacity on the node would waste it around the clock. Conversions are short bursts and the limits cover them. |
|
||||
| `gotenberg.resources.limits` | object | `memory: 3Gi`, `ephemeralStorage: 256Mi` | **Set explicitly**, not computed — the computed pair (1536Mi / 30Mi) was too small for real documents. No CPU limit. |
|
||||
|
||||
An erased block (`gotenberg.securityContext:` with no children, a `null` probe, a `null` `resources`) falls back to the chart's own defaults instead of disappearing — [style guide §12.2](Chart-Style-Guide#12-values-that-must-not-lie).
|
||||
|
||||
## Operating notes
|
||||
|
||||
### No authentication, and this chart cannot add any
|
||||
|
||||
Gotenberg's basic auth and OIDC, its SSRF guards for the `downloadFrom` feature, and its request timeout are all **command-line flags**. This chart passes no flags — it offers only `gotenberg.env` — so none of them can be switched on through values. Everything that reaches the Service may convert, and the URL routes make the server fetch whatever URL they are handed. Keep it cluster-internal; do not put an Ingress in front of it without authentication in between.
|
||||
|
||||
### Requests time out after 30 seconds
|
||||
|
||||
That is Gotenberg's own default and, since the chart passes no flags, it cannot be changed here. It is the ceiling large conversions actually meet: an 18 MB HTML needs about 22 seconds, a 25 MB one does not finish inside the 30. Callers that need longer must split the document.
|
||||
|
||||
### Why the limits are explicit, and what each failure looks like
|
||||
|
||||
Chromium and LibreOffice hold the whole document in memory, so a conversion is a short, memory-hungry burst. The numbers come from measurements with the official image:
|
||||
|
||||
- 18 MB HTML: OOMKilled at 2Gi and at 2560Mi, converts at **3Gi** in ~22 s (21 MB PDF). 12 MB HTML already OOM-kills at the old computed 1536Mi.
|
||||
- Raising memory alone only moves the failure: with 3Gi memory and the old 30Mi ephemeral limit, `/tmp` peaked at 40 MB on a single conversion and ~125 MB over a short series, and the pod was **evicted** instead of OOM-killed. Hence `ephemeralStorage: 256Mi`.
|
||||
|
||||
Memory beyond 3Gi buys nothing, because the 30-second timeout is what a too-large document hits first. Neither failure reports an HTTP error to the caller:
|
||||
|
||||
- **Out of memory** — the connection is dropped mid-request and the pod's `RESTARTS` counter goes up. Confirm with
|
||||
```bash
|
||||
kubectl -n <namespace> get pod -l app=<release>-gotenberg-deployment \
|
||||
-o jsonpath='{.items[*].status.containerStatuses[*].lastState.terminated.reason}'
|
||||
```
|
||||
which then prints `OOMKilled`.
|
||||
- **Out of ephemeral storage** — `/tmp` and `/home/gotenberg` are emptyDirs and count against the ephemeral-storage limit (Chromium's profile alone takes about 4 MB, plus the uploaded file and the rendered PDF). The pod does not restart, it **disappears**: a fresh one takes its place and the old one is left in status `Evicted`, with the message naming ephemeral-storage.
|
||||
|
||||
Since both limits are set explicitly, raising the matching request no longer drags the limit along — change `gotenberg.resources.limits.memory` / `.limits.ephemeralStorage` directly.
|
||||
|
||||
### It shows up as "helper not reached" in the quantity-render test
|
||||
|
||||
The repo's resource-quantity render test injects a bare integer memory request into every `resources` block and checks the multiplied limit. 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. The job therefore lists this chart under **"helper not reached"** rather than counting it as a green tick for a code path that was never entered. That is the intended outcome, not a defect; see [CI/CD](CI-CD).
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
Nothing to back up and nothing to migrate: no volume, no database, no state that survives a pod. An app-version bump is an image swap, and `helm rollback` carries no data risk. After a bump, `curl /health` is the one check worth running — it names the engine that did not come back. Renovate follows `gotenberg/gotenberg` through this chart's `customManagers` block with default docker versioning; it was one of the two charts whose PRs proved the Gitea-side Renovate instance really reads our config — see [Renovate](Renovate).
|
||||
|
||||
## CI
|
||||
|
||||
`ci/gotenberg/test-values.yaml` is literally `{}` — no secrets, no ingress, no persistence — and there is no `fixtures.yaml`. The install-test waits for the Deployment to roll out, and since the probes are the app's own `/health`, "Ready" means both converters answered. See [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — §7 on the resources helper this chart deliberately bypasses
|
||||
- [Versioning](Versioning) — what a bump of `8.0.2+up8.37.0` means
|
||||
- [apache-tika](apache-tika) — the second half of the paperless-ngx document pipeline
|
||||
- [Chart index](Charts)
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
# homeassistant
|
||||
|
||||
Deploys [Home Assistant](https://www.home-assistant.io/) as a single-replica StatefulSet whose `/config` directory is one RWO PVC, with three configuration files mounted read-only out of a 1Password item. Two things set this chart apart from the rest of the repository: its ingress block is the **documented exception** that keeps the upstream-style `hosts`/`tls` lists instead of the required `domain`/`clusterIssuer` pair, and the container **must start as root** because the image boots via s6-overlay.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `6.1.0+up2026.8.3` |
|
||||
| App version | `2026.8.3` (image `ghcr.io/home-assistant/home-assistant`) |
|
||||
| Workloads | StatefulSet (1 replica) |
|
||||
| Storage | one RWO PVC mounted at `/config` (subPath `hass-config`) |
|
||||
| Ingress | optional and **off by default**; upstream-style `hosts`/`tls` lists, **no required values** |
|
||||
| External dependencies | none by default (recorder uses SQLite on the PVC unless configured otherwise) |
|
||||
| Secrets | 1 × `OnePasswordItem` (three fields) |
|
||||
| Install-test | `ci/homeassistant/` (config-secret fixture, `INSTALL_TIMEOUT=600`) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **1Password operator** in the cluster, plus one item carrying the three fields below.
|
||||
- **A storage class that provisions RWO volumes.** The default value is the literal string `default`, which exists in few clusters — set `core.storage.storageClass` to yours.
|
||||
- **cert-manager only if you ask for it.** This chart adds **no** cert-manager annotation of its own; see [The Ingress exception](#the-ingress-exception-nothing-here-requests-a-certificate).
|
||||
- **Someone ready to do the onboarding** right after the first rollout — see [The first visitor becomes the owner](#the-first-visitor-becomes-the-owner).
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install hass jlab/homeassistant \
|
||||
--namespace homeassistant --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml` — `secrets.configSecretRef` is the only value the chart insists on:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
configSecretRef: op://vault/homeassistant-config
|
||||
|
||||
core:
|
||||
timezone: Europe/Berlin
|
||||
storage:
|
||||
storageClass: longhorn
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
hosts:
|
||||
- host: hass.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: tls-hass-example-com
|
||||
hosts:
|
||||
- hass.example.com
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set — including an explicit warning when `ingress.tls` names a Secret that nothing in the release creates. Read it; it is the per-release version of this page.
|
||||
|
||||
### What the 1Password item must contain
|
||||
|
||||
The chart renders one `OnePasswordItem`; the operator turns it into a Kubernetes Secret whose **keys are the field names of the item**. This chart mounts exactly three of them, and the names are decided here — they appear nowhere in 1Password, so they have to match to the letter.
|
||||
|
||||
| Values key | Kubernetes Secret | Field | Mounted at |
|
||||
|---|---|---|---|
|
||||
| `secrets.configSecretRef` | `<release>-homeassistant-config-secret` | `config` | `/config/configuration.yaml` |
|
||||
| | | `http-config` | `/config/http-config.yaml` |
|
||||
| | | `recorder-config` | `/config/recorder-config.yaml` |
|
||||
|
||||
The three are separate files on purpose, and `configuration.yaml` has to pull the other two in itself — the chart only mounts them, it does not wire them together. A working minimum for the `config` field:
|
||||
|
||||
```yaml
|
||||
default_config:
|
||||
http: !include http-config.yaml
|
||||
recorder: !include recorder-config.yaml
|
||||
```
|
||||
|
||||
A field spelled differently yields a Secret without that key. The pod then hangs in `CreateContainerConfigError` while the Secret itself exists, so it looks as if the operator had done its job. What it actually delivered:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> get secret <release>-homeassistant-config-secret -o jsonpath='{.data}'
|
||||
```
|
||||
|
||||
All three are `subPath` mounts from a Secret volume, which makes them **read-only inside the container**. Home Assistant's own file editor and every UI page that offers to write `configuration.yaml` will fail on these three paths — and only on these three. Everything else under `/config` is on the PVC and writable as usual.
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders the StatefulSet with `replicas: 0`. Takes precedence over `replicas`. PVC, `OnePasswordItem`, Service and Ingress stay. |
|
||||
| `replicas` | int | `1` | Replica count; `0` is allowed. **Top-level, not under a section** — this chart keeps `replicas`, `resources`, the probes and `securityContext` at the root. See [One replica, one volume](#one-replica-one-volume). |
|
||||
| `global` | object | `{}` | Helm's cross-chart convention. Read by nothing here; kept open so an umbrella install cannot break on it. |
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.configSecretRef` | string | `null` | **Required — the only required value in this chart.** `op://` path of the item holding `config`, `http-config` and `recorder-config`. Changing the **path** restarts the pod (`checksum/secret-ref`); changing the item's **content** does not. |
|
||||
|
||||
### `service`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `service.type` | string | `ClusterIP` | Service type. |
|
||||
| `service.port` | int | `80` | Service port; the target is always the container's `8123` (port name `http`). |
|
||||
|
||||
### `ingress`
|
||||
|
||||
Documented exception to [style guide §4](Chart-Style-Guide#4-ingress): this chart keeps the upstream-style flexible block. There is no `ingress.domain`, no `ingress.clusterIssuer`, and **no required value here at all** — only `className` follows the repo standard.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `false` | Off by default. Nothing else in the chart reads `ingress.hosts`, so no other setting silently depends on it. |
|
||||
| `ingress.className` | string | `nginx` | Set to `""` to omit `ingressClassName` from the rendered Ingress entirely. |
|
||||
| `ingress.annotations` | object | `{}` | Rendered verbatim. **This is where the cert-manager annotation has to go** — the chart adds none. |
|
||||
| `ingress.hosts` | list | `[{host: chart-example.local, paths: [{path: /, pathType: ImplementationSpecific}]}]` | Host/path list, passed verbatim into the Ingress rules. The default is the chart scaffold's **placeholder** — see [below](#the-placeholder-host). |
|
||||
| `ingress.tls` | list | `[]` | TLS list (`secretName` + `hosts`), passed verbatim. Empty means the Ingress carries no TLS section at all and the URLs are plain `http`. |
|
||||
|
||||
### `securityContext`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `securityContext.pod` | object | `{}` | **Deliberately empty.** The official image boots via s6-overlay, which performs its setup as uid 0 and supports no non-root user, so `runAsNonRoot`/`runAsUser` are not set. Documented deviation from [§5](Chart-Style-Guide#5-security-hardening). |
|
||||
| `securityContext.container` | object | `allowPrivilegeEscalation: false`, `readOnlyRootFilesystem: true`, caps `drop: [ALL]`, `add: [NET_RAW]` | Everything else *is* hardened. `NET_RAW` is added back because device discovery (the DHCP watcher, the ping and arp integrations) uses raw sockets — see [Dropping NET_RAW is silent](#dropping-net_raw-is-silent). |
|
||||
|
||||
### `resources`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `resources.requests` | object | `cpu: 0.2`, `memory: 512Mi`, `ephemeralStorage: 128Mi` | |
|
||||
| `resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
|
||||
### Probes
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `livenessProbe` | object | `GET /` on port `http`, period 10s, timeout 5s, 3 failures | |
|
||||
| `readinessProbe` | object | `GET /` on port `http`, period 10s, timeout 5s, 3 failures | |
|
||||
| `startupProbe` | object | `GET /` on port `http`, period 10s, 30 failures (= 300s) | Gates liveness and readiness. Deliberately generous: a cold start installs and initializes integrations. |
|
||||
|
||||
### `env`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `env` | list | `[]` | Appended after `TZ`, the only variable the chart sets itself. Rendered through `tpl`; `value`, `secretKeyRef`, `configMapKeyRef`. **Additive, not overriding** ([§10](Chart-Style-Guide#10-environment-variables)) — and apart from `TZ`, Home Assistant reads **no** configuration from the environment: an entry reaches the application only if the YAML in the 1Password item picks it up with `!env_var`. |
|
||||
|
||||
### `core`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `core.timezone` | string | `UTC` | Rendered as `TZ`. **Not** Home Assistant's own time zone — see [Two time zones](#tz-and-home-assistants-own-time-zone-are-two-settings). |
|
||||
| `core.storage.size` | string | `5Gi` | Size of the `/config` PVC. Not null-safe on purpose: an erased block yields an invalid PVC instead of a degraded workload. |
|
||||
| `core.storage.storageClass` | string | `default` | Storage class of the PVC. The literal string `default` is not the cluster default — it is a class name that must exist. |
|
||||
|
||||
### Values the chart accepts but does not render
|
||||
|
||||
These five are declared in `values.yaml` and in `values.schema.json` (the schema even describes them as "rendered verbatim into the pod spec"), but **no template reads them**. Setting them passes validation and changes nothing:
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `volumes` | list | `[]` | Not rendered. |
|
||||
| `volumeMounts` | list | `[]` | Not rendered. |
|
||||
| `nodeSelector` | object | `{}` | Not rendered — the StatefulSet template still carries a `# TODO: bind to local nodes`. |
|
||||
| `tolerations` | list | `[]` | Not rendered. |
|
||||
| `affinity` | object | `{}` | Not rendered. |
|
||||
|
||||
## Operating notes
|
||||
|
||||
### The Ingress exception: nothing here requests a certificate
|
||||
|
||||
Unlike every other chart in this repository, this one builds **no** `cert-manager.io/cluster-issuer` annotation from a `clusterIssuer` value, and it does not name the TLS Secret either — you do, in `ingress.tls`. Three outcomes follow:
|
||||
|
||||
- `ingress.tls` empty → the Ingress carries no TLS section, the URLs are plain `http`, nothing requests a certificate.
|
||||
- `ingress.tls` set, **no** cert-manager annotation under `ingress.annotations` → the Ingress references a Secret that nothing creates. ingress-nginx answers with its own default certificate and every browser shows a name mismatch. `NOTES.txt` calls this out in capitals.
|
||||
- `ingress.tls` set **and** the annotation present → cert-manager's ingress-shim names the `Certificate` after your `secretName`, so that is the object to watch:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> get certificate <your ingress.tls secretName>
|
||||
```
|
||||
|
||||
### The placeholder host
|
||||
|
||||
`ingress.hosts` defaults to the scaffold entry `chart-example.local`. With `ingress.enabled: true` and that default untouched, the Ingress exists, ingress-nginx accepts it, and no request ever arrives — the name resolves nowhere. Set the host you actually serve before looking for a deeper reason.
|
||||
|
||||
### Behind an Ingress, the `http` block decides between 400 and wrong client IPs
|
||||
|
||||
Every request through ingress-nginx arrives from the controller's **pod** IP, not from the browser. The `http-config` field is where that is dealt with, and both ways of getting it wrong are quiet in their own way:
|
||||
|
||||
- `use_x_forwarded_for: true` **without** the proxy's address in `trusted_proxies` makes Home Assistant reject the request outright — the browser gets `400 Bad Request` and the log names the untrusted address it saw.
|
||||
- Neither setting at all means every client looks like the ingress controller. Nothing fails; you get one address in the logbook for everyone, and the login-attempt ban then locks out all users at once because they share it.
|
||||
|
||||
The value belongs to the ingress controller's **pod network**, not to the nodes and not to the clients:
|
||||
|
||||
```yaml
|
||||
use_x_forwarded_for: true
|
||||
trusted_proxies:
|
||||
- 10.42.0.0/16 # the cluster's pod CIDR — check yours
|
||||
```
|
||||
|
||||
Read the address Home Assistant actually sees out of the log rather than guessing:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> logs <release>-homeassistant-sfs-0 | grep -i forwarded
|
||||
```
|
||||
|
||||
### Changing the item's content does not restart the pod
|
||||
|
||||
The StatefulSet carries a `checksum/secret-ref` annotation over the rendered `OnePasswordItem`, so pointing `secrets.configSecretRef` at a **different** vault item does roll the pod — the deliberately weaker promise of [§9](Chart-Style-Guide#9-config-changes-must-restart-the-workload): "the config *source* changed", not "the config changed".
|
||||
|
||||
Changing the **content** of the same item does not, and this is the sharp edge. Helm never sees that content, and a `subPath` mount is never refreshed by the kubelet even after the operator has updated the Secret. The running pod keeps the old files until something else restarts it — possibly months later — so a broken value surfaces far away from the edit that caused it. That is especially true for a release parked at `scaleDown: true`: whoever brings it back up gets whatever the vault holds at that moment, on whatever app version the chart carries by then.
|
||||
|
||||
The operator's `operator.1password.io/auto-restart` annotation is **not** the fix and is deliberately not set: it only ever restarts Deployments, never StatefulSets (verified against onepassword-operator 1.12.0). Setting it here would look like a fix and do nothing. After every content change:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout restart statefulset/<release>-homeassistant-sfs
|
||||
```
|
||||
|
||||
### The container starts as root
|
||||
|
||||
The pod security context is empty on purpose. s6-overlay performs its setup as uid 0 and the image supports no non-root user, so this is one of the documented image-forced deviations in [§5](Chart-Style-Guide#5-security-hardening) — justified with a comment in `values.yaml`, as the rule requires. Everything else holds: `automountServiceAccountToken: false`, no privilege escalation, all capabilities dropped (bar `NET_RAW`), read-only root filesystem, `/tmp` and `/run` as emptyDirs.
|
||||
|
||||
### Dropping `NET_RAW` is silent
|
||||
|
||||
The chart's default adds `NET_RAW` back after dropping `ALL`. Overriding `securityContext.container` without it does not fail loudly: the DHCP watcher and the ping and arp integrations log `Operation not permitted` and simply find nothing, so devices that used to appear on their own stop appearing. `NOTES.txt` prints a warning when the effective context has lost the capability.
|
||||
|
||||
### The first visitor becomes the owner
|
||||
|
||||
Against an empty `/config` Home Assistant has no users and sends every visitor into onboarding, where the first account created is the **owner**. There is no password to look up anywhere and no Secret holding one — whoever reaches the URL first owns the instance. Do the onboarding right after the first rollout, not the next morning.
|
||||
|
||||
Onboarding also decides the instance's location, unit system and time zone and stores them under `/config/.storage`. That is state on the PVC, not configuration in the 1Password item: it survives a pod restart, and it is gone with the volume.
|
||||
|
||||
### TZ and Home Assistant's own time zone are two settings
|
||||
|
||||
`core.timezone` is passed to the container as `TZ` and is the only environment variable the chart sets by itself. Home Assistant's own time zone comes from onboarding or from `configuration.yaml` and is **not** derived from it. When the two disagree, log timestamps and time-based automations disagree with them, and nothing reports a problem.
|
||||
|
||||
### What a Ready pod proves — and what it does not
|
||||
|
||||
All three probes fetch `/` on the container port, served by the HTTP integration early in the bootstrap:
|
||||
|
||||
- A `configuration.yaml` that cannot be loaded does not necessarily keep the pod out of Ready. Home Assistant falls back to **safe mode**, serves the frontend with none of your configuration in effect, and the rollout goes green. An instance that comes back "empty" after a config change is this, and the reason is only in the log.
|
||||
- An integration that fails to set up, a recorder that cannot reach its database, a full volume — none of them are in the probe's answer.
|
||||
|
||||
So check the log after every config change, not the rollout status. If a start outruns the 300s startup budget, the kubelet kills the container mid-setup and the next attempt begins again, which looks like `CrashLoopBackOff` with no error in the log at all.
|
||||
|
||||
### `/tmp` and `/run` count against ephemeral-storage
|
||||
|
||||
The root filesystem is read-only, so the two paths the image must write to — `/run` for s6-overlay's state and `/tmp` for scratch files — are emptyDirs, and an emptyDir counts against the container's ephemeral-storage limit. Going over it does not fail a request: the kubelet **evicts the whole pod**, and the reason is on the pod, not in Home Assistant's log. Where integrations pull large downloads through `/tmp`, raise `resources.requests.ephemeralStorage`; the limit follows through `resources.limitFactor`.
|
||||
|
||||
### One replica, one volume
|
||||
|
||||
Every pod of the StatefulSet mounts the **same** RWO claim at `/config` — there is no `volumeClaimTemplate` and no per-pod volume. Additional pods stay stuck before start unless the storage class hands the claim to several nodes; where they do start, two Home Assistant processes write the same state directory and the same recorder database at once. Home Assistant does not run active-active.
|
||||
|
||||
### The Helm test is inert
|
||||
|
||||
This is the only chart in the repository with a `templates/tests/` directory, but its `test-connection.yaml` is **entirely commented out** — the whole manifest sits inside `{{/* … */}}`. Nothing is rendered, no `helm.sh/hook: test` object exists, and `helm test <release>` finds nothing to run. The file is a placeholder from the upstream scaffold, not a working smoke test.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **`/config` is the whole of the state.** The owner account, every long-lived token, all UI-created automations and dashboards, every integration's stored credentials and the recorder database live there — on `<release>-homeassistant-pvc`, inside its subPath `hass-config`. Only the three files from the vault are reproducible from the chart's values; nothing else is.
|
||||
- **`helm uninstall` deletes that PVC.** It is an ordinary claim in the release, not a `volumeClaimTemplate`, and with a `Delete` reclaim policy the instance is then gone for good. A resource-policy annotation added to the live PVC afterwards does not help — Helm reads it from the release manifest.
|
||||
- **The recorder is what makes the volume grow**, and its retention (`purge_keep_days`) is a field of the 1Password item, not a chart value. Growing `core.storage.size` only takes effect if the storage class allows volume expansion; shrinking it is refused by Kubernetes and fails the next upgrade on the PVC instead of touching the data.
|
||||
- **Home Assistant releases monthly and deprecates integrations on a schedule.** Read the upstream release notes before a bump; a rollback returns the old image to a `/config` the new one has already migrated.
|
||||
- The app version is calendar-versioned (`YYYY.M.P`), so the repo's "app major changed → chart major" rule fires once a year at the January release rather than on a breaking change. Judge the bump level by the upstream notes, not by the number — see [Versioning](Versioning).
|
||||
|
||||
## CI
|
||||
|
||||
`ci/homeassistant/` holds `test-values.yaml` (op:// ref against the stub CRD, plus `core.storage.storageClass: local-path` because the values default `default` does not exist in k3d), `fixtures.yaml` (the config Secret the operator would otherwise materialize, with the three keys and a `trusted_proxies` entry for the k3d pod CIDR) and `settings.env` (`INSTALL_TIMEOUT=600`, because the first boot installs and initializes integrations). Home Assistant starts as root, so the writability of the dynamically provisioned volume is not an issue here — unlike the charts that need hostPath fixtures. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — §4 and §5 both name this chart as a documented exception
|
||||
- [Versioning](Versioning) — what a bump of `6.1.0+up2026.8.3` means
|
||||
- [Renovate](Renovate) — how `appVersion` is updated
|
||||
- [Lessons learned](Lessons-Learned)
|
||||
- [Chart index](Charts)
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
# matrix-synapse
|
||||
|
||||
Deploys [Matrix Synapse](https://element-hq.github.io/synapse/) as a single-replica StatefulSet whose entire configuration is one `homeserver.yaml` out of a 1Password item, plus an optional second workload: an nginx Deployment that serves the `/.well-known/matrix/*` delegation documents. No database is part of this chart.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `7.1.0+upv1.159.0` |
|
||||
| App version | `v1.159.0` (image `ghcr.io/element-hq/synapse`) |
|
||||
| Workloads | StatefulSet (1 replica); optional delegation nginx Deployment + Service |
|
||||
| Storage | one RWO PVC, two subPaths: `/data` and `/media_store` |
|
||||
| Ingress | yes, gated by `ingress.enabled`; `ingress.domain` survives the gate — see [§12.1](Chart-Style-Guide#12-values-that-must-not-lie) |
|
||||
| External dependencies | whatever `database:` in the homeserver.yaml names (external PostgreSQL, or SQLite under `/data`) |
|
||||
| Secrets | 1 × `OnePasswordItem` |
|
||||
| Install-test | `ci/matrix-synapse/` (fixtures + seed job + test values, `INSTALL_TIMEOUT=600`) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **1Password operator** in the cluster, plus one item carrying a field named literally `homeserver.yaml` — see [the fields below](#what-the-1password-item-must-contain).
|
||||
- **A complete Synapse configuration**, including a signing key and a log config on the volume. `synapse generate` produces them; the chart does not.
|
||||
- **A reachable database** as named in that file. Nothing here deploys one.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for **both** hostnames pointing at the ingress controller when the delegation is on.
|
||||
- **The server name decided before the first start.** It cannot be changed afterwards — see [The server name is decided once](#the-server-name-is-decided-once-and-not-by-matrixservername).
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install synapse jlab/matrix-synapse \
|
||||
--namespace matrix --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
configSecretRef: op://vault/matrix-synapse-config
|
||||
|
||||
matrix:
|
||||
serverName: example.com
|
||||
delegation:
|
||||
enabled: true
|
||||
serverUrl: example.com
|
||||
|
||||
ingress:
|
||||
domain: matrix.example.com
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set — including a warning when `matrix.serverName` and `matrix.delegation.serverUrl` disagree. Read it; it is the per-release version of this page.
|
||||
|
||||
### What the 1Password item must contain
|
||||
|
||||
The chart renders one `OnePasswordItem`; the operator turns it into a Kubernetes Secret whose **keys are the field names of the item**. This chart reads exactly one of them.
|
||||
|
||||
| Values key | Kubernetes Secret | Fields |
|
||||
|---|---|---|
|
||||
| `secrets.configSecretRef` | `<release>-matrix-synapse-config-secret` | `homeserver.yaml` — the **complete** Synapse configuration file, mounted read-only at `/data/homeserver.yaml` |
|
||||
|
||||
A field spelled differently produces a Secret **without** that key: the pod never starts, and the reason appears only as a volume error in `kubectl describe pod` — the Secret itself exists, so the operator looks as if it had done its job.
|
||||
|
||||
Four settings inside that file are dictated by the chart's mounts, and getting them wrong is not a config error the app reports clearly:
|
||||
|
||||
| Setting | Must be | Why |
|
||||
|---|---|---|
|
||||
| `media_store_path` | `/media_store` | The second PVC subPath. The root filesystem is read-only, so a path outside `/data`, `/media_store` and `/tmp` fails on the first upload. |
|
||||
| `signing_key_path` | under `/data` | The federation identity. Losing it makes this server a stranger to every server that ever spoke to it. |
|
||||
| `log_config` | under `/data` | Anywhere else is unwritable. |
|
||||
| a listener on port `8008`, type `http` | — | The Service and all three probes target that port by name. |
|
||||
|
||||
`/data` is the PVC and `homeserver.yaml` is mounted **on top of it**: a `homeserver.yaml` already lying in the volume (from a `synapse generate` run, for instance) is shadowed and never read. What runs is the item's content.
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0`. Takes precedence over both `replicas` values. PVC, Secret, Services and Ingress stay. |
|
||||
| `global` | object | `{}` | Helm's cross-chart convention. Read by nothing here; kept open so an umbrella install cannot break on it. |
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.configSecretRef` | string | `null` | **Required.** `op://` path of the item holding `homeserver.yaml`. |
|
||||
|
||||
### `matrix`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `matrix.replicas` | int | `1` | Replica count; `0` is allowed. See [One replica, one volume, one config](#one-replica-one-volume-one-config). |
|
||||
| `matrix.serverName` | string | `null` | **Required.** Rendered into `SYNAPSE_SERVER_NAME` — which the running server does **not** read. See [The server name is decided once](#the-server-name-is-decided-once-and-not-by-matrixservername). |
|
||||
| `matrix.timezone` | string | `UTC` | `TZ` on the container. |
|
||||
| `matrix.securityContext.pod` | object | non-root 1000/1000, `fsGroup: 1000`, `fsGroupChangePolicy: Always` | The uid matches the gosu default the upstream image used before the chart pinned a user, so existing PVC data stays accessible. |
|
||||
| `matrix.securityContext.container` | object | `readOnlyRootFilesystem: true`, all caps dropped, no privilege escalation, non-root | With an explicit non-root user the image skips its own gosu privilege drop and runs directly as that user. |
|
||||
| `matrix.livenessProbe` | object | `GET /health` on port `http`, period 10s, timeout 5s, 3 failures | |
|
||||
| `matrix.readinessProbe` | object | `GET /health` on port `http`, period 10s, timeout 5s, 3 failures | |
|
||||
| `matrix.startupProbe` | object | `GET /health` on port `http`, period 10s, 30 failures (= 300s) | Gates liveness and readiness; covers migrations and cache warmup. |
|
||||
| `matrix.env` | list | `[]` | Appended after `SYNAPSE_SERVER_NAME`, `SYNAPSE_REPORT_STATS` (`no`) and `TZ`. Rendered through `tpl`; `value`, `secretKeyRef`, `configMapKeyRef`. **Additive, not overriding** ([§10](Chart-Style-Guide#10-environment-variables)) — and Synapse takes its real configuration from `homeserver.yaml`, not from the environment. |
|
||||
| `matrix.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `matrix.resources.requests` | object | `cpu: 1`, `memory: 1Gi`, `ephemeralStorage: 1Gi` | |
|
||||
| `matrix.resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
|
||||
### `matrix.delegation`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `matrix.delegation.enabled` | bool | `false` | Renders the nginx ConfigMap, Deployment and Service, and adds the second Ingress host. Off by default — and off usually means [federation does not work](#without-delegation-federation-likely-does-not-work). |
|
||||
| `matrix.delegation.replicas` | int | `1` | Replica count; `0` is allowed and makes the `.well-known` documents 404 while remote servers keep asking. |
|
||||
| `matrix.delegation.serverUrl` | string | `null` | **Required while the delegation *and* the Ingress are enabled.** The **delegating** host — the domain in every user ID. With the Ingress off it is rendered into no object at all. |
|
||||
| `matrix.delegation.image.repository` | string | `nginx` | Auxiliary image, pinned independently of `appVersion`. Renovate holds it to **even (stable) nginx minors** — see [Renovate](Renovate). |
|
||||
| `matrix.delegation.image.tag` | string | `1.30.4` | |
|
||||
| `matrix.delegation.securityContext.pod` | object | non-root 101/101 | The image's unprivileged nginx user. |
|
||||
| `matrix.delegation.securityContext.container` | object | `readOnlyRootFilesystem: true`, all caps dropped, no privilege escalation, non-root | The generated `nginx.conf` redirects the pid file and every temp path into the `/tmp` emptyDir and listens on the unprivileged port 8080. |
|
||||
| `matrix.delegation.livenessProbe` | object | `GET /.well-known/matrix/server` on port `http`, period 10s, timeout 3s, 3 failures | Weak by construction — see [What a Ready pod proves](#what-a-ready-pod-proves-and-what-it-does-not). |
|
||||
| `matrix.delegation.readinessProbe` | object | same, period 10s, timeout 3s, 3 failures | |
|
||||
| `matrix.delegation.startupProbe` | object | same, period 5s, 30 failures (= 150s) | |
|
||||
| `matrix.delegation.resources.limitFactor` | number | `3` | |
|
||||
| `matrix.delegation.resources.requests` | object | `cpu: 125m`, `memory: 64Mi`, `ephemeralStorage: 50Mi` | |
|
||||
| `matrix.delegation.resources.limits` | object | unset | |
|
||||
|
||||
The delegation Deployment has **no `env` block** — it is the one container in this repo configured purely by its ConfigMap.
|
||||
|
||||
### `storage`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `storage.amount` | string | `10Gi` | Size of the single PVC. Not null-safe on purpose: an erased block yields an invalid PVC instead of a degraded workload. |
|
||||
| `storage.storageClass` | string | `longhorn-encrypted` | Storage class of the PVC. |
|
||||
|
||||
### `ingress`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | `false` omits the Ingress and frees `clusterIssuer` — **but not `domain`**. |
|
||||
| `ingress.domain` | string | `null` | **Required while the Ingress *or* the delegation is enabled.** Where Synapse actually answers, and the value baked into both `.well-known` documents. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. |
|
||||
|
||||
TLS secrets: `tls-<release>-matrix-synapse-ingress` for `ingress.domain`, and `tls-<release>-matrix-synapse-delegation` for `matrix.delegation.serverUrl`.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### `ingress.domain` needs its own guard
|
||||
|
||||
This chart is the reason [§12.1](Chart-Style-Guide#12-values-that-must-not-lie) exists. The delegation nginx serves `ingress.domain` inside `/.well-known/matrix/{server,client}` — the endpoints through which **federation finds the homeserver**. Putting the Ingress behind `ingress.enabled` without a second `required()` on the domain would publish
|
||||
|
||||
```
|
||||
{"m.server": ":443"} {"m.homeserver": {"base_url": "https://"}}
|
||||
```
|
||||
|
||||
— rendered cleanly, pod green, federation broken. The ConfigMap therefore carries its own `required()`, independent of the Ingress gate.
|
||||
|
||||
### Two hostnames that are not interchangeable
|
||||
|
||||
With the delegation on, the Ingress publishes **two** hosts and the chart cannot check that you got the split right:
|
||||
|
||||
- `matrix.delegation.serverUrl` is the **delegating** host — the domain in every user ID (`@user:example.com`). Remote servers look up `.well-known` here, so this name must equal the `server_name` of the running homeserver.
|
||||
- `ingress.domain` is where Synapse actually answers. It appears **inside** the documents and needs no relation to the server name.
|
||||
|
||||
Only the path `/.well-known/matrix` is routed to nginx on `serverUrl`; everything else on that host is not served by this release. Both hosts need a certificate, and cert-manager only issues the second one once `serverUrl` resolves to this cluster — a pending certificate there is a federation outage, not a cosmetic problem. Check what is really served, from outside the cluster:
|
||||
|
||||
```bash
|
||||
curl -s https://<serverUrl>/.well-known/matrix/server
|
||||
```
|
||||
|
||||
### Without delegation, federation likely does not work
|
||||
|
||||
With `matrix.delegation.enabled: false` nothing here serves `/.well-known/matrix/server`. A remote server that finds no such document falls back to port **8448** of the server name — and this release exposes no 8448 anywhere: the container publishes 8008 only, and the Service maps port 80 onto it. Unless the delegation is served by something else (a static `.well-known` on the server-name host, or an SRV record), federation stays broken while clients on `ingress.domain` work fine.
|
||||
|
||||
With `ingress.enabled: false` the documents exist only on the in-cluster Service `<release>-matrix-synapse-delegation-service`; whatever terminates TLS in front of the cluster has to route `https://<server name>/.well-known/matrix` there. Matrix federation is HTTPS-only, so nothing reaches this homeserver until something does.
|
||||
|
||||
### Changing the item does not restart Synapse
|
||||
|
||||
Pointing `secrets.configSecretRef` at a **different** item changes the rendered StatefulSet and rolls the pod. Changing the content **inside** the same item does not: Helm never sees that content, so nothing in the pod template changes — and because the file is a `subPath` mount, the kubelet does not refresh it in the running container either. Both halves have to be undone by hand:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout restart statefulset/<release>-matrix-synapse-sfs
|
||||
```
|
||||
|
||||
The operator's `operator.1password.io/auto-restart` annotation is **not** a substitute and is deliberately not set: it only ever restarts Deployments, and this is a StatefulSet (verified against onepassword-operator 1.12.0) — setting it would advertise a guarantee that does not exist. See [style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload).
|
||||
|
||||
The delegation nginx is the opposite case: its ConfigMap **is** hashed into the pod template (`checksum/config`), so a changed `ingress.domain` rolls it automatically.
|
||||
|
||||
### The server name is decided once, and not by `matrix.serverName`
|
||||
|
||||
`matrix.serverName` is rendered into `SYNAPSE_SERVER_NAME`, which the image only consults when it *generates* a configuration — which is not what happens here. The running server reads its name from `server_name:` in the mounted `homeserver.yaml`. Changing `matrix.serverName` alone rolls the pod and changes nothing else.
|
||||
|
||||
And the name cannot be changed afterwards at all: it is baked into every user ID, room ID and event this server has ever signed. A server that needs a different name is a new server with a new database, not an upgrade. Decide it before the first start — together with `matrix.delegation.serverUrl`, which has to match it.
|
||||
|
||||
### What a Ready pod proves — and what it does not
|
||||
|
||||
All three Synapse probes hit `/health`, which answers 200 as soon as the HTTP listener is up. It says nothing about the database, the signing key or federation: a `homeserver.yaml` pointing at an unreachable database usually fails at start (CrashLoopBackOff, log only), but every problem that appears later leaves the pod Ready.
|
||||
|
||||
The delegation probe is weaker still. It requests `/.well-known/matrix/server`, and that endpoint is a static `return 200` in the generated `nginx.conf` — it passes with a wrong `ingress.domain` baked into the document. Read the body, not the probe.
|
||||
|
||||
### The volume belongs to this release
|
||||
|
||||
`/data` (the SQLite database if used, the signing key, the log config) and `/media_store` (all uploaded media) are two subPaths of **one** ReadWriteOnce claim, `<release>-matrix-synapse-pvc`. It is an ordinary PVC in the release, not a `volumeClaimTemplate`, and it carries no resource-policy annotation: `helm uninstall` deletes it, and with a `Delete` reclaim policy the media and the signing key go with it. Adding the annotation to the live PVC afterwards does not help — Helm reads it from the release manifest. Back the volume up first.
|
||||
|
||||
Growing `storage.amount` only takes effect if the StorageClass allows volume expansion; shrinking it is refused by Kubernetes and fails the next upgrade on the PVC instead of touching the data. Media is what grows, and it grows without an upper bound unless the `homeserver.yaml` sets retention.
|
||||
|
||||
Files in the volume owned by another uid — restored from a backup, written by an older release — are not readable, and the signing key (mode 0600) is the first thing to fail.
|
||||
|
||||
Everything else the process writes goes to `/tmp`, an emptyDir that counts against the container's ephemeral-storage limit. Raise `matrix.resources.requests.ephemeralStorage` to move it; the limit follows through `matrix.resources.limitFactor`.
|
||||
|
||||
### One replica, one volume, one config
|
||||
|
||||
Every pod of the StatefulSet mounts the same RWO claim and the same `homeserver.yaml` — there is no `volumeClaimTemplate` and no per-pod configuration. Additional pods either stay `Pending` on a node without the volume, or start as a second full homeserver on the same data. Scaling Synapse means running workers with distinct configurations, which this chart does not deploy.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **Back up `/data` before every app-version bump.** Schema migrations run at start and are one-way; a `helm rollback` returns the old image to a database the new one has already rewritten.
|
||||
- **The signing key is the irreplaceable part.** Snapshot it with the database and the media store, not separately.
|
||||
- **Raise `matrix.startupProbe.failureThreshold` before a large jump**: a kubelet restart mid-migration is the failure mode the 300s default budget is calibrated against.
|
||||
- App **major** jumps go stepwise ([Versioning](Versioning)) — and a major is also the moment to bump the CI fixture image (below).
|
||||
- The delegation nginx tag is reviewed on every app-update round; Renovate offers only even (stable) nginx minors.
|
||||
|
||||
## CI
|
||||
|
||||
`ci/matrix-synapse/` holds `test-values.yaml` (op:// ref against the stub CRD, `synapse.ci.local` as server name, `matrix.ci.local` as domain, delegation on), `fixtures.yaml` and `settings.env` (`INSTALL_TIMEOUT=600`). Synapse needs a signing key and a log config on its PVC **before** it starts, so the claim is backed by a pre-bound hostPath PV and a seed Job runs `synapse generate` into that directory first, with `UID`/`GID` set to 1000 so the 0600 key is readable by the server container. The static `homeserver.yaml` in the fixture uses SQLite under `/data`.
|
||||
|
||||
**That fixture pins a Synapse image Renovate does not follow** (`ci/**` is deliberately unmanaged, so the throwaway infrastructure stays on its major lines). Bump `ghcr.io/element-hq/synapse` in `ci/matrix-synapse/fixtures.yaml` **by hand** when the chart moves a major, or the seed job generates a config for a different version than the one under test. See [Renovate](Renovate) and [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — §12.1 is written from this chart
|
||||
- [Versioning](Versioning) — what a bump of `7.1.0+upv1.159.0` means
|
||||
- [Renovate](Renovate) — the nginx even-minor constraint, and the unmanaged CI fixture
|
||||
- [Lessons learned](Lessons-Learned)
|
||||
- [Chart index](Charts)
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
# minecraft-router
|
||||
|
||||
Deploys [mc-router](https://github.com/itzg/mc-router) as a stateless Deployment in front of several Minecraft servers. The router reads the hostname out of the Minecraft handshake and proxies the raw TCP connection to the backend named in its routing table. It is **not** a web service: no Ingress, no certificate, no URL.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `4.1.1+up1.46.5` |
|
||||
| App version | `1.46.5` (image `docker.io/itzg/mc-router`) |
|
||||
| Workloads | Deployment (1 replica) |
|
||||
| Storage | none — the only mount is the routing ConfigMap |
|
||||
| Ingress | no; a **NodePort** Service, TCP only |
|
||||
| External dependencies | the backend Minecraft servers (their Services) |
|
||||
| Secrets | none |
|
||||
| Install-test | `ci/minecraft-router/` (test values only, no fixtures) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **A cluster whose API server runs with an extended `--service-node-port-range`.** The default `server.outwardPort` is `25565` — the port Minecraft clients dial — and that is outside the Kubernetes default range `30000-32767`. Without the extension the API server rejects the Service, and the release fails on that one object while everything else applies.
|
||||
- **Backend Minecraft servers that already exist**, each reachable under a cluster-internal Service name. The router dials `internalUrl` by name; nothing in this chart starts, watches or health-checks a backend.
|
||||
- **DNS for every mapped hostname pointing at a node of this cluster** (or at whatever sits in front of it). Routing is decided by the hostname the player typed, so a connection to a node's bare IP carries no hostname and is routed nowhere.
|
||||
- No 1Password item, no cert-manager, no storage class.
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install mc-router jlab/minecraft-router \
|
||||
--namespace minecraft --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
mappings:
|
||||
- domain: mc.example.com
|
||||
internalUrl: survival-service.minecraft.svc.cluster.local
|
||||
- domain: creative.example.com
|
||||
internalUrl: creative-service.minecraft.svc.cluster.local
|
||||
port: 25566
|
||||
```
|
||||
|
||||
`mappings` is the only value without a default. A missing or empty list aborts the render with a message naming the value — deliberately loud, because a router without routes is not operable.
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the routing table actually in effect. Read it — it is the per-release version of this page.
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders the Deployment with `replicas: 0` without uninstalling. Takes precedence over `server.replicas`. Service and ConfigMap stay. |
|
||||
|
||||
### `mappings`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `mappings` | list | `null` | **Required, and must not be empty.** The routing table, and the only source of routes in this release. Rendered into the ConfigMap `<release>-minecraft-router-configmap` (key `custom-routing-config.json`). A change here rolls the pod — see [A routing change is a planned outage](#a-routing-change-is-a-planned-outage). |
|
||||
| `mappings[].domain` | string | — | **Required per entry.** Client-facing hostname the player types; matched against the Minecraft handshake. |
|
||||
| `mappings[].internalUrl` | string | — | **Required per entry.** Cluster-internal address of the backend. Point it at the backend's **Service** — the name survives a restart of the Minecraft server, a pod IP does not. |
|
||||
| `mappings[].port` | int | `server.backendPort` | Overrides the backend port for this single entry. |
|
||||
|
||||
### `server`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `server.replicas` | int | `1` | Replica count; `0` is allowed. See [More than one replica](#more-than-one-replica). |
|
||||
| `server.outwardPort` | int | `25565` | `nodePort` of the Service — the port a player dials. Outside the default node-port range on purpose; do not "fix" it into `30000-32767` unless you also change what the clients connect to. |
|
||||
| `server.port` | int | `25565` | Port mc-router listens on (`--port`); `containerPort` and the Service's cluster-internal port. Changing it does **not** change what a player dials. |
|
||||
| `server.backendPort` | int | `25565` | Default port of the backends, used for every mapping without its own `port`. |
|
||||
| `server.apiPort` | int | `8080` | Port of the mc-router HTTP API (`--api-binding`). Not published by the Service; only the probes talk to it. |
|
||||
| `server.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `server.resources.requests` | object | `cpu: 100m`, `memory: 50Mi`, `ephemeral-storage: 50Mi` | |
|
||||
| `server.resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
| `server.livenessProbe` | object | `GET /routes` on port `api-port`, `Accept: application/json`, period 10s, timeout 5s, 3 failures | See [What a Ready router does not prove](#what-a-ready-router-does-not-prove). |
|
||||
| `server.readinessProbe` | object | same request, period 10s, timeout 5s, 3 failures | |
|
||||
| `server.startupProbe` | object | same request, period 2s, 15 failures (= 30s) | Gates liveness and readiness. mc-router is one static Go binary reading one file; 30s is generous. |
|
||||
| `server.securityContext.pod` | object | non-root `10000:10000`, `fsGroup: 10000`, `fsGroupChangePolicy: Always`, `seccompProfile: RuntimeDefault` | Passed into the pod spec. The uid/gid are pinned by the chart even though the image declares `USER 65532` since 1.46.0, so the runtime identity does not move with the base image. The seccomp profile sits here on purpose: it covers every container including init containers. |
|
||||
| `server.securityContext.container` | object | `readOnlyRootFilesystem: true`, all caps dropped, no privilege escalation, non-root | The image ships nothing but the static binary and a CA bundle and writes nothing, so no deviation is needed. |
|
||||
| `server.env` | list | `[]` | Extra environment variables appended to the chart's own (`ROUTES_CONFIG`). Rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. **Additive, not overriding** — see [Environment variables](Chart-Style-Guide#10-environment-variables). |
|
||||
|
||||
Probe, `securityContext` and `resources` blocks may be erased (`null`) — the chart then falls back to exactly these defaults instead of rendering an unhardened, unprobed or unaccounted container. `mappings` is deliberately **not** null-safe: it fails instead.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### The hostname routes, not the port
|
||||
|
||||
Every mapping is reached over the same port. mc-router reads the server address out of the handshake and looks it up in the table, so the player has to enter a **mapped name**. A name that is not in the table is connected to nothing: the chart configures no default route. mc-router knows the concept (`POST /defaultRoute`) and parses every flag equally as an environment variable, so `server.env` is where one would be set.
|
||||
|
||||
### `mappings` is the only source of routes
|
||||
|
||||
The chart does not run mc-router in its Kubernetes auto-discovery mode — there is no ServiceAccount and `automountServiceAccountToken` is `false`, so the router cannot watch the API and never reads routing annotations on a backend Service. A server announced that way stays unreachable until its name stands in `mappings`; where both exist, this table wins.
|
||||
|
||||
### A routing change is a planned outage
|
||||
|
||||
The table is mounted with `subPath` at `/data/custom-routing-config.json`, the kubelet never refreshes a `subPath` mount, and mc-router reads `ROUTES_CONFIG` only at startup. Since chart 2.0.0 the Deployment therefore carries `checksum/config` over the rendered ConfigMap, so `helm upgrade` restarts the pod for you — at the price of a few seconds in which no client can connect and open sessions are dropped.
|
||||
|
||||
This chart is the one that produced the outage behind the rule: before the annotation existed, a changed routing table stayed inert and **the router kept routing to Services that no longer existed while every bundle was green and the pod was Ready** ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload), [Lessons learned](Lessons-Learned)).
|
||||
|
||||
### What a Ready router does not prove
|
||||
|
||||
All three probes call `GET /routes` on the mc-router API — there is still no `/health` endpoint in 1.46.x, and the API only starts after the config has been read and the listener is up. The API answers just as happily when every backend is down and when a `domain` or `internalUrl` is misspelled. A green rollout is a statement about the proxy, never about a Minecraft server; nothing in this release watches the backends.
|
||||
|
||||
An unreachable backend also does not restart the router: it accepts the client, fails to dial and closes the connection, which the player sees as an ordinary connection failure. The log is the only place that names the address it tried:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> logs deploy/<release>-minecraft-router-deployment
|
||||
```
|
||||
|
||||
The API is not published by the Service, so ask the pod directly:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> port-forward deploy/<release>-minecraft-router-deployment 8080:8080
|
||||
curl -s -H 'Accept: application/json' http://127.0.0.1:8080/routes
|
||||
```
|
||||
|
||||
`/metrics` on the same port returns Prometheus metrics.
|
||||
|
||||
### Writes over the API do not survive a restart
|
||||
|
||||
`POST /routes`, `POST /defaultRoute` and `DELETE /routes/{addr}` change the routing of the **running process** only. The mounted ConfigMap is the source of truth and the next rollout silently reinstates it. Fix routing in `mappings`, not over the API.
|
||||
|
||||
### More than one replica
|
||||
|
||||
mc-router keeps no state between connections and every replica mounts the same ConfigMap, so the Service can spread clients across them. Two things change: an established connection is pinned to the replica that accepted it, so a rollout drops open sessions pod by pod; and a runtime change through the API reaches only the pod you forwarded to, leaving the replicas disagreeing until the next restart.
|
||||
|
||||
### `server.env` cannot change the ports
|
||||
|
||||
The chart passes `--port` and `--api-binding` as command-line arguments, and those take precedence over the matching environment variables — `PORT` or `API_BINDING` set in `server.env` change nothing. Use `server.port` and `server.apiPort`. Everything else mc-router accepts as a flag is settable here (`LOG_LEVEL`, `BACKEND_DIAL_TIMEOUT`, `CONNECTION_RATE_LIMIT`), because it parses its flags with go-flagsfiller. A plain `value` is stored in the pod spec in clear text; anything sensitive belongs in `valueFrom.secretKeyRef`.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **There is nothing to back up.** The release creates no `OnePasswordItem`, mounts no Secret and owns no volume; the ConfigMap is rendered entirely from `mappings`. `helm uninstall` destroys nothing that is not already in your values file — the worlds live in the backend releases.
|
||||
- **Plan an upgrade as a short connection outage**, not as a rolling no-op: any change to `mappings` (and every app-version bump) replaces the pod, and open sessions go with it.
|
||||
- **Scaling to zero** (`scaleDown: true` or `server.replicas: 0`) leaves Service and ConfigMap in place; the backends keep running and stay reachable through their own Services, only the way in through the router is gone.
|
||||
|
||||
## CI
|
||||
|
||||
`ci/minecraft-router/test-values.yaml` is the whole fixture set — the chart needs no secrets, no ingress and no persistence. It supplies the required `mappings` and overrides `server.outwardPort` to `30565`, because the k3d CI cluster runs the default node-port range while the real cluster does not. That override is CI-only; the chart default stays `25565`. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements
|
||||
- [Lessons learned](Lessons-Learned) — the `checksum/config` outage this chart produced
|
||||
- [Versioning](Versioning) — what a bump of `4.1.1+up1.46.5` means
|
||||
- [Chart index](Charts)
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
# minecraft-server
|
||||
|
||||
Deploys a Minecraft server ([itzg/minecraft-server](https://github.com/itzg/docker-minecraft-server)) as a single-replica StatefulSet on one RWO world volume. No Ingress, no HTTP: clients speak the Minecraft protocol over raw TCP, routed in the cluster by the separate `minecraft-router` release. **Only the newest Java line is maintained** out of this directory.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `5.0.0+up2026.8.2.java25` |
|
||||
| App version | `2026.8.2-java25` (image `itzg/minecraft-server`) — the tag fixes the JVM |
|
||||
| Workloads | StatefulSet (1 replica), plus an init container with `curseforgeServerpack` |
|
||||
| Storage | one RWO PVC, `<release>-mc-volume-claim`, two subPaths (`mc-data` → `/data`, `mc-modpacks` → `/modpacks`) |
|
||||
| Ingress | none — `ClusterIP` by default, `NodePort` optional |
|
||||
| External dependencies | Playerdb (operator name resolution), CurseForge or FTB API when a modpack source is on |
|
||||
| Secrets | 1 × `OnePasswordItem`, rendered unconditionally |
|
||||
| Install-test | `ci/minecraft-server/` (fixtures + test values + `settings.env`) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **1Password operator** in the cluster, and a 1Password item — the CR is rendered unconditionally, so `secrets.minecraftServerRef` must be set even when nothing consumes a key from it. See [the fields below](#what-the-1password-item-must-contain).
|
||||
- **A storage class that provisions RWO volumes**, named in `minecraft.resources.storageClassName` (default `longhorn`).
|
||||
- **A way in for players**: either an entry in the `minecraft-router` release's static mappings table pointing at this Service, or `server.service.type: NodePort`. See [Nothing reaches this server by itself](#nothing-reaches-this-server-by-itself).
|
||||
- **A `minecraft.version` that fits the Java line of the image tag.** Since chart 5.0.0 the chart checks the pairing and fails the render — see [The Java line is the chart's central fact](#the-java-line-is-the-charts-central-fact).
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install mc jlab/minecraft-server \
|
||||
--namespace minecraft --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
minecraftServerRef: op://vault/minecraft-server
|
||||
```
|
||||
|
||||
Everything else has a default that matches the published Java line. `helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set — including the effective image tag, its Java line, the two memory numbers and whether the whitelist currently locks everyone out. Read it — it is the per-release version of this page.
|
||||
|
||||
`helm install` returns **before** any of this happens: without `--wait`, success is reported the moment the objects are applied, while the image is still downloading the server jar (and the whole modpack). Watch the rollout instead of trusting the exit code:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout status statefulset/<release>-minecraft-server-sfs
|
||||
```
|
||||
|
||||
### What the 1Password item must contain
|
||||
|
||||
The chart renders a `OnePasswordItem` CR; the operator turns it into a Kubernetes Secret whose **keys are the field names of the 1Password item**. A missing field does not fail the install: the release goes through and the pod then sits in `CreateContainerConfigError` while the Secret itself exists, so it looks as though the operator had done its job.
|
||||
|
||||
| Values key | Kubernetes Secret | Fields |
|
||||
|---|---|---|
|
||||
| `secrets.minecraftServerRef` | `<release>-minecraft-server-secret` | `rcon-password` → `RCON_PASSWORD`, **only when `server.rcon.enabled` is true**; `curseforge-api-key` → `CF_API_KEY`, **only when `curseforge.enabled` is true** |
|
||||
|
||||
With both features off the item may be empty — but the values key is still required, or the CR renders an `itemPath` of `null`. What the operator actually delivered:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> get secret <release>-minecraft-server-secret -o jsonpath='{.data}'
|
||||
```
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders the StatefulSet with `replicas: 0` without uninstalling. Takes precedence over `server.replicas`. The world PVC, the Service and the `OnePasswordItem` stay. |
|
||||
| `global` | object | unset | Helm's conventional cross-chart values. Unused by this chart; declared in the schema and kept open so an umbrella install cannot break on it. |
|
||||
|
||||
### `image`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `image.tagOverride` | string | `""` | Tag of the `itzg/minecraft-server` image. Empty means the chart's `appVersion`. **An emergency exit, not a knob** — see [Running an older Java line](#running-an-older-java-line-imagetagoverride). Deliberately outside Renovate's reach. The Java line of the effective tag is validated against `minecraft.version` at render time. |
|
||||
|
||||
The whole `image` block may be `null`; the tag then falls back to `appVersion`, which is the pre-5.0.0 behaviour.
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.minecraftServerRef` | string | `null` | **Required, always** — the CR is rendered unconditionally. `op://` item path; the item needs fields only for the features actually switched on. |
|
||||
|
||||
### `server`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `server.replicas` | int | `1` | Replica count; `0` is allowed. **More than one cannot work** — see [One replica, one world](#one-replica-one-world). |
|
||||
| `server.port` | int | `25565` | Port inside the container. Passed as `SERVER_PORT`, used as the `containerPort` and read by the image's `mc-health` script, so an override stays consistent everywhere. |
|
||||
| `server.service.type` | string | `ClusterIP` | `ClusterIP` or `NodePort` (enumerated in the schema). `ClusterIP` is the default because routing is done by `minecraft-router` over the in-cluster DNS name. |
|
||||
| `server.service.port` | int | `25565` | Port the Service publishes inside the cluster. |
|
||||
| `server.service.outwardPort` | int | `30565` | Node port, used **only** with `type: NodePort`. Must be inside the cluster's node-port range (30000–32767 by default); `NOTES.txt` warns when it is not. |
|
||||
| `server.motd` | string | `Running` + modpack name and version, with Java-properties unicode escapes (backslash-`u00A7`) for the Minecraft colour-code character | Those escapes **must stay literal backslash sequences** — `server.properties` is a Java properties file and unescapes them itself. Copy the default out of `values.yaml` rather than retyping it. |
|
||||
| `server.rcon.enabled` | bool | `false` | Only when true does the chart set `ENABLE_RCON`, declare the RCON container port and wire `RCON_PASSWORD` from the Secret. |
|
||||
| `server.rcon.port` | int | `25575` | RCON port. The Service does **not** publish it — see [RCON is declared, not published](#rcon-is-declared-not-published). |
|
||||
| `server.initImage` | string | `alpine:3.24` | Init container that downloads and unpacks a CurseForge server pack; used only with `curseforgeServerpack.enabled`. Pinned per repo convention — auxiliary images are never unpinned. |
|
||||
| `server.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `server.resources.requests.cpu` | string/number | `"2"` | |
|
||||
| `server.resources.requests.ephemeral-storage` | string/number | `"100Mi"` | Both spellings (`ephemeral-storage`, `ephemeralStorage`) are accepted; the helper normalizes to the Kubernetes key. |
|
||||
| `server.resources.requests.memory` | string/number | `null` — **derived** | Deliberately unset: the chart derives it from `minecraft.resources.memory` (the JVM heap) so heap and container memory cannot drift apart. With the default 4G heap that is a 4Gi request and a 12Gi limit. Setting it explicitly **decouples the two** — see [Two memory numbers that must not drift apart](#two-memory-numbers-that-must-not-drift-apart). |
|
||||
| `server.resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
|
||||
### `env`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `env` | list | `[]` | Extra environment variables for the server container, **appended** to everything the chart sets. Rendered through `tpl`; `value`, `secretKeyRef`, `configMapKeyRef` forms. Because the chart's own variables come first, an entry repeating one of them is what the image sees last — the escape hatch for anything the chart does not model, and equally the way to break a setting it does. A plain `value` is readable by anyone who can read pods in the namespace; anything sensitive belongs in `valueFrom.secretKeyRef`. |
|
||||
|
||||
### Probes
|
||||
|
||||
All three run the image's own `mc-health` script — a real Minecraft server-list ping against `SERVER_PORT` via `mc-monitor`, which is the only health check this image offers. An erased block falls back to these defaults rather than removing the probe.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `livenessProbe` | object | `exec: ["mc-health"]`, period 30s, timeout 10s, 3 failures | |
|
||||
| `readinessProbe` | object | `exec: ["mc-health"]`, period 15s, timeout 10s, 3 failures | |
|
||||
| `startupProbe` | object | `exec: ["mc-health"]`, period 15s, timeout 10s, 80 failures (= 20 minutes) | Gates liveness and readiness, so a slow start can no longer be killed. The budget has to cover a fresh volume: server jar download and, for FTB/CurseForge, the whole modpack, before the world even begins to load. |
|
||||
|
||||
### `securityContext`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `securityContext.pod` | object | non-root `1000:1000`, `fsGroup: 1000`, `fsGroupChangePolicy: OnRootMismatch` | uid/gid 1000 is the image's `minecraft` user. Starting as that user directly makes the entrypoint take its **non-root branch**, which skips a recursive `chown` of `/data` and a write to `/etc/nsswitch.conf` — the latter would fail on a read-only root filesystem. `OnRootMismatch` instead of `Always` avoids a recursive chown of the whole world on every pod start (minutes on a 32Gi world) while still running once if the volume root lacks the group. This block is read **twice**: as the pod's `securityContext` and by the serverpack init script, which chowns migrated files to the same uid/gid. |
|
||||
| `securityContext.container` | object | `readOnlyRootFilesystem: true`, `runAsNonRoot: true`, all caps dropped, no privilege escalation | Applied to the server container **and** the serverpack init container. The read-only root filesystem works because the chart mounts an emptyDir at `/tmp`: the image writes its server-icon scratch file and `mc-server-runner`'s console named pipe there; everything else goes to the PVC. |
|
||||
|
||||
### `minecraft`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `minecraft.difficulty` | string | `easy` | `peaceful`, `easy`, `normal`, `hard` (enumerated in the schema). |
|
||||
| `minecraft.whitelist.enabled` | bool | `false` | Wired to `ENABLE_WHITELIST` in **both** positions since chart 2.0.0, which is what makes `false` actually switch the whitelist off again in a persisted `server.properties`. Also drives `ENFORCE_WHITELIST`. **`true` without `players` locks everyone out, including the ops** — see [The whitelist can lock out everyone, invisibly](#the-whitelist-can-lock-out-everyone-invisibly). |
|
||||
| `minecraft.whitelist.overridePolicy` | string | `MERGE` | `EXISTING_WHITELIST_FILE` — how an already existing `whitelist.json` is treated: `SKIP` leaves it alone, `MERGE` adds these players, `SYNCHRONIZE` makes it match exactly and removes everyone else. Only applies while enabled. |
|
||||
| `minecraft.whitelist.players` | string | `null` | Comma-separated names, passed as `WHITELIST` **only while enabled** — a non-empty `WHITELIST` enables the whitelist in the image regardless of `ENABLE_WHITELIST=false`, so it must stay unset while disabled. |
|
||||
| `minecraft.management.opPlayers` | string | `null` | Comma-separated operators, passed as `OPS` with `EXISTING_OPS_FILE=SYNCHRONIZE` pinned — see [Operators are synchronized from values](#operators-are-synchronized-from-values-not-from-the-game). Every name is resolved against the external Playerdb API at startup, and a name that does not resolve aborts the start. |
|
||||
| `minecraft.management.maxPlayers` | int | `10` | `MAX_PLAYERS`. |
|
||||
| `minecraft.management.forceGameMode` | bool | `true` | `FORCE_GAMEMODE`. |
|
||||
| `minecraft.management.gameMode` | string | `survival` | `creative`, `survival`, `adventure`, `spectator` (enumerated). |
|
||||
| `minecraft.management.worldSaveName` | string | `world` | `LEVEL`. |
|
||||
| `minecraft.management.serverName` | string | `mc-server` | `SERVER_NAME`. |
|
||||
| `minecraft.resources.storage` | string | `32Gi` | Size of the world PVC. Growing it works only if the storage class allows expansion; shrinking is refused by Kubernetes and fails the next `helm upgrade` on the PVC. |
|
||||
| `minecraft.resources.storageClassName` | string | `longhorn` | **Immutable once the volume is bound** — changing it does nothing to an existing release. |
|
||||
| `minecraft.resources.memory` | string/number | `"4"` | JVM heap in **gigabytes**, passed as `MEMORY=<value>G`. Also drives the container's memory request/limit unless `server.resources.requests.memory` is set. |
|
||||
| `minecraft.version` | string/number | `"26.2"` | Minecraft version, pinned rather than `LATEST` (which would drift on every restart). **Must match the Java line of the effective image tag**; checked at render time. **Quote it** — unquoted YAML reads `1.20` as the number `1.2`, and the chart would pass `"1.2"` to the image as `VERSION`. |
|
||||
|
||||
### Modpack sources
|
||||
|
||||
`curseforge`, `ftb` and `curseforgeServerpack` are **mutually exclusive**; enabling more than one fails the render by name. Before chart 1.0.0 it silently rendered a duplicate `TYPE` entry and the last one won.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `curseforge.enabled` | bool | `false` | `TYPE=AUTO_CURSEFORGE`. The image resolves the pack against the CurseForge API **at every start**, using `curseforge-api-key` — so each start depends on a third-party API and on that key still being valid; an expired key is a server that stops coming up, not a warning. |
|
||||
| `curseforge.forceReload` | bool | `false` | `CF_FORCE_SYNCHRONIZE`. True re-synchronizes the pack on every single start; turn it off again once the reload has happened. |
|
||||
| `curseforge.modpack.slug` | string | `null` | `CF_SLUG`. |
|
||||
| `curseforge.modpack.versionId` | string/int | `null` | `CF_FILE_ID`. |
|
||||
| `curseforge.modpack.exclude` | string/int | `null` | `CF_EXCLUDE_MODS`. |
|
||||
| `curseforge.modpack.excludeOverride` | string/int | `null` | `CF_OVERRIDES_EXCLUSIONS`. |
|
||||
| `curseforge.modpack.include` | string/int | `null` | `CF_FORCE_INCLUDE_MODS`. |
|
||||
| `ftb.enabled` | bool | `false` | `TYPE=FTBA`. No API key, but the start depends on the Feed-the-Beast API being reachable, and the first one downloads the whole pack before the world loads. |
|
||||
| `ftb.modpackId` | string/int | `null` | `FTB_MODPACK_ID`. |
|
||||
| `ftb.versionId` | string/int | `null` | `FTB_MODPACK_VERSION_ID`. |
|
||||
| `curseforgeServerpack.enabled` | bool | `false` | `TYPE=CURSEFORGE`; an init container downloads and the server unpacks. **`downloadUrl` becomes required** — the chart fails the render otherwise, rather than letting the init container download an empty URL. |
|
||||
| `curseforgeServerpack.downloadUrl` | string | `null` | **Required when `curseforgeServerpack.enabled` is true.** Rendered into the `init.sh` ConfigMap. |
|
||||
| `curseforgeServerpack.workingDirectory` | string | `/data/serverpack` | `CF_BASE_DIR`, and where the init script unpacks. Also rendered into the ConfigMap. |
|
||||
|
||||
A change to either of the last two rolls the pod (`checksum/config` on the StatefulSet), which is what makes it take effect at all — `init.sh` is mounted with `subPath` and only read at pod start. The restart is not free: ~124 s measured for ftb-skies on Longhorn.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### The Java line is the chart's central fact
|
||||
|
||||
The JVM is fixed by the **image tag**, not by a value of the game. `appVersion` is that tag, and since chart 2.0.0 it is a **dated upstream release** (`2026.8.2-java25`) rather than the rolling line tag (`java25`): rolling tags are rebuilt continuously, so a pod restart could pull a different image than the last one without anything being recorded anywhere. The dated tags are immutable, so the image is pinned by the chart version like every other chart here.
|
||||
|
||||
The boundaries are Mojang's, not this chart's — `javaVersion.majorVersion` from the piston-meta version manifest, swept over all 102 releases on 2026-09-02. There are exactly four transitions and they are monotonic in release order:
|
||||
|
||||
| Minecraft | Java line |
|
||||
|---|---|
|
||||
| up to 1.16.5 | `java8` |
|
||||
| 1.17 – 1.17.1 | `java16` (never published as a chart line here) |
|
||||
| 1.18 – 1.20.4 | `java17` |
|
||||
| 1.20.5 – 1.21.11 | `java21` |
|
||||
| 26.1 and later | `java25` |
|
||||
|
||||
**Since chart 5.0.0 the pairing is checked at render time** and a mismatch fails with both values named and the corrected tag suggested. Two cases pass unchecked on purpose: a tag without a recognizable `java<N>` part, and a `minecraft.version` that is not a plain numeric `x[.y[.z]]` (a snapshot, a pre-release, `LATEST`). The chart cannot compare those, and a guard that rejects everything it does not understand gets worked around instead of respected. In those two the old failure mode still applies: the release installs, and the container fails afterwards — `CrashLoopBackOff` once the 20-minute startup budget is spent, with the reason in the log and nowhere else.
|
||||
|
||||
### Only the newest line is maintained; the older ones are frozen
|
||||
|
||||
Up to chart 4.1.0 the chart was published once per line (java8 / java17 / java21 / java25) out of this single directory, which is what made the lines drift apart: whatever change landed while a line happened to lie in the directory reached that line only. That is over (#158). This directory carries exactly one line — the newest — and only it gets chart changes.
|
||||
|
||||
The older lines stay **published as frozen artifacts** and remain installable, but will never be updated again:
|
||||
|
||||
| Line | Frozen at chart version |
|
||||
|---|---|
|
||||
| java8 | `4.0.0` |
|
||||
| java17 | `4.0.0` |
|
||||
| java21 | `4.1.0` |
|
||||
|
||||
### Running an older Java line: `image.tagOverride`
|
||||
|
||||
Installing a frozen chart version means giving up every later fix, so the line became a decision at rollout time instead of a property of the chart:
|
||||
|
||||
```yaml
|
||||
image:
|
||||
tagOverride: "2026.8.2-java21"
|
||||
minecraft:
|
||||
version: "1.21.11"
|
||||
```
|
||||
|
||||
It is called `tagOverride`, not `tag`, because it **decouples the running image from the chart version**: `helm list` then reports `5.0.0+up2026.8.2.java25` while the pod runs something else, and `NOTES.txt` says so. Prefer moving to the current line; use this when a modpack pins you to an older Minecraft version.
|
||||
|
||||
It is also **deliberately outside Renovate's reach** — `renovate.json5` tracks `appVersion` in `Chart.yaml` and nothing else. A tag set here is never bumped by anything; it is yours to review.
|
||||
|
||||
Moving across a line boundary is therefore no longer a change of chart version: set `image.tagOverride` **and** `minecraft.version` in the same upgrade. Changing only one now fails the render instead of leaving a broken pod behind.
|
||||
|
||||
### Nothing reaches this server by itself
|
||||
|
||||
There is no Ingress, no certificate and no URL.
|
||||
|
||||
- **`ClusterIP` (default)**: the server answers at `<release>-minecraft-server-service.<namespace>.svc.cluster.local:<server.service.port>`. The way in for players is the separate `minecraft-router` release, and that router does **not** discover backends: it routes on the hostname out of the Minecraft handshake, using the static mappings table in its own values file. Until an entry pointing at this Service is added **there**, the server is unreachable from outside no matter how healthy it is here — and no annotation on this release changes that. (Before chart 1.0.0 an `mcRouterIntegration.enabled` value added a misleading auto-discovery annotation; it is gone, only the service type remains, now named for what it does.)
|
||||
- **`NodePort`**: players dial `<any-node-ip>:<server.service.outwardPort>`. Note which of the three ports a player types — not `server.service.port` (in-cluster) and not `server.port` (in the container).
|
||||
|
||||
Either way, a port-forward reaches it without changing the Service:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> port-forward svc/<release>-minecraft-server-service 25565:25565
|
||||
```
|
||||
|
||||
### What a green rollout does and does not prove
|
||||
|
||||
`mc-health` is a real server-list ping, so Ready means more here than "the process is alive": the server is up and answering pings. It does **not** mean anyone can play. A server-list ping is answered before any login is attempted, so every reason a player is refused **at login** is invisible to all three probes — the whitelist is the usual one, and the rollout stays green throughout. The probes also run inside the pod and say nothing about whether players can reach it: with `ClusterIP`, a missing router mapping is a perfectly healthy server nobody can join.
|
||||
|
||||
### The whitelist can lock out everyone, invisibly
|
||||
|
||||
`minecraft.whitelist.enabled: true` with `players` unset emits `ENABLE_WHITELIST=true` and no `WHITELIST`, which is `white-list=true` against an empty `whitelist.json`: every login is refused, including the operators. That is the image's own semantics and it is intended — and it is exactly the state the probes cannot see. `NOTES.txt` prints the case explicitly.
|
||||
|
||||
`server.properties` lives on the PVC, which is why this matters historically: before chart 2.0.0 the chart only ever emitted whitelist variables while enabled was true and had no way to say "off", so a release that had once written `white-list=true` kept it forever. `ENFORCE_WHITELIST` follows the same switch for the same reason — the image only ever sets it to true and never resets it.
|
||||
|
||||
### Operators are synchronized from values, not from the game
|
||||
|
||||
`EXISTING_OPS_FILE=SYNCHRONIZE` is pinned: the image makes `ops.json` on the volume match `minecraft.management.opPlayers` exactly on every start. A player promoted in-game with `/op` is not in the value, so the next restart removes them again. Promote people in the values file.
|
||||
|
||||
Those names are resolved against the external Playerdb API during startup, and a name that does not resolve aborts the start. A typo is therefore not a missing operator but a server that never comes up — and an outage of that third-party lookup delays every pod start.
|
||||
|
||||
### Two memory numbers that must not drift apart
|
||||
|
||||
`minecraft.resources.memory` is the JVM heap (`MEMORY=<n>G`). While `server.resources.requests.memory` is unset, the chart derives the container request from it and the two cannot drift; the limit follows via `limitFactor`. Setting the request explicitly decouples them — keep the limit above the heap plus the JVM's own overhead. A heap that does not fit the limit is **not** a Java `OutOfMemoryError`: the kernel kills the container, the pod shows `OOMKilled`, and nothing in the server log explains it.
|
||||
|
||||
### The world lives on one PVC that `helm uninstall` deletes
|
||||
|
||||
`/data` and `/modpacks` are two subPaths of a single ReadWriteOnce claim, `<release>-mc-volume-claim`. The name is a **deliberate exception from the naming scheme** ([style guide](Chart-Style-Guide#1-naming)): renaming a PVC makes Kubernetes provision a new, empty volume and orphan the existing world.
|
||||
|
||||
It is an ordinary PVC in this release, not a StatefulSet `volumeClaimTemplate`. `helm uninstall` therefore deletes it, and with a Delete reclaim policy the world goes with it. **Back it up before uninstalling** — a resource-policy annotation added to the live PVC afterwards does not help, because Helm reads that annotation from the release manifest.
|
||||
|
||||
`server.properties` lives on this volume too, which is why settings the chart does not send as an environment variable persist across upgrades unchanged.
|
||||
|
||||
### One replica, one world
|
||||
|
||||
Every pod of the StatefulSet mounts the same RWO claim — there is no `volumeClaimTemplate` and no per-pod volume. Further pods stay stuck before start on a node that does not hold the volume, and two Minecraft servers on one world directory would corrupt it in any case. `NOTES.txt` says so when `server.replicas` is raised.
|
||||
|
||||
### Rotating a secret does not restart the pod
|
||||
|
||||
The StatefulSet hashes the rendered `OnePasswordItem` into `checksum/secret-ref`, so pointing `secrets.minecraftServerRef` at a **different** item rolls the pod. Changing the value **inside** the same item does not: Helm never sees that content, and the keys are consumed as environment variables, which Kubernetes never refreshes in a running container ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)).
|
||||
|
||||
The operator's own `operator.1password.io/auto-restart: true` annotation does **not** close that gap here and is therefore deliberately not set: verified in the operator source (main, 9c3b1b9, 2026-08-11), `restartWorkloadsWithUpdatedSecrets` lists nothing but `DeploymentList` and `getPodTemplate` returns "unsupported type" for everything else. This chart is a StatefulSet, so the annotation would do nothing while suggesting the opposite.
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout restart statefulset/<release>-minecraft-server-sfs
|
||||
```
|
||||
|
||||
Budget for that restart: a full server start — ~124 s measured for ftb-skies on Longhorn.
|
||||
|
||||
### RCON is declared, not published
|
||||
|
||||
With `server.rcon.enabled: true` the container declares the RCON port and the chart wires `RCON_PASSWORD`. The Service publishes only the Minecraft port, so RCON is reachable from inside the pod's network and through a forward:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> port-forward <release>-minecraft-server-sfs-0 25575:25575
|
||||
```
|
||||
|
||||
Exposing it more widely is a decision to make deliberately: it is a remote console with full server rights, protected by that one password.
|
||||
|
||||
### The metrics port is declared, not served
|
||||
|
||||
The container declares port 19565 named `metrics`. Declaring a `containerPort` opens nothing: the Service does not publish it, the chart ships no `ServiceMonitor`, and it sets no variable that would switch the image's metrics on. Treat that entry as documentation, not as a scrape target.
|
||||
|
||||
### The serverpack init script has one sharp edge
|
||||
|
||||
Its migration branch runs only when `curseforgeServerpack.workingDirectory` **already exists**; on a first install the script just downloads and the server unpacks. When it does run, it copies `banned-ips.json`, `banned-players.json`, `ops.json` and `whitelist.json` out of the backup unconditionally under `set -e` — so an existing serverpack directory missing any one of those four fails the init container and the pod never starts. Look there first when a serverpack upgrade hangs before the server logs anything:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> logs <release>-minecraft-server-sfs-0 -c download-serverpack
|
||||
```
|
||||
|
||||
Its closing `chown` reads the same pod security context helper as the pod spec (they used to be able to disagree in silence) and is explicitly non-fatal: an unprivileged process cannot chown foreign files, and under `set -e` a failure there would keep the whole pod from starting.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **Back up the world before uninstalling, and before a line change.** The PVC is a chart object, so `helm uninstall` takes it with the release.
|
||||
- **A line change is two values in one upgrade** (`image.tagOverride` + `minecraft.version`). Changing only one fails the render — which is the improvement; before 5.0.0 it installed silently.
|
||||
- **App major jumps go stepwise** and through PRs, per [Versioning](Versioning) — not through the `change-app-version.yml` dispatch workflow, which commits straight to main and bypasses both pipelines.
|
||||
- `minecraft.resources.storageClassName` is immutable on a bound volume, and shrinking `storage` fails the upgrade on the PVC instead of touching data.
|
||||
|
||||
## Renovate
|
||||
|
||||
The chart's `customManagers` block tracks `appVersion` against `itzg/minecraft-server` with
|
||||
|
||||
```
|
||||
versioning: regex:^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)-(?<compatibility>java\d+)$
|
||||
```
|
||||
|
||||
The `compatibility` group is the point: the tags encode the JRE as a suffix, and without the group Renovate would happily offer a tag from another Java line — a silent runtime switch. `image.tagOverride` is not tracked by anything. See [Renovate](Renovate).
|
||||
|
||||
## CI
|
||||
|
||||
`ci/minecraft-server/` holds three files:
|
||||
|
||||
- `test-values.yaml` — a real 2Gi world, a 1G heap and a 500m CPU request so a 4-vCPU runner copes. **`minecraft.version` is deliberately not set**: leaving it at the chart default means the install-test exercises exactly what a user gets, so a line published with a mismatched default version fails CI instead of passing on a CI-only override. `opPlayers` stays null because the image resolves every name against Playerdb and aborts the start on a miss. `server.service.type` stays `ClusterIP`, which is what the real deployments run and which keeps CI free of node-port allocation.
|
||||
- `fixtures.yaml` — a no-provisioner StorageClass named `longhorn` plus a pre-bound hostPath PV (the chart's storage class does not exist in k3d), a node-setup Job that pre-creates the directory world-writable (hostPath volumes do not support `fsGroup`), and the dummy Secret with both optional keys.
|
||||
- `settings.env` — `INSTALL_TIMEOUT=600`, because the image is large and the server downloads its vanilla jar on first start before the container settles.
|
||||
|
||||
The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements; it is the pilot for the strict `values.schema.json`
|
||||
- [Versioning](Versioning) — what a bump of `5.0.0+up2026.8.2.java25` means, and why the `+up` suffix normalizes the dash
|
||||
- [Renovate](Renovate) — the Java-line compatibility group
|
||||
- [Lessons learned](Lessons-Learned)
|
||||
- [Chart index](Charts)
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
# outline
|
||||
|
||||
Deploys [Outline](https://www.getoutline.com/), a team knowledge base, as a single-replica StatefulSet with a local upload volume. Postgres and Redis are **not** part of this chart.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `7.1.2+up1.10.1` |
|
||||
| App version | `1.10.1` (image `outlinewiki/outline`) |
|
||||
| Workloads | StatefulSet (1 replica) |
|
||||
| Storage | one RWO PVC for uploads (`FILE_STORAGE=local`) |
|
||||
| Ingress | yes, `ingress.domain` + `ingress.clusterIssuer` required |
|
||||
| External dependencies | Postgres, Redis, an OIDC provider |
|
||||
| Secrets | 3 × `OnePasswordItem` |
|
||||
| Install-test | `ci/outline/` (fixtures + test values) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **1Password operator** in the cluster, plus three 1Password items — see [the fields below](#what-the-1password-items-must-contain).
|
||||
- **Postgres and Redis reachable before the first start.** All three probes hit `/_health`, which checks both, so a missing database shows up as a pod that never turns Ready — read the container log, not the pod events.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller.
|
||||
- **An OIDC client** whose redirect URI is `https://<ingress.domain>/auth/oidc.callback`.
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install outline jlab/outline \
|
||||
--namespace outline --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
configSecretRef: op://vault/outline-config
|
||||
dbSecretRef: op://vault/outline-db
|
||||
redisSecretRef: op://vault/outline-redis
|
||||
|
||||
ingress:
|
||||
domain: wiki.example.com
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
|
||||
|
||||
### What the 1Password items must contain
|
||||
|
||||
The chart renders `OnePasswordItem` CRs; the operator turns each into a Kubernetes Secret whose **keys are the field names of the 1Password item**. A missing field does not fail the install: the release goes through and the pod then hangs in `CreateContainerConfigError` naming the key it could not find.
|
||||
|
||||
| Values key | Kubernetes Secret | Fields |
|
||||
|---|---|---|
|
||||
| `secrets.configSecretRef` | `<release>-outline-secret` | `outline-secret-key` (32 random bytes hex, `openssl rand -hex 32`), `outline-utils-secret` (a second, independent value), `discovery-url` (OIDC `.well-known` endpoint), `client-id`, `client-secret` |
|
||||
| `secrets.dbSecretRef` | `<release>-outline-db-secret` | `connection-url` — `postgres://user:password@host:5432/database` |
|
||||
| `secrets.redisSecretRef` | `<release>-outline-redis-secret` | `connection-url` — `redis://host:6379` |
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0` without uninstalling. Takes precedence over `outline.replicas`. PVC, Secrets, Service and Ingress stay. |
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.configSecretRef` | string | `null` | **Required.** `op://` path of the app/OIDC item. |
|
||||
| `secrets.dbSecretRef` | string | `null` | **Required.** `op://` path of the Postgres item. |
|
||||
| `secrets.redisSecretRef` | string | `null` | **Required.** `op://` path of the Redis item. |
|
||||
|
||||
### `outline`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `outline.replicas` | int | `1` | Replica count; `0` is allowed. See [One replica, one volume](#one-replica-one-volume) before raising it. |
|
||||
| `outline.maxUploadSize` | string | `'262144000'` | Outline's own upload ceiling, **in bytes** (~250 MiB). |
|
||||
| `outline.forceHttps` | string | `'false'` | Outline redirects to HTTPS itself. Off by default because the Ingress already terminates TLS; on, it loops endlessly if the forwarded-proto header does not reach the pod. |
|
||||
| `outline.oidc.displayName` | string | `OIDC` | Label of the login button. |
|
||||
| `outline.storage.size` | string | `10Gi` | Size of the upload PVC. |
|
||||
| `outline.storage.storageClass` | string | `null` | Storage class of the upload PVC; `null` uses the cluster default. |
|
||||
| `outline.securityContext.pod` | object | non-root 1001, `fsGroup: 1001`, `seccompProfile: RuntimeDefault` | Passed verbatim into the pod spec. The seccomp profile sits here on purpose: it covers every container including init containers. |
|
||||
| `outline.securityContext.container` | object | `readOnlyRootFilesystem: true`, all caps dropped, no privilege escalation | Passed verbatim into the container spec. Outline writes only to the PVC and to `/tmp` (an emptyDir), so the root filesystem stays read-only. |
|
||||
| `outline.livenessProbe` | object | `GET /_health:3000`, period 10s, timeout 5s, 3 failures | See [Why all three probes share `/_health`](#why-all-three-probes-share-_health). |
|
||||
| `outline.readinessProbe` | object | `GET /_health:3000`, period 10s, timeout 5s, 3 failures | |
|
||||
| `outline.startupProbe` | object | `GET /_health:3000`, period 10s, 30 failures (= 300s) | Gates liveness and readiness. The budget covers the startup migrations. |
|
||||
| `outline.env` | list | `[]` | Extra environment variables appended to the chart's own. Rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. **Additive, not overriding** — see [Environment variables](Chart-Style-Guide#10-environment-variables). |
|
||||
| `outline.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `outline.resources.requests` | object | `cpu: 0.1`, `memory: 512Mi`, `ephemeralStorage: 10Mi` | |
|
||||
| `outline.resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
|
||||
### `ingress`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | Switches the Ingress object off. **`ingress.domain` stays required** — it is also Outline's `URL` variable. |
|
||||
| `ingress.domain` | string | `null` | **Required.** Host, and the base of every generated link, share URL and the OIDC redirect URI. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.maxUploadFileSize` | string | `25m` | `proxy-body-size` annotation. See [Two upload ceilings](#two-upload-ceilings-the-smaller-one-wins). |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager and body-size annotations the chart always sets. |
|
||||
|
||||
TLS is stored in `tls-<release>-outline-ingress`.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### Why all three probes share `/_health`
|
||||
|
||||
`/_health` checks Postgres **and** Redis. Normally liveness must not hang on an external dependency ([style guide §12.3](Chart-Style-Guide#12-values-that-must-not-lie)), but Outline returns 500 on `/` as well when the database is down, so there is no dependency-free path to probe. The reason is a comment on the helper — do not "fix" the inconsistency.
|
||||
|
||||
### One replica, one volume
|
||||
|
||||
Uploads live on a **ReadWriteOnce** PVC that is a plain chart object, not a `volumeClaimTemplate`. Every replica would mount the same volume, so a second pod usually stays `Pending` on `Multi-Attach error for volume`.
|
||||
|
||||
### Two upload ceilings, the smaller one wins
|
||||
|
||||
`outline.maxUploadSize` (bytes, default ~250 MiB) and `ingress.maxUploadFileSize` (default `25m`). With the defaults the Ingress is tighter by a factor of ten, and it fails the more confusingly: the file never reaches Outline, so the user sees the ingress controller's 413 page rather than a message from the app. Raise both, or neither.
|
||||
|
||||
### Rotating a secret does not restart the pod
|
||||
|
||||
Helm never sees a `OnePasswordItem`'s content, so it cannot hash it ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)). Pointing a `*SecretRef` at a **different** item rolls the pods; editing a value **inside** the same item does not. After such a rotation:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout restart statefulset/<release>-outline-sfs
|
||||
```
|
||||
|
||||
Rotating `outline-secret-key` invalidates existing sessions, and doing so without keeping the old value makes previously encrypted data unreadable.
|
||||
|
||||
### The chart configures no mail
|
||||
|
||||
No `SMTP_*` variable is set, so invitations, notifications and digest mails are silently never sent and nothing in the UI says so. Add them under `outline.env`, which also supports `secretKeyRef` for the password.
|
||||
|
||||
### `PGSSLMODE=disable`
|
||||
|
||||
Pinned next to the connection URL. That fits a Postgres reached over the cluster network; anything less trusted needs TLS asked for explicitly, and the chart has no value for it — use `outline.env`.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **Back up the database before every app-version bump.** Migrations are one-way; a `helm rollback` returns the old image to a database the new one has already rewritten.
|
||||
- **Raise `outline.startupProbe.failureThreshold` before a large jump or a first import**, not after the crash loop has started: a restart lands in the middle of a migration.
|
||||
- **Backup is two things at once.** The database holds documents and one row per attachment, the PVC holds the attachment bytes. Snapshot them together. Because the PVC is a chart object, `helm uninstall` deletes it — the external database survives, which makes an accidental uninstall look recoverable while the attachments are already gone.
|
||||
- The first account to sign in creates the workspace and becomes its admin. On a fresh install, sign in yourself before handing out the URL.
|
||||
|
||||
## CI
|
||||
|
||||
`ci/outline/` holds `test-values.yaml` (op:// refs against the stub CRD, `outline.ci.local`) and `fixtures.yaml` (throwaway Postgres and Redis plus the dummy secrets). The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements
|
||||
- [Versioning](Versioning) — what a bump of `7.1.2+up1.10.1` means
|
||||
- [Chart index](Charts)
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
# paperless-ngx
|
||||
|
||||
Deploys [Paperless-ngx](https://docs.paperless-ngx.com/), a document management system, as a single-replica StatefulSet with two PVCs. It is the largest chart in this repository: a root init container that hands over `/run`, an optional SMB consume CronJob with its own PersistentVolume, and a scripts ConfigMap that is currently switched off. Postgres and Redis are **not** part of this chart.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `12.1.1+up3.1.0` |
|
||||
| App version | `3.1.0` (image `ghcr.io/paperless-ngx/paperless-ngx`) |
|
||||
| Workloads | StatefulSet (1 replica) with a root init container; optional consume CronJob |
|
||||
| Storage | main PVC (RWO, `paperless.files.storage`) + consume PVC (RWX, **hardcoded 1Gi**); both on the hardcoded storage class `longhorn` |
|
||||
| Ingress | yes, **always rendered** — this chart has no `ingress.enabled` |
|
||||
| External dependencies | Postgres, Redis; optionally Tika + Gotenberg, an OIDC provider, the `smb.csi.k8s.io` driver |
|
||||
| Secrets | 3 × `OnePasswordItem`, a 4th with SMB enabled |
|
||||
| Install-test | `ci/paperless-ngx/` (fixtures + test values, `INSTALL_TIMEOUT=600`) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **1Password operator** in the cluster, plus the items described in [the fields below](#what-the-1password-items-must-contain).
|
||||
- **Postgres and Redis reachable before the first start.** They fail in opposite ways and only one of them is loud — see [Redis fails quietly, Postgres does not](#redis-fails-quietly-postgres-does-not).
|
||||
- **A storage class named `longhorn`** that can serve **ReadWriteMany** for the consume claim. Both PVCs hardcode it; there is no value to change it.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller.
|
||||
- **With `smbConnection.enabled`:** the `smb.csi.k8s.io` CSI driver, which this chart does not install.
|
||||
- **With `oidc.enabled`:** an OIDC client that completes the flow **without a client secret** (PKCE).
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install paperless jlab/paperless-ngx \
|
||||
--namespace paperless --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
databaseSecretRef: op://vault/paperless-db
|
||||
redisSecretRef: op://vault/paperless-redis
|
||||
paperlessSecretRef: op://vault/paperless
|
||||
|
||||
ingress:
|
||||
domain: paperless.example.com
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
|
||||
|
||||
### What the 1Password items must contain
|
||||
|
||||
The chart renders `OnePasswordItem` CRs; the operator turns each into a Kubernetes Secret whose **keys are the field names of the 1Password item**. A missing field does not fail the install: the release goes through and the pod then hangs in `CreateContainerConfigError` naming the key it could not find.
|
||||
|
||||
| Values key | Kubernetes Secret | Fields |
|
||||
|---|---|---|
|
||||
| `secrets.databaseSecretRef` | `<release>-paperless-ngx-db-secret` | `server` (the host — the field is called `server`, not `host`), `port`, `database`, `username`, `password`. The engine is pinned to `postgresql`; no value changes it. |
|
||||
| `secrets.redisSecretRef` | `<release>-paperless-ngx-redis-secret` | `redis-connection-url` (e.g. `redis://:PASSWORD@redis:6379`), `key-prefix` (`PAPERLESS_REDIS_PREFIX` — set it per release, or two releases sharing one Redis consume each other's task queue) |
|
||||
| `secrets.paperlessSecretRef` | `<release>-paperless-ngx-secret` | `username` (used **twice**, as `PAPERLESS_ADMIN_USER` *and* `PAPERLESS_ADMIN_MAIL`, so it has to work as a login name and as an address), `password`, `paperless-secret-key` (Django's `SECRET_KEY`) |
|
||||
| `secrets.smbConnectionSecretRef` | `<release>-paperless-ngx-smb-secret` | Read by **no pod**: it is wired into the PersistentVolume as `nodeStageSecretRef` and the `smb.csi.k8s.io` driver reads it itself — usually `username` and `password`, plus `domain` where the server wants one. The chart names no key, so a wrong field name surfaces as a mount failure on the CronJob's pod. |
|
||||
|
||||
`PAPERLESS_ADMIN_*` is only acted on while that superuser does not exist yet. Editing the `password` field after the first start changes nothing — reset it in the pod:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> exec -it <release>-paperless-ngx-sfs-0 -- \
|
||||
python3 /usr/src/paperless/src/manage.py changepassword <user>
|
||||
```
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0` **and suspends the consume CronJob**. Takes precedence over `paperless.replicas`. PVCs, Secrets, Service and Ingress stay. |
|
||||
| `global` | object | `{}` | Helm's cross-chart convention. Read by nothing here; kept open so an umbrella install cannot break on it. |
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.databaseSecretRef` | string | `null` | **Required.** `op://` path of the Postgres item. |
|
||||
| `secrets.redisSecretRef` | string | `null` | **Required.** `op://` path of the Redis item. |
|
||||
| `secrets.paperlessSecretRef` | string | `null` | **Required.** `op://` path of the app item. |
|
||||
| `secrets.smbConnectionSecretRef` | string | `null` | **Required while `smbConnection.enabled` is true**, ignored otherwise. |
|
||||
| `secrets.oidcSecretRef` | string | `null` | Accepted by the schema and **read by no template** — see [`secrets.oidcSecretRef` renders nothing](#secretsoidcsecretref-renders-nothing). |
|
||||
|
||||
### `ingress`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.domain` | string | `null` | **Required, always.** Ingress host *and* `PAPERLESS_URL` (`https://<domain>`), the only origin the release declares. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required, always** — the Ingress has no `enabled` gate to free it. cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager and the two body-size annotations the chart always sets. |
|
||||
|
||||
TLS is stored in `tls-<release>-paperless-ngx-ingress`.
|
||||
|
||||
### `paperless`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `paperless.replicas` | int | `1` | Replica count; `0` is allowed. See [One replica, one volume](#one-replica-one-volume) before raising it. |
|
||||
| `paperless.busybox.image.repository` | string | `busybox` | Auxiliary image for the `/run`-ownership init container and the SMB consume mover. Pinned independently of `appVersion`; reviewed on every app-update round ([Renovate](Renovate)). |
|
||||
| `paperless.busybox.image.tag` | string | `1.38.0` | |
|
||||
| `paperless.ocr.languages` | string | `eng` | `PAPERLESS_OCR_LANGUAGE`, **singular**. The plural variable that makes the image fetch packages at start is deliberately not set — see [OCR languages come with the image](#ocr-languages-come-with-the-image). |
|
||||
| `paperless.ocr.mode` | string | `auto` | `auto` \| `redo` \| `force` \| `off`. The 2.x values `skip`/`skip_noarchive` were removed upstream; `skip` maps to `auto`. |
|
||||
| `paperless.ocr.archiveFileGeneration` | string | `auto` | `auto` \| `always` \| `never`. PDF/A generation, decoupled from OCR in 3.0 (`always` is the 2.x behaviour). |
|
||||
| `paperless.ocr.rotate_pages` | string/bool | `'true'` | `PAPERLESS_OCR_ROTATE_PAGES`. |
|
||||
| `paperless.ocr.output_type` | string | `pdfa` | `PAPERLESS_OCR_OUTPUT_TYPE`. |
|
||||
| `paperless.files.filenameFormat` | string | `{{ created_year }}/{{ correspondent }}/{{ title }}` | `PAPERLESS_FILENAME_FORMAT` (paperless's own placeholder syntax, not Helm's). |
|
||||
| `paperless.files.storage` | string | `20Gi` | Size of the **main** PVC only (`data/`, `media/`, `export/` as subPaths). Not null-safe on purpose: an erased value yields an invalid PVC instead of a degraded workload. |
|
||||
| `paperless.files.maxUploadFileSize` | string | `10m` | Rendered into both `nginx.ingress.kubernetes.io/proxy-body-size` and `nginx.org/client-max-body-size`. Applies to web uploads only. |
|
||||
| `paperless.files.barcodes.enabled` | bool | `true` | Barcode handling. **Only an explicit `false` disables it** — see [A childless block means "defaults"](#a-childless-block-means-defaults). |
|
||||
| `paperless.files.barcodes.barcodeString` | string | `PATCHT` | Separator barcode. |
|
||||
| `paperless.files.barcodes.retainBarcodePage` | bool | `false` | Keep the separator page in the split documents. |
|
||||
| `paperless.files.barcodes.asn.enabled` | bool | `true` | Archive-serial-number barcodes. |
|
||||
| `paperless.files.deleteDuplicates` | bool | `false` | `true` restores the 2.x behaviour of **deleting** a document that matches one already in the archive. |
|
||||
| `paperless.files.tikaGotenberg.enabled` | bool/string | `false` | Office-document conversion. The schema accepts a string because live bundles write `"true"`; the template gates on **truthiness**, so the *string* `"false"` still renders the block. Use a real boolean `false` to switch it off. |
|
||||
| `paperless.files.tikaGotenberg.tikaEndpoint` | string | `null` | **Required while the block is on.** Bare `host:port` — see [The Tika endpoints carry no scheme](#the-tika-endpoints-carry-no-scheme). |
|
||||
| `paperless.files.tikaGotenberg.gotenbergEndpoint` | string | `null` | **Required while the block is on.** Bare `host:port`. |
|
||||
| `paperless.scripts.preConsumptionScripts` | list | `[]` | Renders no object and changes no behaviour — see [`paperless.scripts` does nothing](#paperlessscripts-does-nothing). |
|
||||
| `paperless.scripts.postConsumptionScripts` | list | `[]` | As above. |
|
||||
| `paperless.livenessProbe` | object | `GET /:8000`, period 30s, timeout 5s, 3 failures | |
|
||||
| `paperless.readinessProbe` | object | `GET /:8000`, period 10s, timeout 5s, 3 failures | |
|
||||
| `paperless.startupProbe` | object | `GET /:8000`, period 10s, 60 failures (= 600s) | Gates liveness and readiness. The budget covers migrations and, on the first 3.x start, the Whoosh → Tantivy index rebuild. |
|
||||
| `paperless.env` | list | `[]` | Extra environment variables appended to the chart's own. Rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. **Additive, not overriding** — see [Environment variables](Chart-Style-Guide#10-environment-variables). |
|
||||
| `paperless.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `paperless.resources.requests` | object | `cpu: 0.2`, `memory: 1024Mi`, `ephemeralStorage: 200Mi` | |
|
||||
| `paperless.resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
| `paperless.securityContext.pod` | object | non-root 1000/1000, `fsGroup: 1000`, `fsGroupChangePolicy: Always` | Passed verbatim into the pod spec **and read a second time by the init container**, which chowns `/run` to `runAsUser:runAsGroup`. |
|
||||
| `paperless.securityContext.container` | object | `readOnlyRootFilesystem: true`, all caps dropped, no privilege escalation, non-root | Passed verbatim into the main container. Only `/tmp` and `/run` (emptyDirs) and the PVCs are writable. |
|
||||
|
||||
### `smbConnection`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `smbConnection.enabled` | bool | `false` | Renders the SMB PV, PVC, `OnePasswordItem` and the consume CronJob. |
|
||||
| `smbConnection.server` | string | `null` | Share address, used as `//<server>` and as the PV's `volumeHandle`. See [The SMB PersistentVolume outlives the release](#the-smb-persistentvolume-outlives-the-release). |
|
||||
| `smbConnection.cronJob` | string | `0 0,12 * * *` | Cron schedule. **A schedule starting with `*` breaks the render** — see [A schedule may not start with `*`](#a-schedule-may-not-start-with-). |
|
||||
| `smbConnection.cron.securityContext.pod` | object | non-root 1000/1000, `fsGroup: 1000`, `fsGroupChangePolicy: Always` | The mover only shuffles files between two volumes, so it runs fully hardened as the same uid as paperless. |
|
||||
| `smbConnection.cron.securityContext.container` | object | `readOnlyRootFilesystem: true`, all caps dropped, no privilege escalation, non-root | |
|
||||
| `smbConnection.cron.resources.limitFactor` | number | `3` | |
|
||||
| `smbConnection.cron.resources.requests` | object | `cpu: 10m`, `memory: 16Mi`, `ephemeralStorage: 10Mi` | |
|
||||
| `smbConnection.cron.resources.limits` | object | unset | |
|
||||
|
||||
### `oidc`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `oidc.enabled` | bool | `false` | Adds the allauth OpenID-Connect provider. PKCE-only, no client secret. |
|
||||
| `oidc.autoLoginWithProvider` | bool | `false` | `PAPERLESS_REDIRECT_LOGIN_TO_SSO` — skips the app's own login form. |
|
||||
| `oidc.disableRegularLogin` | bool | `false` | `PAPERLESS_DISABLE_REGULAR_LOGIN` — removes it. See [Locking yourself out with OIDC](#locking-yourself-out-with-oidc). |
|
||||
| `oidc.provider.id` | string | `null` | **Required while `oidc.enabled` is true.** Appears in the redirect URI. |
|
||||
| `oidc.provider.name` | string | `null` | **Required while `oidc.enabled` is true.** Label of the login button. |
|
||||
| `oidc.provider.clientId` | string | `null` | **Required while `oidc.enabled` is true.** Travels as plain env, not through a Secret. |
|
||||
| `oidc.provider.discoveryUrl` | string | `null` | **Required while `oidc.enabled` is true.** The provider's `.well-known` endpoint. |
|
||||
| `oidc.provider.tokenAuthMethod` | string | `null` | Explicit token-endpoint auth method (e.g. `client_secret_basic`). Some providers need it since the allauth update in 3.0; the symptom without it is a login dying at the callback with `invalid_client`. |
|
||||
|
||||
## Operating notes
|
||||
|
||||
### Redis fails quietly, Postgres does not
|
||||
|
||||
Without the database paperless does not come up at all — and all three probes ask for `/` on port 8000, so you get no useful error: the pod never turns Ready and the startup probe kills it after 600s. Read the container log, not the pod events.
|
||||
|
||||
Without Redis the web UI comes up and looks healthy. Redis is the Celery broker: consumption, OCR, the scheduled tasks and the index updates simply never run, and documents dropped into the consume directory stay there. A wrong `redis-connection-url` costs the whole processing side and nothing turns red.
|
||||
|
||||
### What a green pod does not prove
|
||||
|
||||
One container runs the web server, the Celery worker and the scheduler side by side under s6-overlay, and all three probes only ask the web server for `/`. A dead worker, a consumer that is not watching and a broken Redis connection all leave the pod Ready and the rollout green. The honest check is a document going in and coming out the other side, plus:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> logs <release>-paperless-ngx-sfs-0 | grep -i -e error -e celery
|
||||
```
|
||||
|
||||
### The root init container that hands over `/run`
|
||||
|
||||
s6-overlay refuses to start unless `/run` is **owned** by the runtime uid — group-write is not enough — while an emptyDir is always created root-owned. A minimal busybox init container therefore runs as uid 0 with only `CHOWN` added back, chowns the `/run` emptyDir to the pod context's `runAsUser:runAsGroup`, and exits. This is the documented deviation from the hardening baseline ([style guide §5](Chart-Style-Guide#5-security-hardening)); the main container itself stays non-root with a read-only root filesystem.
|
||||
|
||||
A consequence of that read-only rootfs: anything the app would install at runtime — an extra tesseract language pack, for instance — cannot be written, no matter which variable asks for it.
|
||||
|
||||
### How documents get in
|
||||
|
||||
Everything in `/usr/src/paperless/consume` is picked up by the consumer. That directory is its **own** volume, `<release>-paperless-ngx-consume-volume-claim`, and its size is *not* `paperless.files.storage`: the claim is hardcoded to **1Gi, ReadWriteMany**, and both PVCs are hardcoded to storage class `longhorn`. Without SMB, the web UI is the only automatic route in; to hand a file over directly:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> cp <file.pdf> <release>-paperless-ngx-sfs-0:/usr/src/paperless/consume/
|
||||
```
|
||||
|
||||
Web uploads take the second route and pass the Ingress, where `paperless.files.maxUploadFileSize` (default `10m`) caps the body. A larger file is rejected by the ingress controller with a 413 before paperless ever sees it — the consume directory has no such limit.
|
||||
|
||||
### The consume CronJob is the one probes exception
|
||||
|
||||
`smbConnection.enabled: true` adds `<release>-paperless-ngx-consume-cronjob`, which mounts the share and runs, once per firing:
|
||||
|
||||
```
|
||||
find /mnt/smb -maxdepth 1 -name "*.pdf" → mv into the consume volume
|
||||
```
|
||||
|
||||
Read that literally. Anything in a **subdirectory** is never touched, and anything that is not `*.pdf` — an image, an `.eml`, a `.PDF` in capitals — stays on the share forever with nothing reporting it. It is a **move, not a copy**: after the job has run, the only copy is on the consume volume.
|
||||
|
||||
The job has **no probes** — the single documented exception to the repo-wide probes rule ([style guide §6](Chart-Style-Guide#6-probes)) — and it cannot fail usefully either: with nothing to move it exits 0, indistinguishable from a successful run. Judge it by what arrives:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> get jobs --sort-by=.metadata.creationTimestamp
|
||||
kubectl -n <namespace> logs job/<name-of-the-last-one>
|
||||
```
|
||||
|
||||
`scaleDown: true` suspends the CronJob. `paperless.replicas: 0` does **not** — it keeps moving PDFs off the share onto a consume volume that nothing is reading, so the files are gone from the share and not yet in paperless.
|
||||
|
||||
### A schedule may not start with `*`
|
||||
|
||||
`smbConnection.cronJob` is rendered into the manifest **unquoted**, so a schedule that starts with `*` (`*/15 * * * *`) aborts the render with a YAML parse error — YAML reads the leading `*` as an alias, and quoting it in your values file does not help because the quotes are gone by then. Use an equivalent that starts with a digit (`5,20,35,50 * * * *`); a `*` anywhere but in first position is fine.
|
||||
|
||||
### The SMB PersistentVolume outlives the release
|
||||
|
||||
`<release>-paperless-ngx-smb-pv` is **cluster-scoped** with reclaim policy `Retain`, and its `volumeHandle` is derived from `smbConnection.server` alone. Two releases pointing at the same server collide on that handle, and a retained PV survives `helm uninstall` and blocks the next install of the same name until it is deleted by hand.
|
||||
|
||||
### One replica, one volume
|
||||
|
||||
The main volume is a plain **ReadWriteOnce** PVC shared by the whole StatefulSet, not a `volumeClaimTemplate`. A second pod usually stays `Pending` on `Multi-Attach error for volume`; where the storage class really is RWX and it does start, two consumers watch the same directory and two schedulers run the same periodic tasks.
|
||||
|
||||
### Rotating a secret does not restart the pod
|
||||
|
||||
Helm never sees a `OnePasswordItem`'s content, so it cannot hash it ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)) — and this chart carries **no checksum annotation at all**, not even over the rendered item paths. Pointing a `*SecretRef` at a **different** item rolls the pods; editing a value **inside** the same item does not. After such a rotation:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout restart statefulset/<release>-paperless-ngx-sfs
|
||||
```
|
||||
|
||||
Rotating `paperless-secret-key` logs every session out. Rotating the database fields points the release at whatever that endpoint contains, empty or not, and the next start migrates it.
|
||||
|
||||
### A childless block means "defaults"
|
||||
|
||||
An override that writes `barcodes:` with no children is YAML `null`. The chart merges the block onto its defaults instead of letting the childless key erase them (#61), so this **does not switch barcodes off** — only `barcodes.enabled: false` does. The same null-safe semantics apply to the probe, `securityContext` and `resources` blocks of both workloads (#99). See [style guide §12.2](Chart-Style-Guide#12-values-that-must-not-lie).
|
||||
|
||||
### The Tika endpoints carry no scheme
|
||||
|
||||
The chart prefixes `http://` itself, so `tikaEndpoint` and `gotenbergEndpoint` are bare `host:port`. A value written with a scheme renders as `http://http://…`, every Office document fails to convert, and everything else keeps working. Neither service is part of this chart or checked at startup.
|
||||
|
||||
### OCR languages come with the image
|
||||
|
||||
`paperless.ocr.languages` sets `PAPERLESS_OCR_LANGUAGE` — *which* language to use. The chart deliberately does not set the plural `PAPERLESS_OCR_LANGUAGES` that makes the image fetch further packages at start, and with the read-only root filesystem it could not work anyway: a language the image does not already carry needs a different image, not a different value.
|
||||
|
||||
### `paperless.scripts` does nothing
|
||||
|
||||
The ConfigMap that would carry the pre/post-consumption scripts is disabled at the top of its own template (`{{- if false }}`), no volume mounts it, and neither `PAPERLESS_PRE_CONSUME_SCRIPT` nor `PAPERLESS_POST_CONSUME_SCRIPT` is set on the container. Both values render no object and change no behaviour; `NOTES.txt` says so out loud when either list is non-empty.
|
||||
|
||||
### `secrets.oidcSecretRef` renders nothing
|
||||
|
||||
No template references it: no `OnePasswordItem` is created and no Secret appears. The OIDC client id travels as plain env from `oidc.provider.clientId`, and no client secret is configured at all. Whatever the item holds has no effect on the release.
|
||||
|
||||
### Locking yourself out with OIDC
|
||||
|
||||
The chart enables PKCE and configures **no client secret** — the client has to be registered as one that can complete the flow without one. Register this redirect URI at the provider:
|
||||
|
||||
```
|
||||
https://<ingress.domain>/accounts/oidc/<oidc.provider.id>/login/callback/
|
||||
```
|
||||
|
||||
Nothing about OIDC is exercised before someone actually signs in, so a wrong discovery URL, client id or redirect URI surfaces as a failed login on a perfectly healthy pod. Before relying on `autoLoginWithProvider` or `disableRegularLogin`, make sure at least one local superuser still has a usable password and that Django's admin login at `/admin/` still lets it in — that is the only way back when the provider is unreachable.
|
||||
|
||||
### Port-forwarding and CSRF
|
||||
|
||||
`ingress.domain` is also `PAPERLESS_URL`, the only origin the release declares. A port-forward loads pages over `http://localhost:8000`, but the login POST is refused as a CSRF failure. Forward the port to look around, use the domain to log in, or add `PAPERLESS_CSRF_TRUSTED_ORIGINS` through `paperless.env` for as long as needed. Changing `ingress.domain` later changes `PAPERLESS_URL` with it, and the pods have to restart before they notice.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **Back up before every app-version bump, and back up both halves.** Postgres holds the metadata, the tags and one row per document; `<release>-paperless-ngx-volume-claim` holds the files under `data/`, `media/` and `export/`. Restoring one without the other gives an archive full of references to documents that are not there.
|
||||
- **Migrations are one-way.** `helm rollback` puts the old image back on a database the new one has already rewritten.
|
||||
- **Raise `paperless.startupProbe.failureThreshold` before a large jump or a first import**, not after the crash loop has started: every start runs migrations before the server answers, the first 3.x start also rebuilds the search index from scratch, and a restart lands in the middle of it.
|
||||
- **Both PVCs are chart objects**, so `helm uninstall` deletes them and the documents with them unless the reclaim policy says otherwise. The external database survives — which makes an accidental uninstall look recoverable while the files are already gone.
|
||||
- App **major** jumps go stepwise: last release of the old major line first, then the newest major ([Versioning](Versioning)).
|
||||
|
||||
## CI
|
||||
|
||||
`ci/paperless-ngx/` holds `test-values.yaml` (op:// refs against the stub CRD, `paperless.ci.local`, and a deliberately childless `barcodes:` key as the regression guard for #61), `fixtures.yaml` (throwaway Postgres and Redis, the dummy secrets, a no-provisioner `longhorn` StorageClass with pre-bound hostPath PVs, and a node-setup Job that pre-creates the exact subPath directories world-writable because hostPath volumes ignore `fsGroup`) and `settings.env` (`INSTALL_TIMEOUT=600` for the first-start migrations). `smbConnection` and `oidc` stay off in CI. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements, and the two exceptions it holds
|
||||
- [Versioning](Versioning) — what a bump of `12.1.1+up3.1.0` means
|
||||
- [Renovate](Renovate) — how `appVersion` and the busybox image are updated
|
||||
- [Lessons learned](Lessons-Learned)
|
||||
- [Chart index](Charts)
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
# patchmon
|
||||
|
||||
Deploys [PatchMon](https://patchmon.net/), a patch-status monitor for Linux and Windows hosts, as a single stateless Deployment. The 2.x server serves the frontend and `/api/*` from one process; frontend assets, agent binaries and SCAP content are baked into the image, so there is no PVC. Postgres and Redis are **not** part of this chart, and sign-in is **OIDC-only**.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `7.0.1+up2.1.3` |
|
||||
| App version | `2.1.3` (image `ghcr.io/patchmon/patchmon-server`) |
|
||||
| Workloads | one Deployment (1 replica), optional `guacd` sidecar in the same pod |
|
||||
| Storage | none — the image is stateless; `/tmp` is an emptyDir |
|
||||
| Ingress | yes, gated by `ingress.enabled`; `ingress.domain` survives the gate |
|
||||
| External dependencies | Postgres, Redis, an OIDC provider |
|
||||
| Secrets | 4 × `OnePasswordItem` |
|
||||
| Install-test | `ci/patchmon/` (fixtures with postgres, redis and a dex OIDC provider) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **1Password operator** in the cluster, plus **four** items — see [the fields below](#what-the-1password-items-must-contain).
|
||||
- **Postgres and Redis reachable before the first start.** The server waits for the database and runs migrations at boot.
|
||||
- **An OIDC provider.** There is no local account to fall back on — see [Sign-in is OIDC-only](#sign-in-is-oidc-only-and-there-is-no-local-account).
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller.
|
||||
- **The hostname decided up front.** A wrong `ingress.domain` keeps the pod out of Ready forever — see [below](#a-wrong-ingressdomain-keeps-the-pod-out-of-ready-forever).
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install patchmon jlab/patchmon \
|
||||
--namespace patchmon --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
postgresSecretRef: op://vault/patchmon-postgres
|
||||
redisSecretRef: op://vault/patchmon-redis
|
||||
patchmonSecretRef: op://vault/patchmon
|
||||
oidcSecretRef: op://vault/patchmon-oidc
|
||||
|
||||
ingress:
|
||||
domain: patchmon.example.com
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
|
||||
|
||||
### What the 1Password items must contain
|
||||
|
||||
The chart renders `OnePasswordItem` CRs that carry an item path only; the operator turns each into a Kubernetes Secret whose **keys are the field names of the item**. Which *fields* each item needs is decided in the Deployment. A missing field does **not** fail the install and does not even stop the container: the variable arrives empty and the server misbehaves later.
|
||||
|
||||
| Values key | Kubernetes Secret | Fields |
|
||||
|---|---|---|
|
||||
| `secrets.postgresSecretRef` | `<release>-patchmon-postgres-secret` | `database-url` — e.g. `postgresql://user:password@host:5432/patchmon` |
|
||||
| `secrets.redisSecretRef` | `<release>-patchmon-redis-secret` | `host`, `port`, `username`, `password`, `db` |
|
||||
| `secrets.patchmonSecretRef` | `<release>-patchmon-patchmon-secret` | `jwt-secret`, `encryption-key` (`AI_ENCRYPTION_KEY`), `session-secret` |
|
||||
| `secrets.oidcSecretRef` | `<release>-patchmon-oidc-secret` | `issuer-url`, `client-id`, `client-secret`, `redirect-uri`, `requested-scopes`, `button-text`, `user-group-name`, `admin-group-name` |
|
||||
|
||||
Watch them materialize:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> get onepassworditem
|
||||
```
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders the Deployment with `replicas: 0`. Takes precedence over `patchmon.server.replicas`. Secrets, Service and Ingress stay; the agents on your hosts keep retrying, so nothing is lost. |
|
||||
| `global` | object | `{}` | Helm's cross-chart convention. Read by nothing here; kept open so an umbrella install cannot break on it. |
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.postgresSecretRef` | string | `null` | **Required.** `op://` path of the Postgres item. |
|
||||
| `secrets.redisSecretRef` | string | `null` | **Required.** `op://` path of the Redis item. |
|
||||
| `secrets.patchmonSecretRef` | string | `null` | **Required.** `op://` path of the app-secrets item. |
|
||||
| `secrets.oidcSecretRef` | string | `null` | **Required.** `op://` path of the OIDC item — the chart hardcodes `OIDC_ENABLED=true`, so this one is not optional. |
|
||||
|
||||
### `patchmon.server`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `patchmon.server.replicas` | int | `1` | Replica count; `0` is allowed. **Raising it does not scale the server out** — see [One replica only](#one-replica-only). |
|
||||
| `patchmon.server.securityContext.pod` | object | non-root 65532/65532, `seccompProfile: RuntimeDefault` | Passed verbatim into the pod spec. The seccomp profile sits here on purpose: from the pod context it covers every container — the server and the guacd sidecar alike — including any init container added later. |
|
||||
| `patchmon.server.securityContext.container` | object | `readOnlyRootFilesystem: true`, all caps dropped, no privilege escalation, non-root | The image is a hardened Alpine build running as uid 65532; only `/tmp` (emptyDir) is writable. |
|
||||
| `patchmon.server.livenessProbe` | object | `GET /health:3000` with `Host: {{ .Values.ingress.domain }}`, period 10s, timeout 5s, 3 failures | The `Host` header is load-bearing — see [below](#a-wrong-ingressdomain-keeps-the-pod-out-of-ready-forever). Probes are rendered through `tpl`, so the header follows `ingress.domain`. |
|
||||
| `patchmon.server.readinessProbe` | object | same, period 10s, timeout 5s, 3 failures | |
|
||||
| `patchmon.server.startupProbe` | object | same, period 10s, 60 failures (= 600s) | Gates liveness and readiness. The first 2.x boot rebuilds database indexes, which takes minutes on slow storage. |
|
||||
| `patchmon.server.env` | list | `[]` | Extra environment variables appended to the chart's own. Rendered through `tpl`; `value`, `secretKeyRef`, `configMapKeyRef`. **Additive, not overriding** — see [Environment variables](Chart-Style-Guide#10-environment-variables). |
|
||||
| `patchmon.server.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `patchmon.server.resources.requests` | object | `cpu: 0.1`, `memory: 256Mi`, `ephemeral-storage: 10Mi` | |
|
||||
| `patchmon.server.resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
|
||||
### `patchmon.guacd`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `patchmon.guacd.enabled` | bool | `false` | Runs the Apache Guacamole daemon as a sidecar and points the server at it via `GUACD_ADDRESS=127.0.0.1:4822`. In-browser RDP for Windows hosts, which upstream still labels beta. |
|
||||
| `patchmon.guacd.image` | string | `docker.io/guacamole/guacd:1.6.0` | Full image reference including the tag (not a `repository`/`tag` pair). An independent upstream, so it does not follow `appVersion`; reviewed on every app-update round. |
|
||||
| `patchmon.guacd.securityContext.container` | object | `readOnlyRootFilesystem: true`, all caps dropped, no privilege escalation, non-root 1000/1000 | **No `pod` key exists here** — a sidecar shares the server's pod context. |
|
||||
| `patchmon.guacd.livenessProbe` | object | TCP `4822`, period 10s, timeout 5s, 3 failures | |
|
||||
| `patchmon.guacd.readinessProbe` | object | TCP `4822`, period 10s, timeout 5s, 3 failures | |
|
||||
| `patchmon.guacd.startupProbe` | object | TCP `4822`, period 5s, 30 failures (= 150s) | |
|
||||
| `patchmon.guacd.env` | list | `[]` | Extra environment variables for the sidecar (e.g. `GUACD_LOG_LEVEL`); the `env` block is only rendered when the list is non-empty. |
|
||||
| `patchmon.guacd.resources.limitFactor` | number | `3` | |
|
||||
| `patchmon.guacd.resources.requests` | object | `cpu: 0.05`, `memory: 64Mi`, `ephemeral-storage: 10Mi` | |
|
||||
| `patchmon.guacd.resources.limits` | object | unset | |
|
||||
|
||||
### `ingress`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | `false` omits the Ingress object and frees `clusterIssuer` — **but not `domain`**. |
|
||||
| `ingress.domain` | string | `null` | **Required, always** — also with `ingress.enabled: false`. It is the Ingress host, the server's `CORS_ORIGIN` (`https://<domain>`), and the `Host` header of all three probes. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. |
|
||||
|
||||
The Ingress carries a single `/` prefix path: the 2.x server serves the frontend and `/api/*` from one process, WebSockets included. TLS is stored in `tls-<release>-patchmon-ingress`.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### A wrong `ingress.domain` keeps the pod out of Ready forever
|
||||
|
||||
The 2.x server answers **403** to any request whose `Host` header is neither loopback nor a `CORS_ORIGIN` host, and `CORS_ORIGIN` is built from `ingress.domain`. Kubelet probes address the pod IP, so they must send the public hostname explicitly — which is why all three probe defaults carry an `httpHeaders` entry rendered through `tpl` from `ingress.domain`.
|
||||
|
||||
If that value is not the hostname the server is actually reached under, the probes get 403, the pod never turns Ready, and nothing in the log looks like a configuration error — just repeated 403s. This is the case [§12.5](Chart-Style-Guide#12-values-that-must-not-lie) is written from: a probe default carries the probe's *context*, not just its timings, and where the context is dynamic the fallback helper takes the root context in and rebuilds the header.
|
||||
|
||||
The same applies when you address the server yourself:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> port-forward svc/<release>-patchmon-service 3000:80
|
||||
curl -H "Host: <ingress.domain>" http://localhost:3000/health
|
||||
```
|
||||
|
||||
### `ingress.domain` is required even without an Ingress
|
||||
|
||||
`ingress.enabled: false` removes the Ingress object and makes `clusterIssuer` unnecessary. It does **not** free `ingress.domain`: the Deployment reads it for `CORS_ORIGIN` behind its own `required()`, and the probe defaults read it through `tpl`. This is exactly the "a value read outside its obvious template needs its own guard" case from [§12.1](Chart-Style-Guide#12-values-that-must-not-lie).
|
||||
|
||||
> The comment above `ingress.enabled` in `values.yaml` and the `enabled` description in `values.schema.json` both claim the domain is only read inside the Ingress template. That is stale — the templates and `NOTES.txt` are right, those two comments are wrong.
|
||||
|
||||
### Sign-in is OIDC-only, and there is no local account
|
||||
|
||||
The chart hardcodes `OIDC_ENABLED=true` and `OIDC_DISABLE_LOCAL_AUTH=true`: the login form is gone and there is no built-in admin. **If the OIDC item is wrong, nobody gets in — including you.**
|
||||
|
||||
Users are created on first login (`OIDC_AUTO_CREATE_USERS=true`) with the `readonly` role; admin rights follow the group named in the item's `admin-group-name` field, and roles are synchronized on each login (`OIDC_SYNC_ROLES=true`). The item's `redirect-uri` must point back at this installation under `https://<ingress.domain>` and match what is registered at the identity provider **exactly**.
|
||||
|
||||
The 2.x role vocabulary is `superadmin`, `admin`, `host_manager`, `readonly`, `user`. The 1.x `viewer` role this chart used to default to no longer exists; `readonly` is its least-privilege equivalent.
|
||||
|
||||
### One replica only
|
||||
|
||||
The server registers itself in the Redis presence registry under its **pod hostname**, which the chart pins to `<release>-patchmon-server` — a Deployment pod's default hostname changes on every rollout and would leave stale presence records behind (upstream pins the same name in its compose file). A second replica would register the same identity, so this chart supports exactly one. Raising `patchmon.server.replicas` does not scale it out.
|
||||
|
||||
### Give the first start time
|
||||
|
||||
The server waits for the database and runs migrations at boot, and the first 2.x start rebuilds the database indexes — minutes on slow storage. The startup probe grants 600s before the kubelet starts killing the container, so a slow first boot is expected, not a hang:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> logs deploy/<release>-patchmon-deployment -f
|
||||
```
|
||||
|
||||
### Rotating a secret does not restart the pod
|
||||
|
||||
Helm never sees a `OnePasswordItem`'s content, so it cannot hash it ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)). This chart sets neither a `checksum/secret-ref` annotation nor the operator's `operator.1password.io/auto-restart` — which, unlike the StatefulSet charts here, it *could*: the annotation works for Deployments. As it stands, pointing a `*SecretRef` at a **different** item rolls the pods; editing a value **inside** the same item does not:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout restart deployment/<release>-patchmon-deployment
|
||||
```
|
||||
|
||||
Rotating `jwt-secret` or `session-secret` invalidates existing sessions. Rotating `encryption-key` without keeping the old value makes data encrypted under it unreadable.
|
||||
|
||||
### The guacd sidecar
|
||||
|
||||
With `patchmon.guacd.enabled: true` the daemon runs beside the server and is reached at `127.0.0.1:4822`; it listens on the pod only and nothing exposes 4822 outside. The image is pinned separately from `appVersion` and needs its own review at upgrade time — see [Renovate](Renovate), which tracks it through the built-in `helm-values` manager.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **Back up the database before every app-version bump.** Migrations run at boot and are one-way; a `helm rollback` returns the old image to a database the new one has already rewritten. The chart itself holds no state at all, so the database plus the four 1Password items *are* the backup.
|
||||
- **Raise `patchmon.server.startupProbe.failureThreshold` before a large jump**, not after the crash loop: a restart lands in the middle of an index rebuild.
|
||||
- App **major** jumps go stepwise: last release of the old major line first, then the newest major ([Versioning](Versioning)). The 1.x → 2.x jump is what turned this chart from two Deployments (frontend + backend) into the single one documented here.
|
||||
- Review the `guacd` pin in the same round.
|
||||
|
||||
## CI
|
||||
|
||||
`ci/patchmon/` holds `test-values.yaml` (op:// refs against the stub CRD, `patchmon.ci.local`) and `fixtures.yaml` (throwaway Postgres and Redis, a **dex** OIDC provider with a static client, and the four dummy secrets). Because the 2.x server is a single stateless container, no PV, StorageClass or service-alias fixtures are needed and the chart takes the default install timeout. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — §12.5 is written from this chart's probes
|
||||
- [Versioning](Versioning) — what a bump of `7.0.1+up2.1.3` means
|
||||
- [Renovate](Renovate) — `appVersion` and the guacd pin
|
||||
- [Lessons learned](Lessons-Learned)
|
||||
- [Chart index](Charts)
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
# rallly
|
||||
|
||||
Deploys [Rallly](https://rallly.co/), a self-hosted scheduling and poll app, as a stateless Deployment. Postgres is **not** part of this chart, and neither is any persistent volume: the only writable path is an emptyDir over the Next.js cache. Sign-in goes exclusively through an OIDC provider.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `8.1.4+up4.15.1` |
|
||||
| App version | `4.15.1` (image `lukevella/rallly`) |
|
||||
| Workloads | Deployment (1 replica) |
|
||||
| Storage | none persistent — an emptyDir mounted over the Next.js cache directory |
|
||||
| Ingress | yes, `ingress.domain` + `ingress.clusterIssuer` required |
|
||||
| External dependencies | Postgres, an OIDC provider, optionally an SMTP server |
|
||||
| Secrets | 3 × `OnePasswordItem`, plus a 4th when SMTP is enabled |
|
||||
| Install-test | `ci/rallly/` (fixtures + test values) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **1Password operator** in the cluster, plus three (or four) 1Password items — see [the fields below](#what-the-1password-items-must-contain).
|
||||
- **Postgres reachable before the first start.** The chart brings no database.
|
||||
- **An OIDC client** whose redirect URI is `https://<ingress.domain>/api/auth/callback/oidc`. There is no fallback login — see [Sign-in goes through the provider](#sign-in-goes-through-the-provider-and-only-through-the-provider).
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller.
|
||||
- **An SMTP server** only if `rallly.smtp.enabled` is `true`; otherwise mail is off — see [Mail is off by default](#mail-is-off-by-default-and-nothing-says-so).
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install rallly jlab/rallly \
|
||||
--namespace rallly --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
ralllySecretRef: op://vault/rallly
|
||||
oidcSecretRef: op://vault/rallly-oidc
|
||||
databaseSecretRef: op://vault/rallly-db
|
||||
|
||||
rallly:
|
||||
supportEmail: support@example.com
|
||||
|
||||
ingress:
|
||||
domain: poll.example.com
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
|
||||
|
||||
### What the 1Password items must contain
|
||||
|
||||
The chart renders `OnePasswordItem` CRs; the operator turns each into a Kubernetes Secret whose **keys are the field names of the 1Password item**. Every field is wired as a plain `secretKeyRef` without `optional: true`, so a missing field does not fail the install: the release goes through and the pod then hangs in `CreateContainerConfigError` naming the key it could not find. The key has to exist; an empty value is accepted, but the app then sees an empty string rather than its own default.
|
||||
|
||||
| Values key | Kubernetes Secret | Fields |
|
||||
|---|---|---|
|
||||
| `secrets.ralllySecretRef` | `<release>-rallly-secret` | `secret-key` (`SECRET_PASSWORD`, at least 32 characters; it encrypts the sessions, so changing it signs everyone out), `initial-admin-email` (`INITIAL_ADMIN_EMAIL`) |
|
||||
| `secrets.databaseSecretRef` | `<release>-rallly-database-secret` | `database-url` — `postgres://user:password@host:5432/database` |
|
||||
| `secrets.oidcSecretRef` | `<release>-rallly-oidc-secret` | `discovery-url` (the provider's `.well-known/openid-configuration`), `client-id`, `client-secret` |
|
||||
| `secrets.smtpSecretRef` (only with `rallly.smtp.enabled: true`) | `<release>-rallly-smtp-secret` | `host`, `port`, `secure`, `user`, `password`, `no-reply-email`, `no-reply-email-name`, `tls-servername` |
|
||||
|
||||
All eight SMTP fields are required **here**, including the three that are optional upstream (`port`, `secure`, `tls-servername`) and `no-reply-email`, which upstream would default to `SUPPORT_EMAIL`. An item carrying only `host`, `user` and `password` wedges the pod before it starts.
|
||||
|
||||
The discovery URL is fetched by the **pod**, not by your browser: an IdP that only resolves outside the cluster, or one behind a policy this namespace cannot pass, fails here while looking perfectly reachable from your desk.
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders the Deployment with `replicas: 0` without uninstalling. Takes precedence over `rallly.replicas`. Secrets, Service and Ingress (with its certificate) stay. |
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.ralllySecretRef` | string | `null` | **Required.** `op://` path of the app item (session key + initial admin). |
|
||||
| `secrets.oidcSecretRef` | string | `null` | **Required.** `op://` path of the OIDC item. |
|
||||
| `secrets.databaseSecretRef` | string | `null` | **Required.** `op://` path of the Postgres item. |
|
||||
| `secrets.smtpSecretRef` | string | `null` | **Required while `rallly.smtp.enabled` is `true`**; with SMTP off no `OnePasswordItem` is rendered and the value is unused. |
|
||||
|
||||
### `rallly`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `rallly.replicas` | int | `1` | Replica count; `0` is allowed. |
|
||||
| `rallly.smtp.enabled` | bool | `false` | Renders the SMTP `OnePasswordItem` and the `SMTP_*`/`NOREPLY_*` variables. Off means the app sends nothing — see [Mail is off by default](#mail-is-off-by-default-and-nothing-says-so). |
|
||||
| `rallly.smtp.rejectUnauthorized` | bool | `true` | `SMTP_REJECT_UNAUTHORIZED`. **Leave it at `true`.** `false` disables the mail server's certificate check entirely and opens the SMTP session to a man-in-the-middle; it is the setting for a self-signed certificate only. Strings are rejected by the schema, and an explicit `false` is not swallowed by a `default` (#109). |
|
||||
| `rallly.allowedEmails` | string | `'*'` | `ALLOWED_EMAILS`, comma-separated, wildcards supported (`*@example.com`). See [`*` is an open door](#allowed_emails--is-an-open-door). |
|
||||
| `rallly.supportEmail` | string | `null` | **Required.** `SUPPORT_EMAIL`; the render fails without it, independently of the Ingress. |
|
||||
| `rallly.oidc.name` | string | `OIDC` | `OIDC_NAME` — the label of the login button. |
|
||||
| `rallly.oidc.pictureClaimPath` | string | `picture` | Claim the app reads the avatar from. |
|
||||
| `rallly.oidc.emailClaimPath` | string | `email` | Claim the app reads the address from; it is also what `initial-admin-email` has to match. |
|
||||
| `rallly.oidc.nameClaimPath` | string | `name` | Claim the app reads the display name from. A provider that nests these elsewhere answers with a user who has no name or no address — that is these three values, not the provider. |
|
||||
| `rallly.cache.path` | string | `/app/apps/web/.next/cache` | **Required** (the template uses `required()`), mount path of the writable cache volume. Matches the Next.js cache directory of the upstream image; only needs changing if the image layout changes. |
|
||||
| `rallly.cache.sizeLimit` | string/number | `null` | Optional upper bound for the emptyDir, e.g. `512Mi`. Unset means it is bounded only by the container's ephemeral-storage limit. |
|
||||
| `rallly.securityContext.pod` | object | non-root `20000:1000`, `fsGroup: 1000`, `fsGroupChangePolicy: Always`, `seccompProfile: RuntimeDefault` | Passed into the pod spec. The `fsGroup` is what makes the mounted cache volume group-writable for the container. The seccomp profile sits here on purpose: it covers every container including init containers. |
|
||||
| `rallly.securityContext.container` | object | `readOnlyRootFilesystem: true`, all caps dropped, no privilege escalation, non-root | The app is a plain Node process on the unprivileged port 3000, verified to run with every capability dropped. There is deliberately **no** `add:` key — see [Why `capabilities.add` is absent](#why-capabilitiesadd-is-absent). |
|
||||
| `rallly.env` | list | `[]` | Extra environment variables appended to the chart's own. Rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. **Additive, not overriding** — see [Environment variables](Chart-Style-Guide#10-environment-variables). |
|
||||
| `rallly.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `rallly.resources.requests` | object | `cpu: 0.1`, `memory: 512Mi`, `ephemeral-storage: 100Mi` | |
|
||||
| `rallly.resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
| `rallly.livenessProbe` | object | `GET /` on port `http`, period 10s, timeout 5s, 3 failures | See [What a green rollout does not prove](#what-a-green-rollout-does-not-prove). |
|
||||
| `rallly.readinessProbe` | object | `GET /` on port `http`, period 10s, timeout 5s, 3 failures | |
|
||||
| `rallly.startupProbe` | object | `GET /` on port `http`, period 5s, 30 failures (= 150s) | Gates liveness and readiness, so neither needs an `initialDelaySeconds`. |
|
||||
|
||||
Probe, `securityContext` and `resources` blocks may be erased (`null`) — the chart then falls back to exactly these defaults instead of rendering an unhardened, unprobed or unaccounted container. `rallly.cache` is deliberately **not** null-safe: `cache.path` is `required()`, so an erased block fails loudly.
|
||||
|
||||
### `ingress`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | Switches the Ingress object off. **`ingress.domain` stays required** — it is also the app's `NEXT_PUBLIC_BASE_URL`. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. |
|
||||
| `ingress.domain` | string | `null` | **Required.** Ingress host, public base URL and the base of the OIDC redirect URI in one value. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. |
|
||||
|
||||
TLS is stored in `tls-<release>-rallly-ingress`.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### Sign-in goes through the provider, and only through the provider
|
||||
|
||||
The chart sets `EMAIL_LOGIN_ENABLED` and `REGISTRATION_ENABLED` to `"false"` in the template, not through a value: there is no magic-link login and no self-registration to fall back on. Every account arrives through the OIDC provider. A wrong OIDC item therefore does not degrade the login, it removes it — including your own way back in.
|
||||
|
||||
Admin rights are claimed by `INITIAL_ADMIN_EMAIL` from the app item. It has to match the address the provider returns in the email claim **exactly** — nothing reconciles the two, and a mismatch simply leaves the instance without an admin.
|
||||
|
||||
### `ALLOWED_EMAILS: '*'` is an open door
|
||||
|
||||
With the default every account the provider authenticates may sign in, so the only gate in front of this instance is the provider's own audience. With a broad IdP that is an open door. Narrow it with a comma-separated list; wildcards are supported. Note where the symptom then appears: an address outside the list is rejected **after** a successful login at the provider, so the provider reports success and Rallly still refuses.
|
||||
|
||||
### Changing the domain later is a change on two sides
|
||||
|
||||
`ingress.domain` is the Ingress host, the app's `NEXT_PUBLIC_BASE_URL` and therefore the OIDC redirect URI in one value. Editing it here alone breaks the login: the provider refuses a callback it was never told about, and it is the provider that reports it — Kubernetes reports a healthy pod and a valid certificate for the new name. Register the new redirect URI first, then upgrade.
|
||||
|
||||
The same value is why `ingress.domain` stays required with the Ingress switched off: a port-forward reaches the app, but every generated link and every sign-in still leaves for `https://<ingress.domain>`.
|
||||
|
||||
### Mail is off by default, and nothing says so
|
||||
|
||||
With `rallly.smtp.enabled: false` the container receives none of the `SMTP_*` variables — upstream lists `SMTP_HOST` among the required ones. Invitations and notification mails then fail where they are sent, with a green rollout and a healthy pod either way. Sign-in is unaffected, because email login is off anyway.
|
||||
|
||||
### Rotating an item in 1Password restarts nothing
|
||||
|
||||
Helm never sees a `OnePasswordItem`'s content, so no checksum annotation can cover these Secrets and this chart carries none ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)). Change a field in 1Password and the operator updates the Kubernetes Secret while the running pod keeps what it read at start — environment variables are read once. This bites hardest on `database-url` and the SMTP password: the app goes on using the old credentials, and nothing in the cluster says so.
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout restart deployment/<release>-rallly-deployment
|
||||
```
|
||||
|
||||
### What a green rollout does not prove
|
||||
|
||||
All three probes are the same plain HTTP `GET /` against the container. They establish that the web process answers and nothing beyond it: the OIDC provider, the mail server and the addresses in `ALLOWED_EMAILS` are exercised by the first real sign-in and the first real mail, never by a probe. Every misconfiguration listed above outlives the 150s startup budget untouched.
|
||||
|
||||
### The cache volume counts against the ephemeral-storage limit
|
||||
|
||||
Next.js writes prerendered pages and the optimized images of `/_next/image` into its cache directory. `/app` belongs to the image's own `nextjs` user while the container runs as uid 20000 under a read-only root filesystem, so the chart mounts a writable emptyDir over that path. emptyDir usage counts against the container's ephemeral-storage limit (`limitFactor` × request, `300Mi` by default); whichever bound is reached first — that limit or `rallly.cache.sizeLimit` — the kubelet evicts the pod, which reads as a restart with no application error anywhere in the log.
|
||||
|
||||
The cache is an emptyDir on purpose, not a PVC: its entries are derived data tied to the build id of the image, so a persistent volume would only carry stale entries across app updates. The cost is a slower first request after every restart.
|
||||
|
||||
### Why `capabilities.add` is absent
|
||||
|
||||
The container context drops all capabilities and adds none — and the `add:` key is left out entirely rather than written empty. An empty list item under it renders as a null entry, which is what Trivy reported as `AVD-KSV-0022` for the older chart version deployed in the cluster (#84). Do not reintroduce it.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **All state is in the external Postgres.** The release itself owns no volume, so `helm uninstall` loses nothing but the Kubernetes objects — and a database backup is the only backup there is. Take one before an app-version bump: a `helm rollback` returns the old image, never the old database.
|
||||
- **Rotating `secret-key` signs everyone out**, because it encrypts the sessions.
|
||||
- **Register a new OIDC redirect URI before** changing `ingress.domain`, not after.
|
||||
- App-version bumps go through a PR so that validate and install-test run — see [Versioning](Versioning).
|
||||
|
||||
## CI
|
||||
|
||||
`ci/rallly/` holds `test-values.yaml` (`op://` refs against the stub CRD, `rallly.ci.local`, `supportEmail`, SMTP left off) and `fixtures.yaml` (a throwaway Postgres, a **dex** OIDC provider so the discovery URL actually answers, and the three Secrets the 1Password operator would otherwise materialize). The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements
|
||||
- [Versioning](Versioning) — what a bump of `8.1.4+up4.15.1` means
|
||||
- [Renovate](Renovate) — how the app version gets updated
|
||||
- [Chart index](Charts)
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
# rss-bridge
|
||||
|
||||
Deploys [RSS-Bridge](https://github.com/RSS-Bridge/rss-bridge), which turns websites without a feed into RSS/Atom, as a stateless Deployment. The enabled bridges come from a ConfigMap rendered as `whitelist.txt`; the feed cache is an emptyDir and is meant to be lost.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `1.0.2+up2025.08.05` |
|
||||
| App version | `2025-08-05` (image `docker.io/rssbridge/rss-bridge`) |
|
||||
| Workloads | Deployment (1 replica) |
|
||||
| Storage | none — five emptyDirs, feed cache included |
|
||||
| Ingress | yes, `ingress.hosts` (a **list**) + `ingress.clusterIssuer` required while enabled |
|
||||
| External dependencies | none in the cluster; the bridges fetch the sites they scrape |
|
||||
| Secrets | none — no `OnePasswordItem` |
|
||||
| Install-test | `ci/rss-bridge/` (test values only, no fixtures) |
|
||||
|
||||
The image reference is written out with its registry on purpose: an unqualified Docker Hub reference puts `rssbridge` in the registry position, which says nothing about where the image comes from and is reported as an untrusted registry (AVD-KSV-0125).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for every entry of `ingress.hosts` pointing at the ingress controller.
|
||||
- **Where existing certificates are being taken over**, know their secret names before installing — see [Several hosts, several certificates](#several-hosts-several-certificates).
|
||||
- Nothing else. No database, no cache service, no 1Password item, no volume.
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install rss-bridge jlab/rss-bridge \
|
||||
--namespace rss-bridge --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
clusterIssuer: letsencrypt
|
||||
hosts:
|
||||
- host: rss-bridge.example.com
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set — the hosts, the TLS secrets they were grouped into and the enabled bridges. Read it — it is the per-release version of this page.
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0` without uninstalling. Takes precedence over `rssBridge.replicas`. Service and Ingress stay. |
|
||||
| `whitelist` | list of strings | `["*"]` | **Required** (the chart default satisfies it; it may not be null or empty). Bridge names rendered into `whitelist.txt`, one per line. See [The whitelist](#the-whitelist-is-a-list-not-a-file). |
|
||||
| `global` | object | unset | Helm's conventional cross-chart values. Unused by this chart, kept open in the schema so an umbrella install cannot break on it. |
|
||||
|
||||
`whitelist` is top-level, not under `rssBridge`, and it is required regardless of `ingress.enabled`.
|
||||
|
||||
### `rssBridge`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `rssBridge.replicas` | int | `1` | Replica count; `0` is allowed. The app is stateless (the cache is per-pod), so raising it is safe. |
|
||||
| `rssBridge.securityContext.pod` | object | non-root 33, `runAsGroup: 33`, `fsGroup: 33`, `fsGroupChangePolicy: Always`, `seccompProfile: RuntimeDefault`, `sysctls: net.ipv4.ip_unprivileged_port_start=0` | Passed into the pod spec. uid/gid 33 is `www-data` and is **not** interchangeable — see [Why uid 33 and port 80](#why-uid-33-and-port-80). The sysctl is what lets the non-root process bind port 80. |
|
||||
| `rssBridge.securityContext.container` | object | `readOnlyRootFilesystem: true`, `runAsNonRoot: true`, all caps dropped, no privilege escalation | Passed into the container spec. Five emptyDirs make the read-only root filesystem possible. |
|
||||
| `rssBridge.livenessProbe` | object | `GET /?action=health:rss-bridge-port`, period 10s, timeout 5s, 3 failures | See [The health action](#the-health-action-and-what-it-does-not-cover). |
|
||||
| `rssBridge.readinessProbe` | object | `GET /?action=health:rss-bridge-port`, period 10s, timeout 5s, 3 failures | |
|
||||
| `rssBridge.startupProbe` | object | `GET /?action=health:rss-bridge-port`, period 5s, timeout 5s, 30 failures (= 150s) | Gates liveness and readiness, so neither needs an `initialDelaySeconds`. The budget is generous: the container is ready in about two seconds — nginx plus php-fpm, no migrations, no warm-up. |
|
||||
| `rssBridge.env` | list | `[]` | The container's environment. The chart sets none of its own, so entries here are purely additive in effect. Rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. RSS-Bridge maps `RSSBRIDGE_<section>_<key>` onto its config, e.g. `RSSBRIDGE_system_timezone: Europe/Berlin`. **Values must be strings** — see [Environment variables](#numeric-env-values-must-be-quoted). |
|
||||
| `rssBridge.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request — but this chart sets both limits explicitly, so the factor only applies to a request the defaults do not cover. |
|
||||
| `rssBridge.resources.requests` | object | `cpu: 0.1`, `memory: 128Mi`, `ephemeralStorage: 64Mi` | Measured: ~21 MiB resident at startup, ~64 MiB after serving feeds (the 2025-06-03 image came out marginally higher at 24/71 MiB under the identical procedure). The only path that grows is `/app/cache`: 17 entries and 2.2 MB after fetching all five bridges, and the app purges it itself. 64Mi is roughly thirty times the measured footprint and frees 97% of the 2Gi the replaced manifest reserved. |
|
||||
| `rssBridge.resources.limits` | object | `memory: 512Mi`, `ephemeralStorage: 2Gi` | Set explicitly rather than computed. A warm container serving five concurrent feed fetches peaked at 119 MiB, so the replaced manifest's 100Mi cap was already below the real peak; php-fpm allows up to five workers at a 128M PHP `memory_limit` each. No CPU limit by default — CPU is compressible, so a limit only throttles. |
|
||||
|
||||
Probes, security contexts and resources are null-safe: an erased block means "chart defaults apply". The resources fallback reproduces the **explicit limits** as well, because reproducing only the requests would let `limitFactor` compute a memory limit below the measured peak.
|
||||
|
||||
### `ingress`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | Gates the whole Ingress template. Nothing outside it reads an ingress value — the container takes no host in its environment and nginx serves as `default_server` without a `server_name` — so switching it off frees every value below, `hosts` included. |
|
||||
| `ingress.hosts` | list of objects | `null` | **Required while `ingress.enabled` is true.** The hosts this Ingress serves. The chart ships no default, because hostnames are installation data. See [Several hosts, several certificates](#several-hosts-several-certificates). |
|
||||
| `ingress.hosts[].host` | string | — | **Required** per entry. The hostname. |
|
||||
| `ingress.hosts[].tlsSecretName` | string | `null` | Overrides the conventional name for this host. Omitted, the entry falls back to `tls-<release>-rss-bridge-ingress`. Set it only where an existing certificate must keep its secret name. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`; it issues every secret in the grouping. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. |
|
||||
|
||||
## Operating notes
|
||||
|
||||
### Several hosts, several certificates
|
||||
|
||||
This is a **documented deviation** from the repo's one-TLS-secret rule ([style guide §4](Chart-Style-Guide)). Entries are grouped by `tlsSecretName` in first-appearance order: entries sharing a name become one `tls` entry and therefore one certificate with several SANs, entries with distinct names get one certificate each, and entries without a name fall into the conventional `tls-<release>-rss-bridge-ingress` group.
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
hosts:
|
||||
- host: rss-bridge.example.com # -> tls-<release>-rss-bridge-ingress
|
||||
- host: feeds.example.com
|
||||
tlsSecretName: tls-legacy-feeds-ingress
|
||||
- host: bridge.example.com
|
||||
tlsSecretName: tls-legacy-feeds-ingress # shares the certificate above
|
||||
```
|
||||
|
||||
Why the secret name is configurable at all: the installation this chart took over had grown three separate secrets, one per host, from three years of `kubectl apply`. Renaming them would not be a cosmetic cleanup — cert-manager keys certificates by secret name, so three new names mean three fresh issuances, and the service is unreachable on each host until its certificate is issued. That is a self-inflicted outage plus three avoidable Let's Encrypt issuances for a deployment that had been running untouched for over a year. Making host and secret name per-entry values keeps the existing names in place and turns the eventual consolidation onto one certificate — or the retirement of the legacy hostnames — into a values change instead of a template change.
|
||||
|
||||
All hosts serve the same instance; there is no per-host routing.
|
||||
|
||||
### The whitelist is a list, not a file
|
||||
|
||||
`whitelist.txt` is one bridge name per line, and the application trims and filters every line before use, so nothing is lost by modelling it as YAML — while a hardcoded file body in the template could not be changed without editing the chart.
|
||||
|
||||
The default `["*"]` is RSS-Bridge's own wildcard and enables every bridge. It is the default because it is exactly what the image does when no `whitelist.txt` exists at all, so a default install behaves like stock RSS-Bridge instead of silently serving nothing. The schema enforces `minItems: 1`: an empty list would disable every bridge, which is a silent way to serve nothing.
|
||||
|
||||
A change to `whitelist` rolls the pod — the Deployment carries `checksum/config` over the rendered ConfigMap ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)). That is not optional: `whitelist.txt` is a `subPath` mount, which the kubelet never refreshes, and RSS-Bridge reads the file once at request bootstrap.
|
||||
|
||||
### The health action, and what it does not cover
|
||||
|
||||
All three probes hit `/?action=health`, which renders a fixed 49-byte JSON document without touching the file cache or any remote site. That keeps liveness free of external dependencies ([style guide §12.3](Chart-Style-Guide#12-values-that-must-not-lie)): a scraped website going down, or a cache directory that is not writable, must not restart the pod. `/` would be the wrong choice for a second reason — the front page enumerates every enabled bridge and answers with roughly 1.5 MB, which is a wasteful thing to fetch every ten seconds forever.
|
||||
|
||||
The price is one blind spot worth remembering: if `/app/cache` is not writable, `/?action=health` still answers 200 while every feed request throws. A Ready pod is not proof that feeds are being served.
|
||||
|
||||
### Why uid 33 and port 80
|
||||
|
||||
The image ships no `USER` and runs everything as root by default, but it runs fully hardened once its writable paths are provided. uid/gid 33 is `www-data` and is not interchangeable: `/app` and every file in it is owned by `www-data`, `nginx.conf` declares `user www-data` and the php-fpm pool declares `user = www-data`. Both emit a harmless notice that those directives are ignored when the master process is not root, and then serve normally as uid 33.
|
||||
|
||||
nginx is configured for port 80 inside the image, and the only lever the entrypoint offers is `HTTP_PORT`, which runs a `sed` against `/etc/nginx/sites-enabled/default` — impossible with a read-only root filesystem. So the choice is port 80 or `readOnlyRootFilesystem`, and port 80 is the cheaper of the two. Kubernetes sets `net.ipv4.ip_unprivileged_port_start=0` in the pod network namespace anyway, but that is a runtime default and not a guarantee, so the chart pins it instead of inheriting it silently. Measured in k3d (k3s v1.35.5), same pod spec, only that value changed:
|
||||
|
||||
| `net.ipv4.ip_unprivileged_port_start` | Capabilities | Result |
|
||||
|---|---|---|
|
||||
| `1024` | `drop: [ALL]` | `nginx: bind() to 0.0.0.0:80 failed (13: Permission denied)` |
|
||||
| `1024` | plus `NET_BIND_SERVICE` | Identical failure — for a non-root process the capability lands in the permitted set but not the effective/ambient one, so it buys nothing. That is why the chart does not add it. |
|
||||
| `0` (this default) | `drop: [ALL]` | Binds, HTTP 200. |
|
||||
|
||||
Note how that failure looks without probes: the entrypoint starts nginx in the background and then execs php-fpm, so a failed bind leaves a container reporting Running with no web server at all. The probes turn that silent failure into a pod that never becomes Ready.
|
||||
|
||||
The container port name `rss-bridge-port` is load-bearing: the Service selects it by name (`targetPort: rss-bridge-port`) and so do all three probes.
|
||||
|
||||
### Five writable paths, and the one that is invisible
|
||||
|
||||
`readOnlyRootFilesystem: true` is possible because of five emptyDirs. They are image facts rather than user configuration, so none of them is a value, and they were found empirically: the image was run with a writable root filesystem, exercised across every whitelisted bridge, and its diff layer read afterwards.
|
||||
|
||||
| Path | What writes there |
|
||||
|---|---|
|
||||
| `/app/cache` | RSS-Bridge's file cache. Not optional: the app checks it with `is_writable()` and throws on every feed request if it is not. |
|
||||
| `/tmp` | PHP's opcache lock file and its default upload temp dir. |
|
||||
| `/run` | `nginx.pid` |
|
||||
| `/run/php` | php-fpm's pid file and fastcgi socket. Nested inside `/run` on purpose — the emptyDir above does not contain the `php` subdirectory the pool config expects, and php-fpm does not create it. |
|
||||
| `/var/lib/nginx` | nginx's body/fastcgi/proxy/scgi/uwsgi temp directories, created at startup. |
|
||||
|
||||
`/tmp` did **not** show up in the filesystem diff — the opcache lock file is created and immediately unlinked — and only surfaced when the hardened container died at startup with `Unable to create lock file: Read-only file system`. Stopping at the first writable path found would have shipped a chart that cannot start.
|
||||
|
||||
The cache is deliberately lost on every restart. RSS-Bridge refills it on demand; it holds only fetched feeds, no configuration and no user data.
|
||||
|
||||
### Numeric env values must be quoted
|
||||
|
||||
The template quotes every `value` because a value like `"5"` or `"true"` renders unquoted as a YAML number or bool, and the API server then rejects the whole manifest with `cannot unmarshal number into Go struct field EnvVar.value of type string`. RSS-Bridge takes numeric settings this way (`RSSBRIDGE_http_timeout`), so this is reachable with the very first env entry someone adds. The schema accepts strings only.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
Nothing to back up: no database, no volumes, no user data. The feed cache is disposable by design, so `helm rollback` and app-version bumps carry no data risk.
|
||||
|
||||
App versions are date tags (`2025-08-05`). Renovate tracks them with a regex versioning of `^(?<major>\d{4})-(?<minor>\d{2})-(?<patch>\d{2})$`, and the bump script normalizes the dashes to dots for the `+up` suffix — hence `1.0.2+up2025.08.05` for `appVersion: "2025-08-05"`. See [Versioning](Versioning) and [Renovate](Renovate).
|
||||
|
||||
## CI
|
||||
|
||||
`ci/rss-bridge/test-values.yaml` sets the required ingress values with **three hosts across two TLS secret names** — two sharing one, a third with its own — so the install-test renders the secret-grouping helper as well. That helper is what keeps the three existing certificates of the takeover installation alive, and it is not exercised by the chart defaults; an install-test that never rendered it would not notice if it broke. `whitelist` is set to a concrete list (`Youtube`, `Heise`) rather than the default `*`, so the ConfigMap render and the `subPath` mount are covered by something the app has to parse. There is no `fixtures.yaml` — nothing to stand up. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements, and the TLS-secret deviation it documents
|
||||
- [Versioning](Versioning) — what a bump of `1.0.2+up2025.08.05` means
|
||||
- [Renovate](Renovate) — date-tag versioning for this chart
|
||||
- [Lessons learned](Lessons-Learned)
|
||||
- [Chart index](Charts)
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
# samba
|
||||
|
||||
Deploys [Samba](https://www.samba.org/) (image `ghcr.io/dockur/samba`) as a single-replica StatefulSet that exports one SMB share per entry in `samba.shares`, each on its own PVC. `smb.conf` is rendered by the chart into a ConfigMap; the user database comes from a 1Password-backed Secret. SMB is not HTTP, so the release is published through a **NodePort Service** and there is no Ingress and no certificate.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `7.1.0+up4.23.10` |
|
||||
| App version | `4.23.10` (image `ghcr.io/dockur/samba`) |
|
||||
| Workloads | StatefulSet (1 replica) |
|
||||
| Storage | one RWO PVC per share (`<release>-<share>-volume-claim`) |
|
||||
| Ingress | none — NodePort Service on 445/tcp and 139/tcp |
|
||||
| External dependencies | none |
|
||||
| Secrets | 1 × `OnePasswordItem` (`users.conf`) |
|
||||
| Install-test | `ci/samba/` (fixtures + test values) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **1Password operator** in the cluster, plus one 1Password item — see [the field below](#what-the-1password-item-must-contain).
|
||||
- **A storage class**: `samba.storageClass` has no default that works everywhere (`longhorn` is shipped) and is enforced with a `required` — the render fails without it.
|
||||
- **Reachable node IPs.** The Service is of type `NodePort`, so the ports are open on **every** node of the cluster and reachable by anyone who can reach a node. There is no ingress-level authentication in front of it; the only gate is SMB authentication.
|
||||
- **SMB3-capable clients.** The rendered `smb.conf` refuses everything older or unencrypted — see [The server refuses old and unencrypted clients](#the-server-refuses-old-and-unencrypted-clients).
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install samba jlab/samba \
|
||||
--namespace samba --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
sambaUsersSecretRef: op://vault/samba-users
|
||||
|
||||
samba:
|
||||
storageClass: longhorn
|
||||
shares:
|
||||
- name: documents
|
||||
validUsers: alice,bob
|
||||
description: Shared documents
|
||||
size: 50Gi
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information — node ports, claim names, per-share mount paths and the client command lines — against the values actually set. Read it; it is the per-release version of this page.
|
||||
|
||||
### What the 1Password item must contain
|
||||
|
||||
The chart renders one `OnePasswordItem`; the operator turns it into a Kubernetes Secret whose **keys are the field names of the 1Password item**. This chart mounts exactly one key.
|
||||
|
||||
| Values key | Kubernetes Secret | Fields |
|
||||
|---|---|---|
|
||||
| `secrets.sambaUsersSecretRef` | `<release>-samba-users-secret` | `users.conf` — the user database, one line per user |
|
||||
|
||||
The field has to be spelled that way to the letter. A differently named field yields a Secret without the key and the pod does not start at all: it never gets past volume setup, so **there is no log to read** — the mount failure naming the missing key is in the pod's events (`kubectl -n <namespace> describe pod <release>-samba-sfs-0`).
|
||||
|
||||
The content, one line per user, with an optional sixth field for a home directory:
|
||||
|
||||
```
|
||||
username:uid:groupname:gid:password
|
||||
```
|
||||
|
||||
The entrypoint creates a real Unix user per line at start; the password is the SMB password, in clear text in the vault. **Give the users gid 1000** — that is the pod's `fsGroup` and what the share volumes are group-owned by. A user with a different gid authenticates fine and then cannot write. Every name used in a share's `validUsers` must exist here; one that does not is not an error anywhere — the share simply refuses that login.
|
||||
|
||||
Note the exact values key: a shortened `sambaUsersRef` is not read by the chart and is rejected by the schema.
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `global` | object | unset | Helm's conventional cross-chart values. Unused by this chart; the schema keeps it open so an umbrella install cannot break on it. |
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0` without uninstalling. Takes precedence over `samba.replicas`. The share PVCs, the users Secret and the Service stay in place, so no data is lost and the node ports stay reserved. |
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.sambaUsersSecretRef` | string | `null` | **Required**, and enforced: the template wraps it in a `required`, so an unset value fails the render rather than installing a release that cannot start. `op://` item path of the `users.conf` item. |
|
||||
|
||||
### `samba`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `samba.replicas` | int | `1` | Replica count; `0` is allowed. `scaleDown` wins over it. Anything above `1` does not work — see [More than one replica](#more-than-one-replica-will-not-work). |
|
||||
| `samba.smbNodePort` | int | `30445` | Node port published for container port 445. `null` lets the API server assign a free port from the cluster's node-port range; read it back with `kubectl get svc`. |
|
||||
| `samba.netbiosNodePort` | int | `30139` | Node port published for container port 139. Same `null` behavior. |
|
||||
| `samba.shares` | list | one entry with all fields `null` | One SMB share per entry. Each entry drives a share section in `smb.conf`, a PVC and a volumeMount, so the list is part of the pod template — changing it restarts the workload by design. The shipped placeholder entry is not usable: `name` and `size` are enforced with `required`, so the chart's own defaults do not render. |
|
||||
| `samba.shares[].name` | string | `null` | **Required per entry.** Share name, mount path `/shares/<name>`, and the middle of the claim name `<release>-<name>-volume-claim`. |
|
||||
| `samba.shares[].size` | string | `null` | **Required per entry.** Size of that share's PVC. |
|
||||
| `samba.shares[].validUsers` | string | `null` | Comma-separated user list, rendered as `valid users` in the share section. **An empty list does not mean "nobody"** — see below. |
|
||||
| `samba.shares[].description` | string | `null` | Rendered as the share's `comment`. |
|
||||
| `samba.storageClass` | string | `longhorn` | **Required** (enforced with a `required` in the PVC template). Applies to all shares alike. |
|
||||
| `samba.env` | list | `[]` | Environment variables for the container. The chart sets **none of its own**, so this is the container's whole environment. Rendered through `tpl`; `value`, `secretKeyRef`, `configMapKeyRef` forms. `smb.conf` is fully rendered by the chart, so image variables that would generate a config do not override it. |
|
||||
| `samba.livenessProbe` | object | TCP connect to port `445`, period 10s, timeout 5s, 3 failures | SMB is not an HTTP protocol, so the probes connect to the SMB port. See [What a Ready pod does not prove](#what-a-ready-pod-does-not-prove). |
|
||||
| `samba.readinessProbe` | object | TCP connect to port `445`, period 10s, timeout 5s, 3 failures | |
|
||||
| `samba.startupProbe` | object | TCP connect to port `445`, period 5s, timeout 5s, 30 failures (= 150s) | Gates liveness and readiness until the port opens. |
|
||||
| `samba.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `samba.resources.requests` | object | `cpu: 0.1`, `memory: 50Mi`, `ephemeralStorage: 50Mi` | The ephemeral-storage request is not cosmetic here — see [The root filesystem is ephemeral](#the-root-filesystem-is-ephemeral-and-accounted-for). |
|
||||
| `samba.resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
| `samba.securityContext.pod` | object | `fsGroup: 1000` | Passed verbatim into the pod spec. This is the gid the share volumes are group-owned by, and the gid `users.conf` must use. |
|
||||
| `samba.securityContext.container` | object | `allowPrivilegeEscalation: false`, **`readOnlyRootFilesystem: false`**, caps drop `ALL` and add `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `NET_BIND_SERVICE`, `SETGID`, `SETUID` | Passed verbatim into the container spec. A **documented deviation** from the repo hardening baseline, forced by the image — see below. |
|
||||
|
||||
The whole `resources` and `securityContext` blocks, and each probe, may also be `null` or empty: the chart then falls back to exactly the defaults above — **including the deviation**, which the fallbacks reproduce verbatim ([style guide §12](Chart-Style-Guide#12-values-that-must-not-lie)).
|
||||
|
||||
There are no `ingress.*` values: this chart renders no Ingress.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### Access is a node port, not 445
|
||||
|
||||
```
|
||||
SMB <node IP>:30445 (container port 445)
|
||||
NetBIOS <node IP>:30139 (container port 139)
|
||||
|
||||
kubectl get nodes -o wide # the IPs to connect to
|
||||
```
|
||||
|
||||
That a node port is **not** 445 decides which clients can reach the server at all, because SMB clients differ in whether they let you name a port:
|
||||
|
||||
| Client | How |
|
||||
|---|---|
|
||||
| macOS Finder | Cmd-K, then `smb://NODE-IP:30445/SHARE` |
|
||||
| Linux | `mount -t cifs //NODE-IP/SHARE /mnt -o port=30445,vers=3.1.1,username=USER` |
|
||||
| smbclient | `smbclient //NODE-IP/SHARE -p 30445 -U USER` |
|
||||
| **Windows Explorer** | **not possible** — a UNC path `\\host\share` carries no port and the client always dials 445 |
|
||||
|
||||
For Windows, either put a port forward in front of it on the client side, or publish 445 itself through a LoadBalancer Service managed outside this chart.
|
||||
|
||||
Inside the cluster none of that applies: the Service port is the normal 445, so a pod addresses the server as `//<release>-samba-service.<namespace>.svc.cluster.local/SHARE`.
|
||||
|
||||
Nothing UDP is published — NetBIOS name service (137/udp) and datagrams (138/udp) are not in the Service, so this server never shows up by itself in a client's network browser. Address it by node IP or by a DNS name you point at the nodes yourself.
|
||||
|
||||
### The server refuses old and unencrypted clients
|
||||
|
||||
`smb.conf` is fixed by the chart and strict on purpose:
|
||||
|
||||
```
|
||||
server min protocol = SMB3 SMB1 and SMB2 are rejected at negotiation
|
||||
server smb encrypt = required an unencrypted session is refused
|
||||
server signing = mandatory
|
||||
security = user there is no guest access, ever
|
||||
```
|
||||
|
||||
Current Windows, macOS and Linux clients satisfy all of it. What does not is the long tail — smart TVs, media players, scanners, NAS-to-NAS sync, old `cifs` kernels. Those fail at connect with a bare protocol or access error and no hint that the cause is on the server side. None of the four settings is exposed as a value; loosening them means editing `templates/samba-config.configmap.yaml`. Printing services are disabled in the same file.
|
||||
|
||||
### An empty `validUsers` opens the share to everyone
|
||||
|
||||
A share without `samba.shares[].validUsers` renders `valid users =` with an empty list, and in Samba an empty valid-users list does not mean "nobody" — it means the restriction does not apply and **every authenticated user gets in**. Combined with `writable = yes`, which the chart sets on every share, that is full read/write for everyone in `users.conf`. Name the users explicitly if that is not what you want; `NOTES.txt` counts the unrestricted shares of the release and names them.
|
||||
|
||||
### Changing the user list does not restart anything
|
||||
|
||||
Helm never sees the content of a 1Password-backed Secret, so editing the item in the vault changes no rendered object and nothing rolls. Added, removed and re-passworded users stay invisible to the running `smbd` until it is restarted by hand:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout restart statefulset/<release>-samba-sfs
|
||||
```
|
||||
|
||||
**The operator's `operator.1password.io/auto-restart` annotation is not a substitute and is deliberately not set here.** Verified against onepassword-operator 1.12.0, its restart handler enumerates exactly one workload type — `DeploymentList` — so on a StatefulSet the annotation is a silent no-op, and setting it would advertise a guarantee that does not exist ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload); `matrix-synapse` and `homeassistant` are in the same position). Pointing `secrets.sambaUsersSecretRef` at a **different** item does roll the pod, because that changes the rendered CR.
|
||||
|
||||
`smb.conf` is the other half of the same rule and *is* covered: the pod template carries `checksum/config` over the rendered ConfigMap, because the file is mounted with `subPath` (never refreshed by the kubelet) and read once at start.
|
||||
|
||||
### Every restart drops every open SMB session
|
||||
|
||||
This is a single pod, and SMB sessions live in its TCP connections and in lock databases under `/var/lib/samba` that go with the container. A restart disconnects every client at once: open handles die, in-flight writes are lost, and a client that had a file open sees an I/O error rather than a reconnect. SMB3 persistent handles do not save you here.
|
||||
|
||||
What triggers a restart is wider than it looks. Because of the `checksum/config` annotation, **any** edit to `samba.shares` rolls the pod — including adding a share that has nothing to do with the ones in use, or only fixing a description. So does a change to `samba.env`, the resources, the probes, the security context or the app version. Plan share changes for a window when nobody has files open.
|
||||
|
||||
### The share volumes belong to this release
|
||||
|
||||
The PVCs are ordinary resources of the release, not `volumeClaimTemplates`, and carry no resource-policy annotation:
|
||||
|
||||
- `helm uninstall <release>` deletes them, and with a `Delete` reclaim policy every file on every share goes with them. Back the volumes up first; an annotation added to the live PVC afterwards does not help, because Helm reads it from the release manifest.
|
||||
- **Renaming a share is not a rename.** The claim name is derived from the share name, so the next upgrade creates a new, empty claim and deletes the old one — data loss dressed up as a config edit. Copy the data out first, or leave the name alone.
|
||||
|
||||
The claim name `<release>-<share>-volume-claim` carries no chart name. That is a historical name this chart keeps deliberately, a documented exception to [§1 of the style guide](Chart-Style-Guide#1-naming) — renaming it would orphan the data.
|
||||
|
||||
### The hardening is a documented deviation, forced by the image
|
||||
|
||||
The pod runs as root with a writable root filesystem and a non-empty `capabilities.add`. Every part of that is required by `dockur/samba`'s entrypoint (`samba.sh`):
|
||||
|
||||
- **No `runAsNonRoot`**: the entrypoint creates the Unix users from `users.conf` (`groupadd`/`adduser`/`usermod`), and `smbd` impersonates the authenticated share user per session. Both need a root start.
|
||||
- **`readOnlyRootFilesystem: false`**: the entrypoint writes `/etc/passwd` and `/etc/group`, creates the `/etc/samba.conf` healthcheck symlink, `mkdir`s `/shared` and builds the Samba TDB databases under `/var/lib/samba`.
|
||||
- **The capability set** is `ALL` dropped except what a multi-user root file server needs: `NET_BIND_SERVICE` (ports 445/139), `SETUID`/`SETGID` (per-session impersonation), `CHOWN`/`FOWNER`/`DAC_OVERRIDE` (managing share files on behalf of the users).
|
||||
|
||||
Do not "restore" the standard here — it breaks the file server. The README lists samba by name as one of the charts where the image cannot satisfy the baseline ([style guide §5](Chart-Style-Guide#5-security-hardening)).
|
||||
|
||||
### The root filesystem is ephemeral and accounted for
|
||||
|
||||
Shares are on PVCs, but everything the entrypoint writes outside them — `/etc/passwd`, the TDB databases under `/var/lib/samba` — lives on the container's writable layer. It is discarded on every restart, which is fine, because the users are rebuilt from `users.conf` each time. It counts against the container's **ephemeral-storage limit** (computed: `limitFactor` × `requests.ephemeralStorage`, so 150Mi by default). Exceeding that limit does not fail a request: the kubelet evicts the pod, and the reason shows on the pod, not in Samba's log.
|
||||
|
||||
### What a Ready pod does not prove
|
||||
|
||||
All three probes are bare TCP connects to port 445. Green means one thing only: `smbd` is listening. It says nothing about whether `users.conf` parsed, whether the users were created, whether a password is right or whether any share is usable. **A release can be fully Ready and refuse every single login.**
|
||||
|
||||
User creation happens in the entrypoint, so that is where a malformed `users.conf` surfaces:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> logs statefulset/<release>-samba-sfs
|
||||
```
|
||||
|
||||
The real check is a login from a client — the only thing that exercises authentication and the share list at once:
|
||||
|
||||
```bash
|
||||
smbclient -L //NODE-IP -p 30445 -U USER
|
||||
```
|
||||
|
||||
### No shares configured means the server exports nothing
|
||||
|
||||
With `samba.shares` empty, `smb.conf` carries the `[global]` section and not a single share, no PVC is created and the pod mounts no data volume. `smbd` starts, the probes go green and a client can authenticate — and then sees an empty list.
|
||||
|
||||
### More than one replica will not work
|
||||
|
||||
Every replica mounts the same ReadWriteOnce claims; there is no per-pod volume. Unless the storage class hands those claims to several nodes at once, the extra pods never start, and where they do, two independent `smbd` instances write the same files with separate lock databases — which is how SMB corrupts data. The Service would hand clients to either pod at random.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **Back the share volumes up before an uninstall or a share rename** — both delete claims, and the chart sets no resource policy that would hold them.
|
||||
- **Growing `samba.shares[].size` only works if the storage class allows volume expansion.** Shrinking is refused by Kubernetes and makes the next upgrade fail on the PVC instead of touching the data.
|
||||
- **Every upgrade that touches the pod template disconnects all clients** (see above). An app-version bump always does.
|
||||
- There is no schema or state migration: the users are rebuilt from `users.conf` at each start, so a rollback of the app version is a plain image change.
|
||||
- App **major** jumps go stepwise: last release of the old major line first, then the newest major — see [Versioning](Versioning).
|
||||
|
||||
## CI
|
||||
|
||||
`ci/samba/` holds `test-values.yaml` (one `ci-share` of `1Gi` on `local-path` with `validUsers: ciuser`, and the `op://` ref against the stub CRD) and `fixtures.yaml` (the dummy `ci-samba-users-secret` whose `users.conf` is a single line `ciuser:1000:smb:1000:ci-password`, in the image's `username:uid:groupname:gid:password[:homedir]` format). No `settings.env` — the default install timeout suffices. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements, and §5 and §9, where its two deviations are recorded
|
||||
- [Lessons learned](Lessons-Learned) — the auto-restart-on-StatefulSet finding in general form
|
||||
- [Versioning](Versioning) — what a bump of `7.1.0+up4.23.10` means
|
||||
- [Renovate](Renovate) — how `appVersion` gets updated
|
||||
- [Chart index](Charts)
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
# satisfactory
|
||||
|
||||
Deploys a [Satisfactory](https://www.satisfactorygame.com/) dedicated server ([wolveix/satisfactory-server](https://github.com/wolveix/satisfactory-server)) as a single-replica StatefulSet with one data volume. Players connect to a NodePort; there is no Ingress, no certificate and no hostname.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `7.0.0+upv1.9.10` |
|
||||
| App version | `v1.9.10` (image `ghcr.io/wolveix/satisfactory-server`) |
|
||||
| Workloads | StatefulSet (1 replica) |
|
||||
| Storage | one RWO PVC `<release>-satisfactory-vc`, storage class **hardcoded** `longhorn` |
|
||||
| Ingress | no; a **NodePort** Service with three ports (UDP game, TCP API, TCP messaging) |
|
||||
| External dependencies | Steam — the first start downloads 10 GB+ of game files |
|
||||
| Secrets | none |
|
||||
| Install-test | **excluded**, see [CI](#ci) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **A storage class named `longhorn`.** It is written into the PVC template and is *not* a value. On a cluster without it the claim stays `Pending` forever and the pod never starts — with no error anywhere in the game server's log.
|
||||
- **Enough space for game files and saves on one claim.** Both live on `<release>-satisfactory-vc`, mounted twice under different subPaths; the Steam download alone is north of 10 GB.
|
||||
- **The default ports `30777`/`30888` free on every node**, or your own values. They are pinned, not allocated.
|
||||
- **Outbound access to Steam** from the pod, and inbound **UDP** reachability from the players' side.
|
||||
- No 1Password item, no cert-manager, no database.
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install satisfactory jlab/satisfactory \
|
||||
--namespace satisfactory --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
The chart installs with its defaults; a values file is only needed to deviate:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
storage: 40Gi
|
||||
|
||||
game:
|
||||
maxPlayers: 8
|
||||
enableSeasonalEvents: true
|
||||
```
|
||||
|
||||
Players then add the server in the game client under Server Manager → Add Server as `<node-ip>:30777`. Node addresses are not rendered anywhere — the chart does not know them (`kubectl get nodes -o wide`).
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the ports, the computed limits and the seasonal-event state of this release. Read it — it is the per-release version of this page.
|
||||
|
||||
## Configurable values
|
||||
|
||||
This chart keeps `env`, the three probes and `securityContext` at the **top level** of `values.yaml`, not under a chart-named key. The paths in its error messages therefore have no `satisfactory.` prefix.
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders the StatefulSet with `replicas: 0` without uninstalling. Takes precedence over `server.replicas`. Service and PVC stay, so no save is lost and the game files do not have to be downloaded again. |
|
||||
|
||||
### `dev`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `dev.debug` | bool/string | `false` | Passed to the image as `DEBUG`. Changes what you read, not what the server serves — but it makes the log noticeably larger while the pod is pulling 10 GB. |
|
||||
|
||||
### `game`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `game.experimental` | bool/string | `false` | `STEAMBETA` — switches to the experimental Steam branch. See [Switching the branch](#switching-the-branch-is-a-one-way-move-for-a-save). |
|
||||
| `game.maxPlayers` | int/string | `4` | `MAXPLAYERS`. |
|
||||
| `game.enableSeasonalEvents` | bool/string | `false` | Seasonal (holiday) events. **Negated** by the chart into the image's `DISABLESEASONALEVENTS`: `true` renders `"false"` and the events run. Only `true`/`false` are accepted, boolean or quoted; anything else aborts the render. See [Seasonal events](#seasonal-events-the-key-was-renamed-in-700-and-it-is-breaking). |
|
||||
|
||||
The whole block may be erased (`null`) — the chart then falls back to exactly these defaults instead of aborting with a nil-pointer error, which is what an erased `game:` used to do.
|
||||
|
||||
### `server`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `server.replicas` | int | `1` | Replica count; `0` is allowed. More than one does not work — see [One replica](#one-replica-one-volume-one-port). |
|
||||
| `server.gamePort` | int | `30777` | Game traffic (UDP) **and** the server's HTTPS API (TCP), as `containerPort` and as a pinned `nodePort`. Also passed to the image as `SERVERGAMEPORT`, so the port a player types is the port the server believes it runs on. |
|
||||
| `server.messagingPort` | int | `30888` | Messaging port (TCP), `SERVERMESSAGINGPORT`, likewise pinned as a `nodePort`. |
|
||||
| `server.storage` | string | `20Gi` | Size of the data PVC — game files *and* saves. Not null-safe on purpose: an erased value yields an invalid PVC rather than a silently degraded workload. |
|
||||
| `server.resources.limitFactor` | number | `2` | Memory and ephemeral-storage limits are computed as factor × request. **`2`, not the repo-wide `3`, on purpose** — that is what holds the memory limit at the 8Gi this chart has always published ([style guide §12.4](Chart-Style-Guide#12-values-that-must-not-lie)). |
|
||||
| `server.resources.requests` | object | `memory: 4Gi`, `cpu: 1`, `ephemeral-storage: 5Gi` | |
|
||||
| `server.resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here — CPU is compressible, so an unlimited container is throttled rather than killed. |
|
||||
|
||||
### `env`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `env` | list | `[]` | Extra environment variables, appended **after** the nine the chart always sets (`DEBUG`, `MAXPLAYERS`, `PGID`, `PUID`, `SKIPUPDATE`, `STEAMBETA`, `DISABLESEASONALEVENTS`, `SERVERGAMEPORT`, `SERVERMESSAGINGPORT`). Rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. A repeated name is present twice and Kubernetes keeps the last occurrence — which is the only way to override one of the nine, `SKIPUPDATE` in particular. See [Every start asks Steam](#every-start-asks-steam-for-an-update). |
|
||||
|
||||
### Probes
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `livenessProbe` | object | `exec /home/steam/healthcheck.sh`, period 30s, timeout 10s, 4 failures, 1 success | The image's own script, which queries the HTTPS API on `SERVERGAMEPORT` — so it keeps working when the port is overridden, which a `tcpSocket` default could not. |
|
||||
| `readinessProbe` | object | `tcpSocket` on the named port `api`, period 10s, timeout 5s, 3 failures | A bare TCP connect: it proves that something accepts a connection there. |
|
||||
| `startupProbe` | object | `exec /home/steam/healthcheck.sh`, period 30s, timeout 10s, 40 failures (= 20 minutes) | Gates liveness and readiness. The budget is deliberately huge — the first start downloads the game. |
|
||||
|
||||
### `securityContext`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `securityContext.pod` | object | non-root `1000:1000`, `fsGroup: 1000`, `fsGroupChangePolicy: OnRootMismatch`, `seccompProfile: RuntimeDefault` | Passed into the pod spec. The uid is pinned because the image supports rootless operation **only** as its own `steam` user; its entrypoint rejects any other non-root uid/gid. `OnRootMismatch` is deliberate ([style guide §12.4](Chart-Style-Guide#12-values-that-must-not-lie)) — see [The uid is pinned](#the-uid-is-pinned-and-the-chown-is-skipped). |
|
||||
| `securityContext.container` | object | `readOnlyRootFilesystem: true`, all caps dropped, no privilege escalation, non-root | Works because the chart mounts emptyDirs at `/tmp` and over the steam user's `~/.steam`, `~/.config` and `~/.local`; everything else is written to the PVC at `/config`. |
|
||||
|
||||
Probe, `securityContext`, `game` and `resources` blocks may be erased (`null`) — the chart then falls back to exactly these defaults instead of rendering an unhardened, unprobed or unaccounted container.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### The node ports are pinned, so a second release collides
|
||||
|
||||
All three `nodePort`s are set to the same number as the container port, so the port a player types is the port the server believes it runs on. Because they are fixed rather than allocated, a **second release of this chart in the same cluster** is rejected with `provided port is already allocated` — and that release fails on the Service alone while the rest of it applies. Give a second server its own `server.gamePort` and `server.messagingPort`.
|
||||
|
||||
The defaults `30777`/`30888` sit inside the default node-port range on purpose: they echo the game's usual `7777`/`8888` in the digits without needing a cluster-level change. Moving them outside `30000-32767` requires an extended `--service-node-port-range`, or the Service is rejected while the StatefulSet still comes up green.
|
||||
|
||||
### The first start downloads the game, and it is not quick
|
||||
|
||||
The image does not carry the game; it fetches the server files through Steam onto the volume. Until that is done the pod is not Ready and the node port refuses connections — the endpoint only joins the Service once readiness passes. If the download needs longer than the startup budget (20 minutes by default), the kubelet kills the container; the next attempt continues against whatever already lies on the volume, so a slow link still converges over several restarts — but it presents as `CrashLoopBackOff`, and nothing in that word says "still downloading". Look at the log, not at the restart count.
|
||||
|
||||
### Every start asks Steam for an update
|
||||
|
||||
`SKIPUPDATE` is pinned to `"false"` by the chart and is not exposed as a value; only an `env` entry repeating the name can override it. So this is not a first-boot cost: **every** start checks for an update, including every restart and every `helm upgrade`. Plan a rollout as minutes of downtime, not seconds. Overriding it makes restarts fast at the price that a new game version is never picked up — including the one an app-version bump of this chart expects.
|
||||
|
||||
### What a green pod does not prove
|
||||
|
||||
No probe in this release ever touches the **UDP** game port, the one players actually use. A node firewall, a NetworkPolicy or anything upstream that forwards TCP only leaves all three probes green, the rollout green and the restart count at zero while not one client can join. That failure has to be tested from outside the cluster; `kubectl` will never show it.
|
||||
|
||||
### Save games and game files share one volume
|
||||
|
||||
`<release>-satisfactory-vc` is mounted twice from the same claim: `config/` → `/config` (saves and server settings) and `gameFiles/` → `/config/gamefiles` (the Steam download). `server.storage` has to hold both, and a volume that fills up during a Steam update fails the update, not the save. Growing it only takes effect if the storage class allows volume expansion; shrinking is refused by Kubernetes and makes the next `helm upgrade` fail on the PVC instead of touching the data.
|
||||
|
||||
The claim is named `-vc`, not `-pvc`: a [documented naming exception](Chart-Style-Guide#1-naming), because renaming it would provision a new empty volume and orphan the world.
|
||||
|
||||
### The emptyDirs count against the ephemeral-storage limit
|
||||
|
||||
With a read-only root filesystem, `/tmp` and the steam user's dot-directories are emptyDirs — steamcmd bootstraps itself into `~/.local/share/Steam`. emptyDir usage counts against the container's ephemeral-storage limit (`limitFactor` × request, `10Gi` by default). An overrun is not an error message: the kubelet evicts the pod, and the reason sits on the pod, not in the server's log.
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> describe pod <release>-satisfactory-sfs-0 | grep -A3 Evicted
|
||||
```
|
||||
|
||||
### The uid is pinned, and PUID/PGID do nothing
|
||||
|
||||
The pod runs as `1000:1000` because the image supports rootless operation only as its own `steam` user. `PUID` and `PGID` are set in the pod spec but only ever apply to the image's **root** entrypoint path, which this chart never takes — they are inert. Changing `securityContext.pod.runAsUser` is how you stop the container from starting, not how you change the uid.
|
||||
|
||||
`fsGroupChangePolicy: OnRootMismatch` skips the recursive chown of the multi-GB game files on every single pod start. The price: Kubernetes looks only at the volume's **top-level** ownership. If that already reads `1000:1000` it does not descend, so a subtree with wrong ownership — a restored backup, files written by another uid — is never repaired and the server fails to write there. Fix such a case by hand once; do not switch the policy to `Always` unless you want the chown back on every start.
|
||||
|
||||
### No secrets — and where the admin password actually lives
|
||||
|
||||
This chart creates no `OnePasswordItem`, no Secret and no ConfigMap, and reads none. The server is claimed and its admin password set from the game client on first connect, and that state lives in the save data on the volume — not in any Kubernetes object. Restoring the volume restores the password with it; losing the volume loses it, and nothing in this release can hand it back.
|
||||
|
||||
### One replica, one volume, one port
|
||||
|
||||
Every pod of this StatefulSet would mount the same ReadWriteOnce claim at the same paths — there is no `volumeClaimTemplate` — so extra pods stay stuck before start, and where they do start they write the same save from two processes. The pinned nodePort makes the point moot anyway: one port on a node routes to whichever pod is Ready.
|
||||
|
||||
### Seasonal events: the key was renamed in 7.0.0, and it is breaking
|
||||
|
||||
`game.seasonalEvents` **is gone**. It is not deprecated and not quietly accepted: `values.schema.json` rejects it, so a values file that still carries it fails the upgrade with a validation error instead of installing something you did not ask for.
|
||||
|
||||
Up to 6.1.0 the value was handed to the container unchanged, into a variable whose name says the opposite: `game.seasonalEvents: true` wrote `DISABLESEASONALEVENTS=true`. The shipped default therefore had the events switched **off** in every install for as long as the chart existed — while the schema described the same key as "true means events are enabled". The new key `game.enableSeasonalEvents` is negated by the chart and defaults to `false`, which renders exactly what the chart has always rendered: upgrading alone does not move your server, you have to ask for the events.
|
||||
|
||||
Two mechanics come with the fix, and they generalize beyond this chart ([style guide §12.6](Chart-Style-Guide#12-values-that-must-not-lie), [Lessons learned](Lessons-Learned)): Go's `not` treats every non-empty string as true, so `not "false"` is `false` — the chart compares explicitly instead; and a value that is neither `true` nor `false` aborts the render rather than being filed under "off".
|
||||
|
||||
### Switching the branch is a one-way move for a save
|
||||
|
||||
Back up `/config` before setting `game.experimental: true`. A save opened by an experimental build may not open on the stable build again, and setting the flag back re-downloads the stable files but does not undo what happened to the save. With `SKIPUPDATE` overridden the branch switch does not take effect at all — the two settings contradict each other.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **Back up the volume before every app-version bump and before flipping `game.experimental`.** The save is the only copy of the world *and* of the admin password.
|
||||
- **`helm uninstall` deletes the PVC.** It is an ordinary release object, not a `volumeClaimTemplate`, so under a `Delete` reclaim policy every save goes with it. A `helm.sh/resource-policy` annotation added to the live PVC afterwards does not save you — Helm reads that annotation from the release manifest, not from the cluster.
|
||||
- **Raise `startupProbe.failureThreshold` before** a first install on a slow link, not after the crash loop has started.
|
||||
- **Every upgrade is minutes of downtime**, because the restarted pod asks Steam for an update first.
|
||||
|
||||
## CI
|
||||
|
||||
`satisfactory` is **excluded from the install-test** (`ci/excluded-charts.txt`), with the justification recorded there: *the game-server image is multiple GB and Steam downloads 10GB+ game files on first start — impractical on a CI runner*. There is no `ci/satisfactory/` directory.
|
||||
|
||||
An excluded chart gets **no install-test job at all** — it is dropped from the matrix rather than skipped inside a job, so no check can report green for a chart whose image was never pulled; the PR's job summary lists it with this justification instead ([CI/CD](CI-CD)). Everything else still runs: strict lint, package, the version-bump check and the resource-quantity render test. **No CI job has ever started this workload — treat the first rollout in a new cluster as the real test.**
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements, and the two deviations it is allowed
|
||||
- [Lessons learned](Lessons-Learned) — the inverted-value lesson this chart produced
|
||||
- [Versioning](Versioning) — what a bump of `7.0.0+upv1.9.10` means
|
||||
- [Chart index](Charts)
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
# searxng
|
||||
|
||||
Deploys [SearXNG](https://docs.searxng.org/), a metasearch engine, as a stateless Deployment. The whole `settings.yml` is rendered from values into a ConfigMap; the signing key comes from 1Password. The chart ships no Valkey/Redis of its own.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `1.0.5+up2026.9.13.3b8009065` |
|
||||
| App version | `2026.9.13-3b8009065` (image `docker.io/searxng/searxng`) |
|
||||
| Workloads | Deployment (1 replica) |
|
||||
| Storage | none — two emptyDirs (`/tmp`, `/var/cache/searxng`) |
|
||||
| Ingress | yes, `ingress.domain` **always** required, `ingress.clusterIssuer` only while the Ingress is on |
|
||||
| External dependencies | none by default; an external Valkey/Redis only for the limiter |
|
||||
| Secrets | 1 × `OnePasswordItem`, a second one when a Valkey is configured |
|
||||
| Install-test | `ci/searxng/` (fixtures + test values) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **1Password operator** in the cluster, plus one 1Password item — see [the fields below](#what-the-1password-items-must-contain). Without it the pod never starts: SearXNG exits 1 rather than run on the upstream placeholder key.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller.
|
||||
- **Something that authenticates in front of it, if it must not be open.** SearXNG has no login of its own and this chart adds none; the intended place is `ingress.annotations` (forward-auth), see [Authentication belongs on the Ingress](#authentication-belongs-on-the-ingress).
|
||||
- **An external Valkey/Redis** only if `settings.limiter` is switched on. Nothing else in the chart needs one.
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install searxng jlab/searxng \
|
||||
--namespace searxng --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
searxngSecretRef: op://vault/searxng
|
||||
|
||||
ingress:
|
||||
domain: search.example.com
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set — including whether this release is rate limited at all. Read it — it is the per-release version of this page.
|
||||
|
||||
### What the 1Password items must contain
|
||||
|
||||
The chart renders `OnePasswordItem` CRs; the operator turns each into a Kubernetes Secret whose **keys are the field names of the 1Password item**. A missing field does not fail the install: the release goes through and the pod then hangs in `CreateContainerConfigError` naming the key it could not find.
|
||||
|
||||
| Values key | Kubernetes Secret | Fields |
|
||||
|---|---|---|
|
||||
| `secrets.searxngSecretRef` | `<release>-searxng-secret` | `secret-key` — signs the settings cookie and the image-proxy URLs. Anything but the upstream placeholder `ultrasecretkey`; `openssl rand -hex 32` |
|
||||
| `secrets.valkeySecretRef` | `<release>-searxng-valkey-secret` | `valkey-url` — e.g. `redis://valkey.valkey.svc:6379/0`. Only rendered when the value is set |
|
||||
|
||||
The key is a Secret rather than a ConfigMap key on purpose: a ConfigMap is what people read without thinking twice, and this value signs every user's cookie.
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0` without uninstalling. Takes precedence over `searxng.replicas`. Secret, Service, ConfigMap and Ingress stay. |
|
||||
| `global` | object | unset | Helm's conventional cross-chart values. Unused by this chart; declared in the schema and kept open so an umbrella install cannot break on it. |
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.searxngSecretRef` | string | `null` | **Required, always.** `op://` path of the item carrying `secret-key`. |
|
||||
| `secrets.valkeySecretRef` | string | `null` | `op://` path of an **external** Valkey/Redis item. Optional; renders a second `OnePasswordItem` and passes `SEARXNG_VALKEY_URL`. Only meaningful with `settings.limiter: true`. |
|
||||
|
||||
### `settings`
|
||||
|
||||
The chart-owned part of `/etc/searxng/settings.yml`. The file starts with `use_default_settings: true` and names only the deviations, so the image's ~3500-line default settings stay the source of truth for engines.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `settings.instanceName` | string | `SearXNG` | `general.instance_name` — shown in the UI and in the OpenSearch description. |
|
||||
| `settings.safeSearch` | int | `0` | `search.safe_search`; `0` off, `1` moderate, `2` strict. Enumerated in the schema — SearXNG accepts nothing else. |
|
||||
| `settings.autocomplete` | string | `duckduckgo` | `search.autocomplete`; the empty string disables suggestions. Not enumerated — the backend list is upstream's and grows. |
|
||||
| `settings.imageProxy` | bool | `true` | `server.image_proxy` — thumbnails routed through the instance instead of fetched by the browser from the origin. |
|
||||
| `settings.limiter` | bool | `false` | `server.limiter`, the bot detection. Does **nothing** without `secrets.valkeySecretRef` — see [The limiter fails quietly](#the-limiter-fails-quietly-without-a-valkey). |
|
||||
| `settings.publicInstance` | bool | `false` | `server.public_instance`. Depends on the limiter's infrastructure; off for the same reason. |
|
||||
| `settings.extra` | object | `{}` | Free-form passthrough in SearXNG's own key names, merged **over** the block the keys above build. Deliberately open in the schema. See [`settings.extra` is the real configuration surface](#settingsextra-is-the-real-configuration-surface). |
|
||||
|
||||
`settings` and every block below it may be erased to `null`; the chart then falls back to these defaults instead of rendering an instance with an empty name.
|
||||
|
||||
### `searxng`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `searxng.replicas` | int | `1` | Replica count; `0` is allowed. More than one works — settings live in a signed cookie, and all pods share the one key — but each pod warms its own `/tmp` cache. |
|
||||
| `searxng.securityContext.pod` | object | non-root 977, `fsGroup: 977`, `fsGroupChangePolicy: Always`, `seccompProfile: RuntimeDefault` | Merged onto the chart's pod hardening. **977 is not interchangeable**: it is the image's `searxng` user. The seccomp profile sits here so it covers every container including init containers. |
|
||||
| `searxng.securityContext.container` | object | `readOnlyRootFilesystem: true`, `runAsNonRoot: true`, all caps dropped, no privilege escalation | Merged onto the chart's container hardening. The read-only root filesystem holds because `/tmp` and `/var/cache/searxng` are emptyDirs. |
|
||||
| `searxng.livenessProbe` | object | `GET /healthz:http`, period 10s, timeout 5s, 3 failures | See [Why all three probes use `/healthz`](#why-all-three-probes-use-healthz). |
|
||||
| `searxng.readinessProbe` | object | `GET /healthz:http`, period 10s, timeout 5s, 3 failures | |
|
||||
| `searxng.startupProbe` | object | `GET /healthz:http`, period 5s, 30 failures (= 150s) | Gates liveness and readiness. A healthy start was measured at 6s in k3d, so the budget is ~25× that — margin for a cold node, not for a slow app. |
|
||||
| `searxng.env` | list | `[{name: GRANIAN_HOST, value: "::"}]` | **This one is a default, not an addition to one**: setting `searxng.env` replaces the list wholesale, so the `GRANIAN_HOST` entry has to be repeated. Rendered through `tpl`; `value`, `secretKeyRef`, `configMapKeyRef` forms. See [`GRANIAN_HOST` and the `SEARXNG_*` precedence trap](#granian_host-and-the-searxng_-precedence-trap). |
|
||||
| `searxng.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `searxng.resources.requests` | object | `cpu: 0.1`, `memory: 256Mi`, `ephemeralStorage: 64Mi` | Measured: 152 MiB resident once up, 157 MiB peak across a real search; 356 KiB of SQLite cache in `/tmp`. |
|
||||
| `searxng.resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
|
||||
The container port (8080, named `http`) is **not** a value: a value could lie, because granian would keep listening on 8080 while the probes and Service moved. The port *name* is load-bearing — the Service and all three probes select by it.
|
||||
|
||||
### `ingress`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | Gates the Ingress object **only**. `ingress.domain` stays required — see [`ingress.domain` is read twice](#ingressdomain-is-read-twice). |
|
||||
| `ingress.domain` | string | `null` | **Required, with or without the Ingress.** Ingress host, and `SEARXNG_BASE_URL` (`server.base_url`). |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. Freed by `ingress.enabled: false`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. The place for forward-auth. |
|
||||
|
||||
TLS is stored in `tls-<release>-searxng-ingress`.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### `ingress.domain` is read twice
|
||||
|
||||
`ingress.enabled: false` removes the Ingress and frees `clusterIssuer` — and nothing else. The domain is read a second time, outside the Ingress template, to build `SEARXNG_BASE_URL`, and carries its own `required()` there. That read cannot be dropped: `server.base_url` is what SearXNG puts into the OpenSearch description, into the image-proxy URLs it signs and into every absolute link. Left empty it renders `https:///`, the pod goes green, and browsers are sent to a broken address.
|
||||
|
||||
With the Ingress off, reach the instance with a port-forward — and expect links on the page to still point at `https://<ingress.domain>/`.
|
||||
|
||||
### `settings.extra` is the real configuration surface
|
||||
|
||||
The chart models six first-class keys and nothing else, on purpose: a chart that mirrors SearXNG's hundreds of engine options is out of date with the first upstream change and then lies about what the instance runs. Everything else goes into `settings.extra` in upstream's own key names and is merged over the generated block:
|
||||
|
||||
```yaml
|
||||
settings:
|
||||
extra:
|
||||
search:
|
||||
default_lang: "de"
|
||||
formats: ["html", "json"]
|
||||
ui:
|
||||
default_theme: simple
|
||||
```
|
||||
|
||||
Merge semantics come from the same null-safe helper the security contexts use — **not** sprig's `mergeOverwrite`, which drops zero values: nested maps merge key by key, an explicit `false` or `0` wins, a `null` keeps the value from the block below (nulls never delete), and a scalar written where the base has a map is rejected by name instead of failing later as a template error. `settings.extra` stays open in `values.schema.json` because its schema is SearXNG's, not this chart's.
|
||||
|
||||
`server.secret_key` is deliberately absent from the file: it arrives as `SEARXNG_SECRET` from the Secret, so the signing key never lands in a ConfigMap.
|
||||
|
||||
### Why all three probes use `/healthz`
|
||||
|
||||
`/healthz` is the only path SearXNG's bot detection lets through unconditionally (`limiter.py` `filter_request()` returns early on exactly this path). Measured in k3d against a reachable Valkey with the limiter on: `/` answered **429** while `/healthz` answered 200. A probe on `/` would therefore not merely risk a 429 — it would collect one the moment the limiter is switched on, and the NotReady pod would point at the application instead of at the bot detection. The handler is a fixed `Response('OK')`: no engines, no cache, no network, so liveness carries no external dependency ([style guide §12.3](Chart-Style-Guide#12-values-that-must-not-lie)).
|
||||
|
||||
Stated plainly against it: a green pod means the WSGI worker answers. It does not mean a search works, that any engine is reachable, or that this chart's `settings.yml` was the file that got loaded.
|
||||
|
||||
### The limiter fails quietly without a Valkey
|
||||
|
||||
`settings.limiter: true` without `secrets.valkeySecretRef` is the uncomfortable middle outcome, measured rather than guessed:
|
||||
|
||||
- one ERROR at worker start, `searx.limiter: The limiter requires Valkey, …`, and nothing after that
|
||||
- the pod becomes Ready and stays Ready
|
||||
- every request is served normally — `/`, `/search` and `/healthz` all answered 200, with no rate limiting in effect
|
||||
|
||||
So the release is *not* rate limited, and the only trace is one line at the top of the log. `NOTES.txt` prints this case explicitly. Either set the Valkey ref or set the limiter back to `false`.
|
||||
|
||||
### Authentication belongs on the Ingress
|
||||
|
||||
SearXNG has no login. Forward-auth is entirely an ingress-nginx matter and needs no chart change — put the `auth-url` / `auth-signin` / `auth-response-headers` / `auth-snippet` annotations under `ingress.annotations` (the example in `values.yaml` uses Authentik). Two consequences:
|
||||
|
||||
- The kubelet's probes go straight to the pod and never pass the Ingress, so an authentication outpost that is down cannot turn this release red. Good for availability; it also means a green pod says nothing about whether the login in front of it works.
|
||||
- Anyone who adds this instance as a browser search engine sends their queries through that same login, which is why such a setup "suddenly stops working" after a session expires.
|
||||
|
||||
### `GRANIAN_HOST` and the `SEARXNG_*` precedence trap
|
||||
|
||||
`GRANIAN_HOST: "::"` is the image's own default and was measured here: in k3d the pod is single-stack IPv4, granian logs `Listening at: http://:::8080`, and the kubelet's IPv4 probes are answered. On a cluster with an IPv6-disabled kernel granian would fail to bind — set `0.0.0.0` there. There is no `SEARXNG_*` alias for the bind address; the entrypoint maps only `SEARXNG_PORT` onto `GRANIAN_PORT`.
|
||||
|
||||
The precedence is the trap: SearXNG's own `SEARXNG_*` variables override the ConfigMap, so an env entry named `SEARXNG_LIMITER` beats `settings.limiter`. That is a way out if you need it and a trap if you forget it.
|
||||
|
||||
### Rotating the signing key does not restart the pod
|
||||
|
||||
Helm never sees a `OnePasswordItem`'s content, so it cannot hash it ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)). The Deployment carries `checksum/secret-ref` and `checksum/secret-ref-valkey` over the rendered CRs, so pointing a `*SecretRef` at a **different** item rolls the pods; editing the value **inside** the same item does not — both are consumed as environment variables, which Kubernetes never refreshes in a running container.
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout restart deployment/<release>-searxng-deployment
|
||||
```
|
||||
|
||||
A new key invalidates every user's saved preferences cookie, which is a logout-shaped event for the people using it.
|
||||
|
||||
A changed `settings` block **does** roll the pods: the Deployment carries `checksum/config` over the ConfigMap. Without it nothing would happen — `settings.yml` is a `subPath` mount, which the kubelet never refreshes, and SearXNG reads its configuration once at start.
|
||||
|
||||
### The expected ownership warning
|
||||
|
||||
The emptyDir at `/var/cache/searxng` is owned `root:977` (`fsGroup` only sets the group), so the entrypoint prints a multi-line
|
||||
|
||||
```
|
||||
!!! WARNING
|
||||
!!! "/var/cache/searxng" directory is not owned by "searxng:searxng"
|
||||
```
|
||||
|
||||
and carries on. It cannot fix the ownership as a non-root process — which is the point — and does not need to: the directory is group-writable through `fsGroup`. Not a fault.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **Nothing to back up.** `settings.yml` comes from the ConfigMap, the signing key from the Secret, and both writable paths are emptyDirs that refill on their own. App-version upgrades and rollbacks carry no data risk.
|
||||
- `/tmp` is **not** optional: `searx/cache.py` falls back to `tempfile.gettempdir()` and `webapp.py` connects to two SQLite caches before the first request. A read-only `/tmp` is not a degraded cache, it is a container that never starts.
|
||||
- `/var/cache/searxng` is mounted although nothing in this version writes there — it is the path the application declares as its data directory, and the day a release starts using it a read-only root filesystem would turn that into a crash loop.
|
||||
|
||||
## Renovate
|
||||
|
||||
SearXNG publishes no semver line: every tag is `<date>-<commit>` and `latest` moves daily. `appVersion` is the exact tag, and the `+up` suffix normalizes the dash to a dot as [Versioning](Versioning) requires.
|
||||
|
||||
The chart's `customManagers` block overrides `versioning` with an anchored regex in which **the commit hash sits outside every capture group**. That is the lesson this chart paid for: Renovate parses `major`/`minor`/`patch`/`build`/`revision` with `Number.parseInt`, so a hash captured as `build` becomes whatever digits lead it — `9fea41204` is `9`, `777ba8fa4` is `777`, 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, unattended under the patch/minor automerge rule. A capture group is a claim about how the value is compared; parts of a tag that carry no ordering belong outside every group. `prerelease` is not the way out — it sorts correctly but marks every tag unstable, handing the chart to `ignoreUnstable`. See [Renovate](Renovate) and [Lessons learned](Lessons-Learned).
|
||||
|
||||
## CI
|
||||
|
||||
`ci/searxng/` holds `test-values.yaml` (`searxng.ci.local`, an `op://` ref against the stub CRD, and deliberately **non-default** settings) and `fixtures.yaml` (the one dummy Secret the operator would otherwise materialize). Two things are deliberate there:
|
||||
|
||||
- The install runs the default path — limiter off, no Valkey — because switching the limiter on without a reachable Valkey produces a Ready pod that logs an error, which makes the test slower without making it stricter.
|
||||
- The fixture's `secret-key` is a real random-looking string, **not** `ultrasecretkey`: `webapp.py` exits 1 on the placeholder, so a careless fixture would be a crash loop rather than a passing install-test.
|
||||
- The test values set `instanceName`, `safeSearch` and an `extra` block with a nested map, so the install-test is the place that proves the merge joins rather than replaces (`search.safe_search` has to survive next to `search.default_lang`).
|
||||
|
||||
The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements
|
||||
- [Versioning](Versioning) — what a bump of `1.0.5+up2026.9.13.3b8009065` means
|
||||
- [Renovate](Renovate) — why this chart carries a `versioning` override
|
||||
- [Lessons learned](Lessons-Learned)
|
||||
- [Chart index](Charts)
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
# teamspeak-server
|
||||
|
||||
Deploys a [TeamSpeak 6](https://teamspeak.com/) server as a single-replica StatefulSet with one data volume. TeamSpeak is not HTTP, so there is **no Ingress and no certificate**: the chart exposes two NodePorts, one UDP for voice and one TCP for file transfer. This chart also keeps its values at the **top level** rather than under a chart-named key.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `5.1.0+up6.0.0.beta12.1` |
|
||||
| App version | `6.0.0-beta12.1` (image `teamspeaksystems/teamspeak6-server`) |
|
||||
| Workloads | StatefulSet (1 replica) |
|
||||
| Storage | one RWO PVC on `/var/tsserver` — no scratch volume, no emptyDir |
|
||||
| Ingress | **no** — NodePort Service on both ports |
|
||||
| External dependencies | none (the server keeps its own SQLite database on the volume) |
|
||||
| Secrets | none — the server prints its own credentials once, at first start |
|
||||
| Install-test | `ci/teamspeak-server/` (test values only, no fixtures) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **A StorageClass** for `storage.storageClass` (default `longhorn`) that honours `fsGroup`. On one that does not, the pod starts and the server then cannot open its database — that failure is in the pod log, not in the events.
|
||||
- **Two free node ports inside the cluster's node-port range** (a default cluster allocates 30000–32767). Both values are used as NodePort, so a number outside that range is rejected by the API server, not by the chart.
|
||||
- **Reachability of the node IPs** for your users, and firewall rules for both ports — UDP for voice, TCP for file transfer.
|
||||
- Decide `server.voicePort` **before the first install**; it cannot be changed afterwards without a new volume (see below).
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install ts jlab/teamspeak-server \
|
||||
--namespace teamspeak --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml` — everything has a default, so this is optional:
|
||||
|
||||
```yaml
|
||||
storage:
|
||||
size: 25Gi
|
||||
storageClass: longhorn
|
||||
server:
|
||||
voicePort: 30987
|
||||
fileTransferPort: 30033
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set — and warns if a port falls outside the node-port range. Read it — it is the per-release version of this page.
|
||||
|
||||
### How it is reached
|
||||
|
||||
The Service `<release>-teamspeak-server-service` is of type **NodePort**, so both ports are open on **every node** of the cluster and reachable by anyone who can reach a node.
|
||||
|
||||
```
|
||||
voice node IP : 30987/UDP (server.voicePort)
|
||||
file transfer node IP : 30033/TCP (server.fileTransferPort)
|
||||
|
||||
kubectl get nodes -o wide # the IPs to hand to your users
|
||||
```
|
||||
|
||||
Because the voice port is not 9987 — the port a client assumes when it is given a bare host name — tell users the full address `NODE-IP:30987`. Inside the cluster the same ports apply; there is no separate service port:
|
||||
|
||||
```
|
||||
<release>-teamspeak-server-service.<namespace>.svc.cluster.local:30987
|
||||
```
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders the StatefulSet with `replicas: 0` without uninstalling. Takes precedence over `replicas`. PVC and Service stay, so no data is lost and the node ports stay reserved. |
|
||||
| `replicas` | int | `1` | Replica count; `0` is allowed. Top-level, not under a chart key. See [One replica, one volume](#more-than-one-replica-will-not-work). |
|
||||
| `global` | object | unset | Helm's conventional cross-chart values. Unread by this chart; kept open in the schema so an umbrella install cannot break on it. |
|
||||
|
||||
### `server`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `server.voicePort` | int (1–65535) | `30987` | UDP voice port. Used **three times**: container port, Service port and NodePort, and passed to the server as `TSSERVER_DEFAULT_PORT`. **Fixed for the life of the data volume** — see below. |
|
||||
| `server.fileTransferPort` | int (1–65535) | `30033` | TCP file-transfer port, used the same three times plus `TSSERVER_FILE_TRANSFER_PORT`. Unlike the voice port, this one is applied on **every** start and really does follow the value. It is also the port every probe addresses, by the name `file-transfer`. |
|
||||
|
||||
The schema accepts 1–65535 because that is what a port is; a default cluster only allocates 30000–32767. A value outside the range renders fine and is rejected by the **API server** when the Service is applied, with `provided port is not in the valid range` and no mention of this chart. `NOTES.txt` names the offending value explicitly for that reason.
|
||||
|
||||
### `storage`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `storage.size` | string | `25Gi` | Size of the data PVC. Growing works only on a StorageClass that allows expansion; shrinking is refused by Kubernetes and fails the next upgrade on the PVC instead of touching data. |
|
||||
| `storage.storageClass` | string | `longhorn` | StorageClass of the data PVC. Must honour `fsGroup`. |
|
||||
|
||||
Deliberately **not** null-safe: an erased `storage` block yields an invalid PVC rather than a silently degraded workload.
|
||||
|
||||
### Probes, hardening, resources and env (all top-level)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `livenessProbe` | object | `tcpSocket` on port **`file-transfer`**, period 10s, timeout 5s, 3 failures | The voice service is UDP and cannot be probed, so every probe connects to the TCP file-transfer port instead — addressed **by name**. |
|
||||
| `readinessProbe` | object | `tcpSocket` on port `file-transfer`, period 10s, timeout 5s, 3 failures | |
|
||||
| `startupProbe` | object | `tcpSocket` on port `file-transfer`, period 5s, timeout 5s, 30 failures (= 150s) | Gates liveness and readiness. |
|
||||
| `securityContext.pod` | object | non-root `9987:9987`, `fsGroup: 9987`, `fsGroupChangePolicy: Always` | Passed verbatim into the pod spec; `9987` is the image's `teamspeak` user. `fsGroup` is what makes the data volume writable. |
|
||||
| `securityContext.container` | object | `allowPrivilegeEscalation: false`, `readOnlyRootFilesystem: true`, `runAsNonRoot: true`, `capabilities.drop: [ALL]`, `capabilities.add: []` | Passed verbatim into the container spec. |
|
||||
| `env` | list | `[]` | Extra environment variables for the server container. Rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. **Appended**, never merged — see [the port trap](#env-is-appended-and-can-shadow-the-charts-ports). |
|
||||
| `resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `resources.requests` | object | `cpu: 0.2`, `memory: 100Mi`, `ephemeral-storage: 100Mi` | |
|
||||
| `resources.limits` | object | unset (computed: `memory: 300Mi`, `ephemeral-storage: 300Mi`) | An explicit limit always wins. A CPU limit is only rendered when set here. Since the container writes nothing outside the volume, the ephemeral-storage limit is headroom rather than a working budget. |
|
||||
|
||||
An erased block (`securityContext:` with no children, a `null` probe, a `null` `resources`) falls back to the chart's own defaults instead of disappearing — [style guide §12.2](Chart-Style-Guide#12-values-that-must-not-lie).
|
||||
|
||||
## Operating notes
|
||||
|
||||
### Why the probes name `file-transfer`
|
||||
|
||||
This chart is the repo's cautionary example for [style guide §12.5](Chart-Style-Guide#12-values-that-must-not-lie): **a probe default carries the probe's context, not just its timings.** The voice service is UDP and cannot be probed, so all three probes are TCP connects to the file-transfer port, addressed by the port **name** `file-transfer`. A rebuilt fallback default that keeps only the timings and drops the name leaves the workload with **no working probe at all** — and nothing says so.
|
||||
|
||||
### What a Ready pod does not prove
|
||||
|
||||
Green means exactly one thing: the file-transfer listener accepts a connection. It says nothing about voice, which is the actual service. A release can be fully Ready while the voice port is wrong, while the virtual server is stopped, or while every slot is taken. The license is not covered either — though an expired one crashes the process and does surface as a restart loop.
|
||||
|
||||
The real check is a client connecting to `NODE-IP:<voicePort>`. Short of that, the server's own view:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> logs statefulset/<release>-teamspeak-server-sfs | grep "listening on"
|
||||
```
|
||||
|
||||
which prints the ports the server actually bound — compare them with the two configured ones.
|
||||
|
||||
### `server.voicePort` only takes effect on a brand-new volume
|
||||
|
||||
The trap of this chart, and it is silent in both directions. The chart passes `server.voicePort` as `TSSERVER_DEFAULT_PORT`, and the server reads that variable **only when it creates the virtual server**; from then on the port lives in the virtual server's row in `/var/tsserver/tsserver.sqlitedb` and the variable is ignored. Measured on `6.0.0-beta12.1`: a restart against an existing volume with a changed `TSSERVER_DEFAULT_PORT` kept listening on the old port.
|
||||
|
||||
Changing it on a release that already has data therefore moves the Service port, the NodePort and the containerPort to the new number while the server keeps answering on the old one. Voice becomes unreachable — and every probe stays green, because they watch the other port.
|
||||
|
||||
There is no comfortable way out: the port is a property of the virtual server in the database, and the only interface that can edit it is ServerQuery, which this chart does not expose. Treat `server.voicePort` as fixed for the life of the data volume; if it really has to change, plan for a new volume and a new server.
|
||||
|
||||
### The first start prints two secrets and never prints them again
|
||||
|
||||
Against an empty data volume the server creates its database, a virtual server, a ServerQuery admin account and a ServerAdmin privilege key. All of it goes to stdout **once**, at that first start only, and into no Secret:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> logs <release>-teamspeak-server-sfs-0
|
||||
```
|
||||
|
||||
Two different things are printed, and they are not equally recoverable:
|
||||
|
||||
| Printed | What it is | Recoverable? |
|
||||
|---|---|---|
|
||||
| `token=…` | the ServerAdmin privilege key — paste it into a connected client to become server admin | yes: also written to the log **files** on the data volume, `grep -h "token=" /var/tsserver/logs/*.log` |
|
||||
| `serveradmin` / `apikey` | the ServerQuery credentials | **no**: stdout only, never in the log files. Once the pod's stdout is gone, they are gone |
|
||||
|
||||
Losing the privilege key without having claimed admin rights means starting over: the identity that holds admin is stored in the database, so the way back is deleting the volume and letting a new server be created. A second start against an existing volume prints neither — that includes every ordinary restart, rollout, node drain or app-version bump. If a fresh install seems to be missing its token, check whether the PVC already existed.
|
||||
|
||||
### No secrets, and no query interface to use them on
|
||||
|
||||
This chart creates no `OnePasswordItem` and no Kubernetes Secret — there is no `secrets.*` value and nothing to prepare in a vault. Which matters, because those ServerQuery credentials have nowhere to go: measured on `6.0.0-beta12.1`, the only listening sockets in the container are UDP (voice) and TCP (file transfer). The pod declares a third container port, `web-query` 10080, and **nothing binds it**; it is also absent from the Service. The server additionally restricts query access to `127.0.0.1` and `::1` by default. Administration therefore happens through a connected TeamSpeak client with the privilege key, not over ServerQuery.
|
||||
|
||||
### The bundled license expires, and an expired one refuses to start
|
||||
|
||||
The image ships without a license file and falls back to the built-in one. Measured by running `6.0.0-beta12.1`:
|
||||
|
||||
```
|
||||
type : No License
|
||||
starting date : Wed Jul 1 00:00:00 2026
|
||||
ending date : Thu Oct 1 00:00:00 2026
|
||||
max virtualservers: 1
|
||||
max slots : 32
|
||||
```
|
||||
|
||||
Two consequences. **32 slots** is the real capacity of this release, whatever the resources say. And **after the ending date the server refuses to start** — a working deployment turns into `CrashLoopBackOff` with no change on your side. The fix is an app-version bump to a newer beta, which ships a license with a later date; no values change can solve it. Confirm the dates for the version you actually run:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> logs <release>-teamspeak-server-sfs-0 | grep -A6 "Licensing Information"
|
||||
```
|
||||
|
||||
### More than one replica will not work
|
||||
|
||||
Every replica mounts the same **ReadWriteOnce** claim `<release>-teamspeak-volume-claim` — a plain PVC, not a `volumeClaimTemplate`, so there is no per-pod volume. Where the extra pods do start, two servers open the same SQLite database and corrupt it, and the Service sprays a client's UDP packets across both pods, which no TeamSpeak session survives. This chart is built for one replica.
|
||||
|
||||
Note the claim name: `<release>-teamspeak-volume-claim`, **without** the chart name. That is a historical name the chart keeps deliberately — renaming it would orphan the data ([style guide §1](Chart-Style-Guide#1-naming)).
|
||||
|
||||
### The root filesystem is read-only and there is no scratch volume
|
||||
|
||||
The container runs non-root as `9987:9987` with `readOnlyRootFilesystem` and all capabilities dropped, and `/var/tsserver` is the **only** writable path — the chart mounts no emptyDir. Verified against `6.0.0-beta12.1`: the server needs nothing else, so this is the supported configuration and not a deviation to "fix". An image that later wants a writable `/tmp` would fail at start, not at build time.
|
||||
|
||||
### `env` is appended, and can shadow the chart's ports
|
||||
|
||||
Entries land **after** the three variables the chart always sets — `TSSERVER_LICENSE_ACCEPTED`, `TSSERVER_DEFAULT_PORT`, `TSSERVER_FILE_TRANSFER_PORT`. Kubernetes keeps the last value for a repeated name, so redefining either port variable here wins inside the container while the Service, the NodePort and the containerPort still follow `server.*`. That splits the two apart with nothing reporting it — set the ports under `server`, never through `env`. Values are rendered through `tpl`, so anything sensitive belongs in `valueFrom.secretKeyRef`: a plain `value` is stored in the pod spec and readable by everyone who can read pods in the namespace.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **The data volume is the server.** Everything lives in `/var/tsserver`: `tsserver.sqlitedb` (the virtual server, its port, all permissions, identities, channels and ServerQuery accounts), `files/` (file-transfer uploads), `logs/` (including the privilege key) and `query_ip_*list.txt`.
|
||||
- **It is an ordinary PVC of this release and carries no resource-policy annotation.** `helm uninstall <release>` deletes it, and with a `Delete` reclaim policy the whole server goes with it — not just chat history, but the admin identity and the privilege key. Back it up before uninstalling; an annotation added to the live PVC afterwards does not help, because Helm reads it from the release manifest.
|
||||
- **App-version bumps are the license mechanism**, not just a feature update: they are what carries a later expiry date. Renovate tracks `teamspeaksystems/teamspeak6-server` with `versioning: loose`, because the 6.0.0 line ships only beta tags and loose compares the numeric parts left to right, so `beta12.1` sorts above `beta9` where semver would rank every beta as an equal prerelease — see [Renovate](Renovate).
|
||||
- A restart never re-prints the first-start credentials; make sure they are stored somewhere before the pod is replaced.
|
||||
|
||||
## CI
|
||||
|
||||
`ci/teamspeak-server/test-values.yaml` sets only `storage.size: 1Gi` and `storage.storageClass: local-path` — the chart needs no secrets and no throwaway infrastructure, so there is no `fixtures.yaml`. The install-test waits for the StatefulSet to roll out, which here means the file-transfer listener accepted a connection; the caveats above apply to that signal as much as to a production one. See [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — §12.5, whose cautionary example this chart is
|
||||
- [Versioning](Versioning) — what a bump of `5.1.0+up6.0.0.beta12.1` means, and why the `+up` suffix normalizes the dash
|
||||
- [Lessons learned](Lessons-Learned)
|
||||
- [Chart index](Charts)
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
# template-chart
|
||||
|
||||
The scaffold every new chart starts from, and the reference implementation of the [chart style guide](Chart-Style-Guide). It is **not an application chart and not installable**: the image is a placeholder, both workload shapes are rendered side by side, and the example `env` block points at a ConfigMap that does not exist. It is never published, excluded from the install-test, and exempt from the version-bump check.
|
||||
|
||||
This page is the practical companion to [Creating a new chart](New-Chart): what to replace, in what order, and which traps the copy carries with it.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `4.1.0+upxxx` (the scaffold's own; meaningless for a copy) |
|
||||
| App version | `"xxx"` — placeholder image `ghcr.io/ORG/IMAGE:xxx`, which does not exist |
|
||||
| Workloads | a Deployment **and** a StatefulSet — the two shapes to choose between |
|
||||
| Storage | one RWO PVC `<release>-xxx-pvc` (optional for a copy) |
|
||||
| Ingress | yes, gated by `ingress.enabled`; `domain` + `clusterIssuer` required |
|
||||
| External dependencies | none |
|
||||
| Secrets | 1 × `OnePasswordItem` (placeholder field `secretKey`) |
|
||||
| Install-test | **excluded** (`ci/excluded-charts.txt`) |
|
||||
| Published | never — there is no `publish-template-chart.yml` |
|
||||
|
||||
## It is not installable
|
||||
|
||||
Three things keep an install of `template-chart` itself from becoming Ready, all of them on purpose:
|
||||
|
||||
- The image `ghcr.io/ORG/IMAGE:xxx` does not exist, so the pods stay in `ImagePullBackOff`. That is why the chart sits in `ci/excluded-charts.txt` ("scaffold with placeholder image `ghcr.io/ORG/IMAGE:xxx`, never published") and why no publish workflow exists for it.
|
||||
- Both a Deployment and a StatefulSet render. The Service selects the Deployment's pods, so a stateful app has to move that selector along with the workload it keeps.
|
||||
- The `env` block demonstrates the three supported forms and is an example, not a configuration: `ENV_FROM_CONFIGMAP` names `<release>-xxx-config-map` while the ConfigMap the chart creates is `<release>-xxx-configmap`.
|
||||
|
||||
What *does* work, and is the point of the scaffold, is rendering it:
|
||||
|
||||
```bash
|
||||
helm lint --strict template-chart
|
||||
helm template template-chart \
|
||||
--set secrets.xxxSecretRef=op://vault/item \
|
||||
--set ingress.domain=example.com \
|
||||
--set ingress.clusterIssuer=letsencrypt
|
||||
```
|
||||
|
||||
## What to replace, and in what order
|
||||
|
||||
1. **Copy the directory**: `cp -r template-chart <chart-name>`. Everything below happens inside the copy.
|
||||
2. **`Chart.yaml`** — `name` (this is the `xxx` in every rendered object name), `description`, `icon` (mandatory in every chart), `version` and `appVersion`. `appVersion` is the **exact image tag**; `version` is `<chart-semver>+up<appVersion>` with dashes normalized to dots ([Versioning](Versioning)). The scaffold's `4.1.0+upxxx` is its own version and carries nothing into the copy.
|
||||
3. **File names**: `templates/xxx.<kind>.yaml` → `templates/<app>.<kind>.yaml`. The `<app>` part is the chart name; the `<kind>` part is what the object is (`deployment`, `sfs`, `service`, `secret`, `configmap`, `ingress`, `pvc`).
|
||||
4. **Decide the workload shape** and delete the other file. Stateful apps take `<app>.sfs.yaml`; if you keep it, move the Service selector from `…-deployment` to `…-sfs`. Delete `xxx.pvc.yaml` if the app needs no volume, and `xxx.secret.yaml` plus the `secrets` block if it needs no 1Password item.
|
||||
5. **`values.yaml`**: rename the `xxx:` top-level key to the app, rename `secrets.xxxSecretRef`, and replace the placeholder `env`, `ports` and probe content with the app's real ones.
|
||||
6. **The templates**: every `.Values.xxx.*` reference, and every helper call `{{ include "xxx.<helper>" … }}`.
|
||||
7. **`templates/_helpers.tpl`**: the `xxx.` prefix in each `define` — the helpers are duplicated per chart precisely because the charts have no shared library — **and** the default blocks inside them, which must reproduce *your* chart's defaults, not the scaffold's.
|
||||
8. **`values.schema.json`**: both the values paths and the `xxx.` prefixes, then the content along the six points of [style guide §12](Chart-Style-Guide#12-values-that-must-not-lie).
|
||||
9. **The image** in the workload: replace host/org/image, keep the registry host — fully qualified on purpose, because an unqualified `registry/image` resolves to Docker Hub. Keep the tag as `{{ .Chart.AppVersion }}`.
|
||||
10. **The probes**: point them at a real health endpoint, and carry the probe's *context* into the fallback defaults in `_helpers.tpl`, not just its timings. A rebuilt default that loses a port name, a `Host` header or an exec command leaves the workload with a probe that cannot succeed.
|
||||
11. **`templates/NOTES.txt`**: delete the leading `{{/* … */}}` block (it explains the bar and renders nothing) and replace the body with what an operator of *your* app needs — the rendered access URL, **which fields the 1Password item must carry**, the options actually active in this release, and the chart's traps, all conditional on the values really set. No real domain anywhere; `example.com` where an example is unavoidable.
|
||||
12. **Check where `ingress.domain` is read.** In the scaffold it is read once outside the Ingress template (the `NORMAL_ENV` entry), which is why that read carries a `required()` of its own — otherwise switching the Ingress off would render an empty address silently. Decide this per chart instead of assuming either way.
|
||||
|
||||
Then wire up CI — the four steps that live outside the chart directory: a `publish-<chart>.yml` workflow, the chart name in the `change-app-version.yml` choice list, `ci/<chart>/` fixtures (or a justified line in `ci/excluded-charts.txt`), and a `customManagers` block in `renovate.json5`. Details in [Creating a new chart](New-Chart) and [Renovate](Renovate).
|
||||
|
||||
### Where the placeholder appears
|
||||
|
||||
| Placeholder | Where | Replace with |
|
||||
|---|---|---|
|
||||
| `name: xxx` | `Chart.yaml` | the chart name — it is `{{ .Chart.Name }}` in every object name |
|
||||
| `version: 4.1.0+upxxx`, `appVersion: "xxx"` | `Chart.yaml` | the real pair, per [Versioning](Versioning) |
|
||||
| `xxx.<kind>.yaml` | template file names | `<app>.<kind>.yaml` |
|
||||
| `xxx:` / `secrets.xxxSecretRef` | `values.yaml` | the app's values key and secret ref names |
|
||||
| `.Values.xxx.*` | all templates, `NOTES.txt` | the renamed values paths |
|
||||
| `"xxx.<helper>"` | `_helpers.tpl`, all templates | `"<chart>.<helper>"` |
|
||||
| `xxx` paths and prefixes | `values.schema.json` | both the values paths and the helper prefixes |
|
||||
| `ghcr.io/ORG/IMAGE` | `xxx.deployment.yaml`, `xxx.sfs.yaml` | the real registry/org/image |
|
||||
| `secretKey`, `example-setting: "value"`, `NORMAL_ENV`/`ENV_FROM_*` | `xxx.secret.yaml` consumers, `xxx.configmap.yaml`, `values.yaml` | the app's real secret fields, config keys and variables |
|
||||
|
||||
## Configurable values
|
||||
|
||||
The scaffold's values, as they render today. In a copy, `xxx` becomes the app's key.
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0` without uninstalling. Takes precedence over `xxx.replicas`. PVC, Secret, Service and Ingress stay. |
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.xxxSecretRef` | string | `null` | **Required.** `op://` path of the 1Password item; the operator materializes it as `<release>-xxx-secret`, with one Secret key per field. The scaffold reads the field `secretKey` (the `ENV_FROM_SECRET` entry) — list *your* chart's fields in `NOTES.txt`, it is the one piece of information otherwise only findable by reading the templates. |
|
||||
|
||||
### `xxx`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `xxx.replicas` | int | `1` | Replica count; an explicit `0` is allowed and is not swallowed, because the helper uses a `kindIs "invalid"` nil check rather than `default`. |
|
||||
| `xxx.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `xxx.resources.requests` | object | `cpu: 0.1`, `memory: 512Mi`, `ephemeral-storage: 10Mi` | |
|
||||
| `xxx.resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
| `xxx.livenessProbe` | object | `GET /` on port `http`, period 10s, timeout 5s, 3 failures | Placeholder — point it at a real endpoint. |
|
||||
| `xxx.readinessProbe` | object | `GET /` on port `http`, period 10s, timeout 5s, 3 failures | |
|
||||
| `xxx.startupProbe` | object | `GET /` on port `http`, period 5s, 30 failures (= 150s) | Gates liveness and readiness, so neither needs an `initialDelaySeconds`. |
|
||||
| `xxx.securityContext.pod` | object | non-root `20000:1000`, `fsGroup: 1000`, `fsGroupChangePolicy: Always`, `seccompProfile: RuntimeDefault` | Passed into the pod spec. The seccomp profile sits here on purpose: it covers every container including init containers. |
|
||||
| `xxx.securityContext.container` | object | `readOnlyRootFilesystem: true`, `capabilities.drop: [ALL]`, `add: []`, no privilege escalation, non-root | The baseline. An image-forced deviation MUST be documented with a comment in the chart's own `values.yaml`. |
|
||||
| `xxx.ports` | list | one entry: `containerPort: 8080`, `protocol: TCP`, `name: http` | Rendered verbatim as the container's `ports`. Unprivileged on purpose — see [Why 8080 and not 80](#why-8080-and-not-80). |
|
||||
| `xxx.env` | list | three example entries (`NORMAL_ENV`, `ENV_FROM_SECRET`, `ENV_FROM_CONFIGMAP`) | The three supported forms. Rendered through `tpl`. Replace the whole block rather than repairing the example. |
|
||||
|
||||
Probe, `securityContext` and `resources` blocks may be erased (`null`) — the chart then falls back to the defaults in `_helpers.tpl` instead of rendering an unhardened, unprobed or unaccounted container.
|
||||
|
||||
### `storage`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `storage.amount` | string | `10Gi` | Size of the PVC. |
|
||||
| `storage.storageClass` | string | `null` | Storage class of the PVC; `null` omits the field, so the cluster default applies. |
|
||||
|
||||
### `ingress`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | Gates the whole Ingress template. `false` also makes `clusterIssuer` unnecessary — but **not** `domain`, which the `env` block reads as well. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. |
|
||||
| `ingress.domain` | string | `null` | **Required** in the scaffold even with the Ingress off, because `NORMAL_ENV` reads it. Whether that holds in a copy depends on what the copy does with the value. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. Annotations are interpreted by the ingress controller, so a misspelled key is silently ignored — it does not fail the render. |
|
||||
|
||||
TLS is stored in `tls-<release>-xxx-ingress`.
|
||||
|
||||
## Traps the scaffold carries into the copy
|
||||
|
||||
### Why 8080 and not 80
|
||||
|
||||
Not because a non-root container cannot bind port 80 — it can: the kubelet sets `net.ipv4.ip_unprivileged_port_start=0` in the pod network namespace, and this was measured in k3d (k3s v1.35.5) with `runAsUser: 20000` and all capabilities dropped. Anyone who "fixes" it back to 80 and tests it once will find that it works, which is exactly why the reason is written down: that sysctl is a runtime **default, not a guarantee**, and a cluster that restores `ip_unprivileged_port_start=1024` breaks every workload that copied the 80 — with the failure appearing in the copied chart, not here. Every real chart in this repo listens above 1024; `samba` is the one exception and adds `NET_BIND_SERVICE` with a documented justification. Note also that `containerPort` is declarative: point the application at the same port.
|
||||
|
||||
### `value:` must be quoted
|
||||
|
||||
`value: {{ tpl $value.value $ | quote }}` — the `quote` is required, not cosmetic. A value like `"8080"` or `"true"` renders unquoted as a YAML number or bool, and the API server then rejects the whole manifest with `cannot unmarshal number into Go struct field EnvVar.value of type string`. Every chart in this repo quotes here.
|
||||
|
||||
### The `checksum/config` annotation is not decoration
|
||||
|
||||
Both workload templates carry `checksum/config` over the rendered ConfigMap, and it is mandatory ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)): a `subPath` mount is never refreshed by the kubelet and most apps read their config only at start, so without it a config change applies to nothing while everything stays green. The hash is content-based, so an unchanged config stays a no-op upgrade. One line per ConfigMap the workload consumes, with a distinguishing suffix where there are several. A chart that also wants a repointed secret to take effect adds the separately named `checksum/secret-ref` over the rendered `OnePasswordItem` — the scaffold does **not**, so in it neither editing the 1Password item nor repointing `secrets.xxxSecretRef` rolls the pods.
|
||||
|
||||
### The ConfigMap key is called `example-setting`
|
||||
|
||||
Not `key`. A ConfigMap key literally called `key` matches the patterns secret scanners use to find hardcoded credentials, so every copy of the older template inherited a false positive.
|
||||
|
||||
### Never wrap the helper includes in an `if`
|
||||
|
||||
Probes, resources and both security contexts go through the null-safe helpers. A guard around them hides the erased case completely instead of rendering a visible null — which is the failure mode the helpers exist to prevent: a workload that starts and reports healthy while being unhardened.
|
||||
|
||||
### The defaults exist twice, and must stay in sync
|
||||
|
||||
`values.yaml` is the documented, user-facing place for probe, hardening and resource defaults; the copies inside `_helpers.tpl` are only the fallback for an erased block. They have to say the same thing — and in a copy they have to say what *your* chart needs, not what the repo baseline says. Five charts deviate from the hardening baseline on purpose, and "cleaning up" such a default is not tidying but an outage ([style guide §12.4](Chart-Style-Guide#12-values-that-must-not-lie), [Lessons learned](Lessons-Learned)).
|
||||
|
||||
### Check the PVC claim before copying its wording
|
||||
|
||||
The scaffold's `NOTES.txt` states that `helm uninstall` does not delete the PVC. The template carries no `helm.sh/resource-policy: keep` annotation, and `satisfactory`'s `NOTES.txt` says the opposite about the same construction. Verify the behaviour for your chart before repeating either sentence — an operator plans a backup around it.
|
||||
|
||||
## CI
|
||||
|
||||
- **Excluded from the install-test**, with the justification recorded in `ci/excluded-charts.txt`. An excluded chart is dropped from the matrix rather than skipped inside a job, so nothing ever reports a green install for it; the PR's job summary lists it with that justification instead ([CI/CD](CI-CD)).
|
||||
- **Exempt from the chart-version bump check** in `validate-charts.yml` — the scaffold's version means nothing, so changing it is not required to change the files.
|
||||
- **Still linted and packaged** like every other chart when a PR touches it, and **always** covered by the resource-quantity render test, which runs over every chart on every PR precisely because the resources helper travels by copy: a regression arrives in a new chart started from an older scaffold, and in that PR the copied bug is in no diff anyone reads.
|
||||
- **Outside Renovate's reach**: `renovate.json5` sets `ignorePaths: ["template-chart/**"]`, because the image is a literal placeholder.
|
||||
- **Never published**: no `.github/workflows/publish-template-chart.yml` exists, so no push to `main` uploads it to the registry.
|
||||
|
||||
## See also
|
||||
|
||||
- [Creating a new chart](New-Chart) — the surrounding procedure, including the CI wiring
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart is the reference implementation of
|
||||
- [Versioning](Versioning) — the `+up` scheme a new chart adopts
|
||||
- [Renovate](Renovate) — the `customManagers` block a new chart needs
|
||||
- [Chart index](Charts)
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
# urlaubsverwaltung
|
||||
|
||||
Deploys [Urlaubsverwaltung](https://urlaubsverwaltung.cloud/), a leave and absence manager for teams, as a single-replica Deployment. It is a Spring Boot application: the database and the OpenID Connect provider are **not** part of this chart, and the release owns no volume at all.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `9.0.2+up6.12.0` |
|
||||
| App version | `6.12.0` (image `docker.io/urlaubsverwaltung/urlaubsverwaltung`) |
|
||||
| Workloads | Deployment (1 replica) |
|
||||
| Storage | none — `/tmp` is an emptyDir, all state is in the external database |
|
||||
| Ingress | yes, `ingress.domain` + `ingress.clusterIssuer` required while enabled |
|
||||
| External dependencies | a database (JDBC), an OIDC provider |
|
||||
| Secrets | 2 × `OnePasswordItem` |
|
||||
| Install-test | `ci/urlaubsverwaltung/` (fixtures + test values, `INSTALL_TIMEOUT=600`) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **1Password operator** in the cluster, plus two 1Password items — see [the fields below](#what-the-1password-items-must-contain).
|
||||
- **A database reachable before the first start.** Liquibase migrates the schema on every boot, before the HTTP port opens; against an empty database it creates the schema and seeds settings, vacation and sick-note types.
|
||||
- **An OIDC provider that is up at rollout time.** The application resolves `<issuer-uri>/.well-known/openid-configuration` while building its security beans — a provider that does not answer keeps the pod from starting at all. See [The identity provider is required at boot](#the-identity-provider-is-required-at-boot-not-at-login).
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller.
|
||||
- **A registered redirect URI** at the provider: this chart registers the client under the id `default`, so the path is `/login/oauth2/code/default`, e.g. `https://urlaub.example.com/login/oauth2/code/default`.
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install urlaubsverwaltung jlab/urlaubsverwaltung \
|
||||
--namespace urlaubsverwaltung --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
databaseSecretRef: op://vault/uv-db
|
||||
oidcSecretRef: op://vault/uv-oidc
|
||||
|
||||
ingress:
|
||||
domain: urlaub.example.com
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set. Read it — it is the per-release version of this page. Read it **at install time** in particular, because two of the overrides it describes only work then (see [Environment variables](#urlaubsverwaltungenv-is-additive-only-and-a-collision-is-lost-on-upgrade)).
|
||||
|
||||
### What the 1Password items must contain
|
||||
|
||||
The chart renders `OnePasswordItem` CRs; the operator turns each into a Kubernetes Secret whose **keys are the field names of the 1Password item**. The field names are decided by this chart and appear nowhere in 1Password. A missing or misspelled field does not fail the install: the Secret exists, looks as if the operator had done its job, and the pod hangs in `CreateContainerConfigError` naming the key it could not find.
|
||||
|
||||
| Values key | Kubernetes Secret | Fields |
|
||||
|---|---|---|
|
||||
| `secrets.databaseSecretRef` | `<release>-urlaubsverwaltung-db-secret` | `jdbc-url` (a full JDBC URL, e.g. `jdbc:postgresql://postgres.example.com:5432/urlaubsverwaltung`), `username`, `password` |
|
||||
| `secrets.oidcSecretRef` | `<release>-urlaubsverwaltung-oidc-secret` | `client-id`, `client-secret`, `issuer-name` (the provider's display name on the login button), `issuer-uri` (**without** `/.well-known/openid-configuration`), `jwt-issuer-uri` (the same issuer for the resource-server side), `redirect-uri` (the full callback URL, path `/login/oauth2/code/default`) |
|
||||
|
||||
What the Secret actually delivered:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> get secret <release>-urlaubsverwaltung-db-secret -o jsonpath='{.data}'
|
||||
```
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `global` | object | unset | Helm's conventional cross-chart values. Unused by this chart; the schema keeps it open so an umbrella install cannot break on it. |
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0` without uninstalling. Takes precedence over `urlaubsverwaltung.replicas`. Both `OnePasswordItem` CRs, the Service and the Ingress stay, so the certificate is not re-issued. This chart owns no volume — scaling to zero loses nothing. |
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.databaseSecretRef` | string | `null` | **Required.** `op://` path of the database item. Not guarded by a `required` in the template: left unset the CR is created with an empty `itemPath`, the render and the install succeed, and the pod never starts. |
|
||||
| `secrets.oidcSecretRef` | string | `null` | **Required.** `op://` path of the OIDC item. Same failure mode. |
|
||||
|
||||
### `urlaubsverwaltung`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `urlaubsverwaltung.replicas` | int | `1` | Replica count; `0` is allowed. The chart-global `scaleDown` wins over it. |
|
||||
| `urlaubsverwaltung.securityContext.pod` | object | `runAsNonRoot: true`, `runAsUser: 1002`, `runAsGroup: 1001`, `seccompProfile: RuntimeDefault` | Passed verbatim into the pod spec. The uid/gid pair is the image's: it is built with Cloud Native Buildpacks and runs as the non-root `cnb` user. The seccomp profile sits here on purpose — from the pod context it covers every container including init containers. |
|
||||
| `urlaubsverwaltung.securityContext.container` | object | `allowPrivilegeEscalation: false`, `readOnlyRootFilesystem: true`, `runAsNonRoot: true`, all caps dropped | Passed verbatim into the container spec. The app writes only to `/tmp` (an emptyDir for the JVM's temp and Tomcat work files), so the root filesystem stays read-only. |
|
||||
| `urlaubsverwaltung.livenessProbe` | object | `GET /actuator/health/liveness:8080`, period 10s, timeout 5s, 3 failures | `livenessState` alone, deliberately without `db`. See [The three health paths](#the-three-health-paths-and-what-each-one-answers-for). |
|
||||
| `urlaubsverwaltung.readinessProbe` | object | `GET /actuator/health/readiness:8080`, period 5s, timeout 5s, 3 failures | `readinessState` **and** `db`. Raising `timeoutSeconds` past the JDBC timeout only swaps a probe timeout for a 503 and delays the reaction. |
|
||||
| `urlaubsverwaltung.startupProbe` | object | `GET /actuator/health/liveness:8080`, period 10s, timeout 5s, 30 failures (= 300s) | Gates liveness and readiness. The budget covers JVM start plus the Liquibase migration; a measured cold start against an empty database took about 30 seconds. |
|
||||
| `urlaubsverwaltung.env` | list | `[]` | Extra environment variables appended to the chart's own. Rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. **Additive, not overriding** — see [Environment variables](Chart-Style-Guide#10-environment-variables) and the section below. |
|
||||
| `urlaubsverwaltung.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. Here that factor sizes the **Java heap** — see [The memory limit sizes the JVM heap](#the-memory-limit-sizes-the-java-heap--it-is-not-just-a-ceiling). |
|
||||
| `urlaubsverwaltung.resources.requests` | object | `cpu: 0.1`, `memory: 768Mi`, `ephemeralStorage: 10Mi` | |
|
||||
| `urlaubsverwaltung.resources.limits` | object | unset | An explicit limit always wins over the computed one. A CPU limit is only rendered when set here. |
|
||||
|
||||
The whole `resources` and `securityContext` blocks, and each probe, may also be `null` or empty: the chart then falls back to exactly the defaults above rather than rendering a container without resource accounting or hardening ([style guide §12](Chart-Style-Guide#12-values-that-must-not-lie)).
|
||||
|
||||
### `ingress`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | Gates the whole Ingress template. Unlike some other charts here, **`ingress.domain` is read nowhere else** — no environment variable of the container derives from it, so switching the Ingress off makes both `domain` and `clusterIssuer` unnecessary. |
|
||||
| `ingress.domain` | string | `null` | **Required while `ingress.enabled` is true.** Host and TLS subject. The address the application considers its own is the OIDC `redirect-uri` out of the 1Password item, not this value — the two can drift apart without any complaint. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. |
|
||||
|
||||
TLS is stored in `tls-<release>-urlaubsverwaltung-ingress`. The Ingress publishes the app at `/` with no path restriction, so `/swagger-ui.html` and `/v3/api-docs` — enabled by default in this app version and not turned off by the chart — are served under the same host. Both sit behind the login (302 to the provider like every other path), so this is exposure of an API description, not of data; `springdoc.api-docs.enabled` / `springdoc.swagger-ui.enabled` can be added through `urlaubsverwaltung.env`.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### The three health paths and what each one answers for
|
||||
|
||||
The chart fixes two health settings in the deployment template rather than exposing them as values, because `urlaubsverwaltung.env` cannot reliably override them anyway (see below):
|
||||
|
||||
```
|
||||
MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE=readinessState,db
|
||||
MANAGEMENT_HEALTH_MAIL_ENABLED=false
|
||||
```
|
||||
|
||||
| Path | Contains | Used by |
|
||||
|---|---|---|
|
||||
| `/actuator/health` | everything the release runs: `db`, `diskSpace`, `ping`, `ssl` and the two lifecycle states. Not the mail indicator. | external monitoring — the only path that also fails on `diskSpace` or `ssl` |
|
||||
| `/actuator/health/readiness` | `readinessState` **and** `db`; 503 on a database outage | the readiness probe |
|
||||
| `/actuator/health/liveness` | `livenessState` alone, deliberately without `db`; stays 200 through an outage | the liveness and startup probes |
|
||||
|
||||
**This is the chart that produced the rule.** For two years it probed exactly these two split paths and still stayed 1/1 Ready through a total database outage (#218): Spring Boot creates the `readiness` group by itself, but that default group contains `readinessState` **alone** — the paths were right, the group was empty of anything external. Naming the members *replaces* the default, which is why `readinessState` is repeated; `db` is the datasource indicator's real name, read off the running endpoint. The general form is [style guide §12.3](Chart-Style-Guide#12-values-that-must-not-lie): whatever aggregates behind a probe path has to be read off the **running** endpoint, not off the path name.
|
||||
|
||||
The liveness group is deliberately left alone. Measured with the database scaled to zero underneath a running pod: readiness 503, liveness 200, pod 0/1 Ready, restart count 0, leaving Ready about 20 seconds after the database went away — and back to Ready by itself within about five seconds when the database returned, still with restart count 0. Nothing had to be rolled or deleted by hand. Adding `db` to the liveness group would put a crash loop on top of every outage.
|
||||
|
||||
How readiness fails is worth knowing before tuning it: the `db` indicator waits out the JDBC connection timeout first (measured at 30s against a Service with no endpoints), while `readinessProbe.timeoutSeconds` is 5 — so the kubelet logs `Readiness probe failed: ... context deadline exceeded` rather than a 503. The pod leaves Ready either way, and faster.
|
||||
|
||||
### What a Ready pod does and does not prove
|
||||
|
||||
- Ready now means the pod can reach the database, checked on every readiness period.
|
||||
- It does **not** mean the identity provider is reachable: OIDC is resolved once at startup and no health indicator watches it afterwards, so a provider that goes down after the rollout leaves a Ready pod on which nobody can log in.
|
||||
- It proves reachability, not correctness: the `db` indicator validates a connection, never the schema. Measured here — a database that came back **empty** put the pod straight back to Ready with `/actuator/health` at 200 while not one table existed.
|
||||
- A dependency outage has a signature: `READY 0/1`, `STATUS Running`, `RESTARTS` unchanged. The reason is in the endpoint, not in the log. The aggregate answers `{"status":"DOWN","groups":[...]}` without naming components unless `MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS=always` is set via `urlaubsverwaltung.env` — which publishes the database product, filesystem paths and free bytes, certificate chains and stack-trace classes to anyone who can reach the endpoint.
|
||||
- **There is no shell in this image.** `kubectl exec` cannot open one, and a debug sidecar sharing the process namespace cannot run the app's tooling either. Everything you learn from a running pod, you learn from its log and its HTTP endpoints.
|
||||
|
||||
### The identity provider is required at boot, not at login
|
||||
|
||||
The application performs OIDC discovery itself, before the HTTP port opens. An `issuer-uri` that does not resolve, is unreachable from the namespace, or answers something other than a discovery document ends the process with `Application run failed ... Unable to resolve Configuration with the provided Issuer of "<issuer-uri>"` and the pod goes into `CrashLoopBackOff` having never served a request. The reason is only in the log:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> logs deployment/<release>-urlaubsverwaltung-deployment
|
||||
```
|
||||
|
||||
Note the order: the **database migration runs before this step**. A rollout that dies on the identity provider has already migrated the schema.
|
||||
|
||||
### The memory limit sizes the Java heap — it is not just a ceiling
|
||||
|
||||
The image's buildpack memory calculator reads the container's memory **limit** at every start and derives `-Xmx` and the metaspace from it. With the defaults that limit is computed, not set: `limitFactor` (3) × `requests.memory` (768Mi). Lowering it shrinks the heap, raising it raises the heap. A limit chosen too tightly does not merely risk an OOMKill — the calculator refuses to start when what is left after metaspace, code cache and thread stacks is negative, and that failure appears before any application log line. The sizing it chose is the first line of the log:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> logs deployment/<release>-urlaubsverwaltung-deployment | head -1
|
||||
```
|
||||
|
||||
### `urlaubsverwaltung.env` is additive only, and a collision is lost on upgrade
|
||||
|
||||
This is the chart on which the repo-wide rule was measured ([style guide §10](Chart-Style-Guide#10-environment-variables), also [Lessons learned](Lessons-Learned)). Entries are appended **after** the chart's own list. An entry repeating a name the chart already sets does not replace it — it renders the variable twice:
|
||||
|
||||
- **At install** both entries reach the pod and the later one — yours — wins in the container. The API server accepts it with `Warning: ... hides previous definition of "NAME", which may be dropped when using apply`.
|
||||
- **At upgrade** that warning comes true. Kubernetes merges `env` by name, the pair collapses to **one** entry, and the survivor is the **chart's** value. Nothing fails: helm prints "Release has been upgraded" and the revision counts up. Measured with helm 3.19.0 / Kubernetes 1.35: `helm get manifest` shows the variable twice while the live Deployment shows it once, with the chart's value.
|
||||
|
||||
So the trap is silence. Treat the chart's own variables as fixed for the life of a release — to really change one, reinstall — and never read a green upgrade as proof:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> get deployment <release>-urlaubsverwaltung-deployment \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].env}'
|
||||
```
|
||||
|
||||
Anything sensitive belongs in `valueFrom.secretKeyRef`: a plain `value` is stored in the pod spec and readable by anyone who can read pods in the namespace.
|
||||
|
||||
The names the chart occupies: `SPRING_DATASOURCE_*`, `MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE`, `MANAGEMENT_HEALTH_MAIL_ENABLED`, `UV_MAIL_*`, `SPRING_MAIL_HOST`, `SPRING_MAIL_PORT`, `UV_CALENDAR_ORGANIZER`, `SPRING_SECURITY_OAUTH2_*`, `UV_SECURITY_OIDC_CLAIM-MAPPERS_*`.
|
||||
|
||||
### Mail and the application URL are placeholders until you set them
|
||||
|
||||
The chart hardcodes the mail wiring to values that work nowhere: `SPRING_MAIL_HOST=localhost`, `SPRING_MAIL_PORT=1025`, `UV_MAIL_FROM` / `UV_MAIL_REPLYTO` = `urlaubsverwaltung@example.org`, `UV_CALENDAR_ORGANIZER=organizer@example.org`, `UV_MAIL_APPLICATIONURL=http://localhost:8080`. Nothing listens on `localhost:1025`, so no notification mail leaves the release, and `UV_MAIL_APPLICATIONURL` — which is **not** derived from `ingress.domain` — makes every link inside those mails point at the reader's own machine.
|
||||
|
||||
Because that wiring is a placeholder, the chart sets `MANAGEMENT_HEALTH_MAIL_ENABLED=false` and the mail indicator is gone from `/actuator/health`; left on, it pinned the aggregate at 503 DOWN for the whole life of a healthy release (#217) and any monitor pointed at it would page forever.
|
||||
|
||||
**The part that bites later:** the day real mail delivery is configured (`SPRING_MAIL_HOST` / `SPRING_MAIL_PORT` via `urlaubsverwaltung.env`), the indicator has to be switched back on in the **same change** —
|
||||
|
||||
```yaml
|
||||
urlaubsverwaltung:
|
||||
env:
|
||||
- name: MANAGEMENT_HEALTH_MAIL_ENABLED
|
||||
value: "true"
|
||||
```
|
||||
|
||||
Left at `false` it does not report a false alarm, it reports nothing: 200 while the mail server is unreachable and notifications are silently undelivered. And because that name repeats one the chart sets, the override only takes effect if it goes in **at install time** — added to a running release it does nothing and says so nowhere.
|
||||
|
||||
### Rotating a secret does not restart the pod
|
||||
|
||||
Helm never sees a `OnePasswordItem`'s content, so it cannot hash it ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)); this chart stamps no checksum over the secrets. Pointing a `*SecretRef` at a **different** item rolls the pod (the rendered CR changes); editing a value **inside** the same item does not. After such a rotation:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout restart deployment/<release>-urlaubsverwaltung-deployment
|
||||
```
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **Back up the database before every app-version bump.** Liquibase migrates in place on every start; `helm rollback` puts the previous image back, but the schema does not come back with it. There is no volume in this release to back up — the database is the whole of the state.
|
||||
- **Raise `urlaubsverwaltung.startupProbe.failureThreshold` before a long migration**, not after the crash loop has started: if the migration outruns the 300s budget the kubelet kills the container mid-migration and the next attempt starts over.
|
||||
- **A failed rollout does not take the release down** — the old pod keeps serving until the new one is Ready, so `helm upgrade` without `--wait` reports success while the new pod crashloops behind it. Check with `kubectl -n <namespace> rollout status deployment/<release>-urlaubsverwaltung-deployment`.
|
||||
- App **major** jumps go stepwise: last release of the old major line first, then the newest major — see [Versioning](Versioning).
|
||||
|
||||
## CI
|
||||
|
||||
`ci/urlaubsverwaltung/` holds `test-values.yaml` (`op://` refs against the stub CRD, host `uv.ci.local`, `clusterIssuer: ci`), `fixtures.yaml` (throwaway Postgres, a **dex** OIDC provider — the app performs discovery at boot, so a fixture provider is mandatory, plus the two dummy secrets) and `settings.env` with `INSTALL_TIMEOUT=600` for the JVM-plus-Liquibase start. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements, §10 and §12.3 of which it measured
|
||||
- [Lessons learned](Lessons-Learned) — the readiness-group and additive-env findings in general form
|
||||
- [Versioning](Versioning) — what a bump of `9.0.2+up6.12.0` means
|
||||
- [Renovate](Renovate) — how `appVersion` gets updated
|
||||
- [Chart index](Charts)
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
# voucher-vault
|
||||
|
||||
Deploys [VoucherVault](https://github.com/l4rm4nd/VoucherVault), a Django application for storing vouchers, gift cards and loyalty cards, as a single-replica StatefulSet with a local upload volume. Postgres, Redis and the OIDC provider are **not** part of this chart.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `6.1.1+up1.30.2` |
|
||||
| App version | `1.30.2` (image `docker.io/l4rm4nd/vouchervault`) |
|
||||
| Workloads | StatefulSet (1 replica) plus an `init-static` init container |
|
||||
| Storage | one RWO PVC mounted at `/opt/app/database`; three emptyDirs for the read-only root filesystem |
|
||||
| Ingress | yes, `ingress.domain` required **always**, `ingress.clusterIssuer` while enabled |
|
||||
| External dependencies | Postgres, Redis (Celery broker), an OIDC provider |
|
||||
| Secrets | 3 × `OnePasswordItem` |
|
||||
| Install-test | `ci/voucher-vault/` (fixtures + test values) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **1Password operator** in the cluster, plus three 1Password items — see [the fields below](#what-the-1password-items-must-contain).
|
||||
- **Postgres reachable before the first start.** The entrypoint loops on `nc -z $POSTGRES_HOST $POSTGRES_PORT` with no timeout, so a wrong host or port produces no error at all — see [A pod that never starts is usually waiting for Postgres](#a-pod-that-never-starts-is-usually-waiting-for-postgres).
|
||||
- **Redis** as the Celery broker. A wrong `redis-url` costs the expiry notifications and shows nothing else.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller.
|
||||
- **An OIDC client** whose redirect URI is `https://<ingress.domain>/oidc/callback/` — with the trailing slash, and without the language prefix every other path of the app carries. The sign algorithm is RS256, which is why a JWKS endpoint is a required field rather than an optional extra.
|
||||
- **A storage class that honours `fsGroup`.** The entrypoint exits 1 when `/opt/app/database` is not writable for uid/gid 33.
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install voucher-vault jlab/voucher-vault \
|
||||
--namespace voucher-vault --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
voucherVaultSecretRef: op://vault/voucher-vault
|
||||
oidcSecretRef: op://vault/voucher-vault-oidc
|
||||
databaseSecretRef: op://vault/voucher-vault-db
|
||||
|
||||
ingress:
|
||||
domain: vouchers.example.com
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set — and additionally warns when `voucherVault.env` has dropped a required entry. Read it — it is the per-release version of this page.
|
||||
|
||||
### What the 1Password items must contain
|
||||
|
||||
The chart renders three `OnePasswordItem` CRs unconditionally; each carries an item path only. The operator turns each into a Kubernetes Secret whose **keys are the field names of the 1Password item**, and those field names are decided by this chart — they appear nowhere in 1Password. A missing field does not fail the install: the operator writes a Secret without that key, and the pod then hangs in `CreateContainerConfigError` naming the key it could not find.
|
||||
|
||||
| Values key | Kubernetes Secret | Fields |
|
||||
|---|---|---|
|
||||
| `secrets.voucherVaultSecretRef` | `<release>-voucher-vault-secret` | `secretKey` (Django's `SECRET_KEY`; changing it logs everyone out), `redis-url` (Celery broker, e.g. `redis://:PASSWORD@redis:6379/0`) |
|
||||
| `secrets.databaseSecretRef` | `<release>-voucher-vault-db-secret` | `host`, `port`, `user`, `password`, `database` |
|
||||
| `secrets.oidcSecretRef` | `<release>-voucher-vault-oidc-secret` | `client-id`, `client-secret`, `authorization-endpoint`, `token-endpoint`, `user-endpoint`, `oidc-jwks-endpoint` |
|
||||
|
||||
The OIDC item is required **even if OIDC is turned off** via `voucherVault.env`: the CR is not gated, and the container references its keys.
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0` without uninstalling. Takes precedence over `voucherVault.replicas`. PVC, Secrets, Service and Ingress stay, so the certificate is not re-issued. |
|
||||
| `global` | object | unset | Helm's conventional cross-chart values. Unused by this chart, kept open in the schema so an umbrella install cannot break on it. |
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.voucherVaultSecretRef` | string | `null` | **Required.** `op://` path of the app item (`secretKey`, `redis-url`). |
|
||||
| `secrets.oidcSecretRef` | string | `null` | **Required**, unconditionally — the CR is rendered whether or not OIDC is used. `op://` path of the OIDC item. |
|
||||
| `secrets.databaseSecretRef` | string | `null` | **Required.** `op://` path of the Postgres item. |
|
||||
|
||||
### `voucherVault`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `voucherVault.replicas` | int | `1` | Replica count; `0` is allowed. See [Single-instance by construction](#single-instance-by-construction) before raising it. |
|
||||
| `voucherVault.ports` | list | `[{containerPort: 8000, protocol: TCP, name: http}]` | Rendered verbatim as the container's ports list. The name `http` is load-bearing: the Service and all three probes select it by name. |
|
||||
| `voucherVault.env` | list | the chart's full list (see [below](#vouchervaultenv-is-the-complete-environment-not-an-additive-block)) | **The container's complete environment, not an additive block.** A values file that sets it replaces the whole list, secret wiring included. Entries are rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. |
|
||||
| `voucherVault.storage.volumes` | list | one entry: `voucher-vault-data` → PVC `<release>-voucher-vault-pvc` | Volumes of the workload, rendered verbatim. `persistentVolumeClaim`, `configMap`, `secret` and `emptyDir` forms are supported; names and paths go through `tpl`. |
|
||||
| `voucherVault.storage.volumeMounts` | list | one entry: `voucher-vault-data` at `/opt/app/database`, `subPath: voucher-vault-data` | Mounts of the workload, rendered verbatim, in addition to the three emptyDirs the chart always mounts. |
|
||||
| `voucherVault.securityContext.pod` | object | non-root 33, `runAsGroup: 33`, `fsGroup: 33`, `fsGroupChangePolicy: Always`, `seccompProfile: RuntimeDefault` | Passed into the pod spec. uid/gid 33 is the image's `www-data`. The seccomp profile sits here on purpose: from the pod context it covers the init container too. |
|
||||
| `voucherVault.securityContext.container` | object | `readOnlyRootFilesystem: true`, `runAsNonRoot: true`, all caps dropped, no privilege escalation | Applied to **both** the init container and the app container. |
|
||||
| `voucherVault.livenessProbe` | object | `GET /:http` with header `Host: 127.0.0.1`, period 10s, timeout 5s, 3 failures | See [What a Ready pod does not prove](#what-a-ready-pod-does-not-prove). |
|
||||
| `voucherVault.readinessProbe` | object | `GET /:http` with header `Host: 127.0.0.1`, period 10s, timeout 5s, 3 failures | |
|
||||
| `voucherVault.startupProbe` | object | `GET /:http` with header `Host: 127.0.0.1`, period 5s, timeout 5s, 30 failures (= 150s) | Gates liveness and readiness. The budget is also the deadline for a Postgres that never answers. |
|
||||
| `voucherVault.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `voucherVault.resources.requests` | object | `cpu: 0.1`, `memory: 256Mi`, `ephemeral-storage: 128Mi` | The static-files emptyDir (~90Mi of assets) counts toward the pod's ephemeral-storage usage, so the request must cover it. |
|
||||
| `voucherVault.resources.limits` | object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
|
||||
The init container carries fixed resources of its own (requests `10m` / `16Mi` / `1Mi`, limits `200m` / `64Mi` / `10Mi`); they are not values.
|
||||
|
||||
Probes, security contexts and resources are null-safe: an erased block means "chart defaults apply", not "no probe" or "unhardened container".
|
||||
|
||||
### `storage`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `storage.amount` | string | `10Gi` | Size of the data PVC. |
|
||||
| `storage.storageClass` | string | `null` | Storage class of the data PVC; `null` uses the cluster default. |
|
||||
|
||||
Deliberately **not** null-safe: an erased `storage` block yields an invalid PVC rather than a silently degraded workload.
|
||||
|
||||
### `ingress`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | Gates the Ingress object. Switching it off makes `clusterIssuer` unnecessary — but **not** `domain`. |
|
||||
| `ingress.domain` | string | `null` | **Required, always** — also with `ingress.enabled: false`. It is the Ingress host *and* Django's `DOMAIN`: the only `ALLOWED_HOSTS` entry besides `127.0.0.1`, and the base of `CSRF_TRUSTED_ORIGINS`. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. |
|
||||
|
||||
TLS is stored in `tls-<release>-voucher-vault-ingress`.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### `voucherVault.env` is the complete environment, not an additive block
|
||||
|
||||
This is the chart's sharpest deviation from [style guide §10](Chart-Style-Guide#10-environment-variables). For this chart `voucherVault.env` is the container's **whole** env list: a values file that sets it overwrites everything, including the entries that wire up the three Secrets. `NOTES.txt` checks for `DOMAIN`, `SECRET_KEY`, `REDIS_URL`, `DB_ENGINE`, `POSTGRES_HOST` and `POSTGRES_DB` after every install or upgrade and prints which of them went missing — but the render itself succeeds, so read it. To change one entry, copy the full list out of the chart's `values.yaml` and edit the copy.
|
||||
|
||||
What the default list contains:
|
||||
|
||||
| Group | Entries |
|
||||
|---|---|
|
||||
| Host | `DOMAIN` (from `ingress.domain`, via `required`), `PORT=8000` |
|
||||
| Session | `SECURE_COOKIES=True`, `SESSION_COOKIE_AGE=30`, `SESSION_EXPIRE_AT_BROWSER_CLOSE=True` |
|
||||
| Expiry | `EXPIRY_THRESHOLD_DAYS=90`, `EXPIRY_LAST_NOTIFICATION_DAYS=7`, `TZ=UTC` |
|
||||
| Celery | `CELERY_WORKER_CONCURRENCY=1`, `CELERY_WORKER_PREFETCH_MULTIPLIER=1`, `REDIS_URL` (secret) |
|
||||
| OIDC | `OIDC_ENABLED=True`, `OIDC_AUTOLOGIN=True`, `OIDC_CREATE_USER=True`, `OIDC_RP_SIGN_ALGO=RS256`, plus six secret-backed endpoints/credentials |
|
||||
| Database | `DB_ENGINE=postgres`, `POSTGRES_HOST/PORT/USER/PASSWORD/DB` (secret) |
|
||||
| Django | `SECRET_KEY` (secret) |
|
||||
|
||||
`SESSION_COOKIE_AGE` is in **minutes**, not seconds — the app multiplies it by 60. `SECURE_COOKIES=True` means session and CSRF cookies carry the `Secure` flag; browsers accept those from `127.0.0.1` but from no other plain-http host, so a login that silently bounces back to the form over http is this, not a wrong password.
|
||||
|
||||
### `ingress.domain` is not an ingress-only value
|
||||
|
||||
Django's `ALLOWED_HOSTS` holds exactly `127.0.0.1` and this domain, and `CSRF_TRUSTED_ORIGINS` is built from it. Under any other name the app answers 400 before a single view runs. That is why the value stays required when the Ingress is switched off, and why a port-forward has to look exactly like this:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> port-forward svc/<release>-voucher-vault-service 8000:80
|
||||
```
|
||||
|
||||
Keep **8000** as the local port and open `http://127.0.0.1:8000/` — not `http://localhost:8000/`. `localhost` is not in `ALLOWED_HOSTS` and answers 400; `CSRF_TRUSTED_ORIGINS` holds exactly `http://127.0.0.1:8000`, so any other local port fails every POST including the login form.
|
||||
|
||||
### What a Ready pod does not prove
|
||||
|
||||
The probes send `Host: 127.0.0.1` on purpose: that host is always in `ALLOWED_HOSTS`, so probing survives every domain change. The price is that a **wrong `ingress.domain` is invisible to Kubernetes** — all three probes pass, the rollout goes green, and every real request gets 400. Check against the domain instead:
|
||||
|
||||
```bash
|
||||
curl -sI https://vouchers.example.com/ | head -1
|
||||
```
|
||||
|
||||
A 302 to a language-prefixed path is the healthy answer; 400 means the Host does not match.
|
||||
|
||||
Celery is the second blind spot: worker and beat are started detached from the same entrypoint, so uwsgi answers whether or not they came up.
|
||||
|
||||
### The first start prints the only admin password it will ever print
|
||||
|
||||
Against an empty database the entrypoint creates the superuser `admin` with a randomly generated password and writes it to stdout — once, never again, and into no Secret:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> logs <release>-voucher-vault-sfs-0 | grep -A3 "Creating superuser"
|
||||
```
|
||||
|
||||
If that line has already scrolled out of the log, reset it in place:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> exec -it <release>-voucher-vault-sfs-0 -- python manage.py changepassword admin
|
||||
```
|
||||
|
||||
With OIDC enabled the app drops its password-change pages and the login form redirects to the provider on its own (`OIDC_AUTOLOGIN`). Django's own admin login at `/admin/` is not covered by that redirect — that is the way back in when the provider is down or its item is wrong.
|
||||
|
||||
### A pod that never starts is usually waiting for Postgres
|
||||
|
||||
Before it opens the HTTP port, the entrypoint loops on `nc -z $POSTGRES_HOST $POSTGRES_PORT` with no timeout. A wrong host or port in the database item therefore produces no error at all — the log simply stops at
|
||||
|
||||
```
|
||||
[TASK] Waiting for PostgreSQL db...
|
||||
```
|
||||
|
||||
until the startup probe kills the container after 150s, which surfaces as `CrashLoopBackOff`.
|
||||
|
||||
The other candidate is one line earlier: `[FAIL] Insufficient permissions on bind mount volume`, when `/opt/app/database` is not writable for uid/gid 33. `fsGroup: 33` with `fsGroupChangePolicy: Always` covers that on any storage class that honours `fsGroup`.
|
||||
|
||||
### Read-only root filesystem, and the static-files init container
|
||||
|
||||
The image runs as `www-data` (uid/gid 33) and writes at runtime to `/tmp` (upload and PDF scratch files), `/opt/app/logs` and `STATIC_ROOT` (`/opt/app/myapp/static`, since the entrypoint runs `collectstatic` at every start). All three are emptyDirs, which is what makes `readOnlyRootFilesystem: true` possible.
|
||||
|
||||
`STATIC_ROOT` doubles as the app's own static **source** directory, so it cannot simply be shadowed by an empty volume. The `init-static` init container seeds the emptyDir with the assets shipped in the image (`cp -a` of the children, not of `.`, so `cp` never tries to set metadata on a mountpoint the non-root uid may not own; preserved timestamps mean `collectstatic` only adds what is missing, e.g. the Django admin assets).
|
||||
|
||||
Uploads and everything else durable live on the PVC at `/opt/app/database`.
|
||||
|
||||
### Single-instance by construction
|
||||
|
||||
Uploaded files land on the one **ReadWriteOnce** PVC `<release>-voucher-vault-pvc`, which every pod of the StatefulSet mounts at the same path — and each pod runs its own Celery beat. Raising `voucherVault.replicas` above 1 therefore buys nothing: further pods stay `Pending` on a node that does not hold the volume, and where they do start they send every expiry notification a second time.
|
||||
|
||||
### Expiry notifications stay silent until a user adds an Apprise URL
|
||||
|
||||
`EXPIRY_THRESHOLD_DAYS` and `EXPIRY_LAST_NOTIFICATION_DAYS` decide when an item counts as expiring, not whether anyone hears about it: delivery runs over the Apprise URLs each user stores in their own profile, and a profile without one is skipped without a word. The "Periodic Expiry Check" schedule runs daily at 09:00 in the container's time zone (`TZ`) and is created **only while an empty database is initialized** — delete it in the admin and no later start brings it back.
|
||||
|
||||
### Rotating a secret does not restart the pod
|
||||
|
||||
Helm never sees a `OnePasswordItem`'s content, so it cannot hash it ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)). Pointing a `*SecretRef` at a **different** item rolls the pods; editing a value **inside** the same item does not. After such a rotation:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout restart statefulset/<release>-voucher-vault-sfs
|
||||
```
|
||||
|
||||
Rotating `secretKey` logs every user out.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **Upgrades migrate, rollbacks do not unmigrate.** Every single start runs `makemigrations` and `migrate` before the server comes up, so an app-version bump changes the schema the moment the new pod boots. `helm rollback` puts the old image back but leaves the migrated schema in place, and the old code then meets columns it does not know.
|
||||
- **Back up both halves, together.** The rows live in Postgres, the uploads on the PVC. Because the PVC is a chart object, `helm uninstall` deletes it — the external database survives, which makes an accidental uninstall look recoverable while the uploads are already gone.
|
||||
- **Raise `voucherVault.startupProbe.failureThreshold` before a large migration**, not after the crash loop has started.
|
||||
|
||||
## CI
|
||||
|
||||
`ci/voucher-vault/test-values.yaml` holds the three `op://` refs (against the stub CRD) and `vv.ci.local` with issuer `ci`. `ci/voucher-vault/fixtures.yaml` stands up throwaway Postgres and Redis, a **dex** OIDC provider with a static client, and the three Secrets the 1Password operator would otherwise materialize — so the install-test exercises the full OIDC wiring, not just the render. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements, and the env-block deviation it carries
|
||||
- [Versioning](Versioning) — what a bump of `6.1.1+up1.30.2` means
|
||||
- [Renovate](Renovate) — how the app version is tracked
|
||||
- [Lessons learned](Lessons-Learned)
|
||||
- [Chart index](Charts)
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
# ytdlp-web-player
|
||||
|
||||
Deploys [ytdlp_web_player](https://github.com/matszwe02/ytdlp_web_player), a web front end that fetches and transcodes videos on demand, as a stateless Deployment. The video cache and the tool cache are emptyDirs — there is no PersistentVolume. **The only chart in this repo with a dependency**: an optional `oauth2-proxy` subchart, off by default, because the application ships no authentication at all.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `1.0.0+upv1.2.0` |
|
||||
| App version | `v1.2.0` (image `docker.io/matszwe02/ytdlp_web_player`) |
|
||||
| Workloads | Deployment (1 replica); plus the oauth2-proxy subchart's own when enabled |
|
||||
| Storage | none — two emptyDirs (`/data`, `/cache`) |
|
||||
| Ingress | yes, `ingress.domain` + `ingress.clusterIssuer` required **only while the Ingress is on** |
|
||||
| Dependencies | `oauth2-proxy` `10.7.0` (https://oauth2-proxy.github.io/manifests), `condition: oauth2-proxy.enabled`, off by default |
|
||||
| Secrets | 1 × `OnePasswordItem`, and only with the proxy enabled |
|
||||
| Install-test | `ci/ytdlp-web-player/` (test values only — no fixtures needed) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller.
|
||||
- **Egress to the internet**, including `github.com` — yt-dlp fetches a JavaScript challenge-solver library from there. Without egress the pod is Ready and every playback fails.
|
||||
- Only with the proxy enabled: **an OIDC provider** with a client application and a group/policy binding, the **1Password operator**, and one 1Password item — see [the fields below](#what-the-1password-item-must-contain).
|
||||
- A default install needs **no** 1Password item at all.
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install player jlab/ytdlp-web-player \
|
||||
--namespace ytdlp --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml` — and this one is **open to everyone who reaches the URL**:
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
domain: player.example.com
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
With the proxy, which is what a reachable install should look like:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
oauth2ProxySecretRef: op://vault/ytdlp-oauth2-proxy
|
||||
|
||||
ingress:
|
||||
enabled: false # the proxy owns the host from here on
|
||||
|
||||
oauth2-proxy:
|
||||
enabled: true
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts: ["player.example.com"]
|
||||
tls:
|
||||
- secretName: tls-player-example-com
|
||||
hosts: ["player.example.com"]
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt
|
||||
nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"
|
||||
config:
|
||||
configFile: |
|
||||
email_domains = ["*"]
|
||||
upstreams = ["http://{{ .Release.Name }}-ytdlp-web-player-service:80"]
|
||||
reverse_proxy = true
|
||||
session_cookie_minimal = true
|
||||
provider = "oidc"
|
||||
oidc_issuer_url = "https://sso.example.com/application/o/player/"
|
||||
redirect_url = "https://player.example.com/oauth2/callback"
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set — including the open-instance warning and the currently effective ephemeral-storage limit. Read it — it is the per-release version of this page.
|
||||
|
||||
### What the 1Password item must contain
|
||||
|
||||
The chart renders a `OnePasswordItem` CR; the operator turns it into a Kubernetes Secret whose **keys are the field names of the 1Password item**. A missing field does not fail the install: the release goes through and the proxy pod then sits in `CreateContainerConfigError`, which looks like a broken chart.
|
||||
|
||||
| Values key | Kubernetes Secret | Fields |
|
||||
|---|---|---|
|
||||
| `secrets.oauth2ProxySecretRef` | `<release>-ytdlp-web-player-oauth2-proxy-secret` | `client-id` (the OIDC client id), `client-secret`, `cookie-secret` (16, 24 or 32 bytes, **URL-safe** base64) |
|
||||
|
||||
Generate the cookie secret as
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32 | tr '+/' '-_'
|
||||
```
|
||||
|
||||
The `tr` is not cosmetic: oauth2-proxy decodes the value as URL-safe base64 **first**, so a standard-base64 value containing `+` or `/` fails to decode and the pod exits with `cookie_secret must be 16, 24, or 32 bytes`.
|
||||
|
||||
The CR is rendered **only** while the proxy is enabled *and* `oauth2-proxy.config.existingSecret` still names this chart's Secret. Point that value at a Secret you manage yourself and the chart creates nothing — your Secret then has to carry the same three keys.
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0` without uninstalling. Takes precedence over `ytdlpWebPlayer.replicas`. **Does not reach the subchart** — with the proxy on, set `oauth2-proxy.replicaCount: 0` as well, or the proxy keeps running and answers with a gateway error. |
|
||||
| `global` | object | unset | Helm's conventional cross-chart values. Unused by this chart, kept open — and with a subchart present this is no longer hypothetical: whatever is set here reaches oauth2-proxy too. |
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.oauth2ProxySecretRef` | string | `null` | **Required while `oauth2-proxy.enabled` is true and `oauth2-proxy.config.existingSecret` still names this chart's Secret.** Unused otherwise. |
|
||||
|
||||
### `ytdlpWebPlayer`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ytdlpWebPlayer.replicas` | int | `1` | Replica count; `0` is allowed. See [More than one replica shares nothing](#more-than-one-replica-shares-nothing). |
|
||||
| `ytdlpWebPlayer.securityContext.pod` | object | non-root `20000:1000`, `fsGroup: 1000`, `fsGroupChangePolicy: Always`, `seccompProfile: RuntimeDefault` | Merged onto the chart's pod hardening. See [Documented deviation: the uid](#documented-deviation-the-uid). The seccomp profile sits here so it covers every container including init containers. |
|
||||
| `ytdlpWebPlayer.securityContext.container` | object | `readOnlyRootFilesystem: true`, `runAsNonRoot: true`, all caps dropped, no privilege escalation | Merged onto the chart's container hardening. Everything the app writes goes to the two emptyDirs. |
|
||||
| `ytdlpWebPlayer.livenessProbe` | object | `GET /:http`, period 10s, timeout 5s, 3 failures | The app exposes no health endpoint; `/` is Flask's index route and touches neither ffmpeg nor the network. See [A Ready pod does not mean a video will play](#a-ready-pod-does-not-mean-a-video-will-play). |
|
||||
| `ytdlpWebPlayer.readinessProbe` | object | `GET /:http`, period 10s, timeout 5s, 3 failures | |
|
||||
| `ytdlpWebPlayer.startupProbe` | object | `GET /:http`, period 5s, 30 failures (= 150s) | Gates liveness and readiness. The app opened its port in about two seconds in k3d; the budget only has to cover a slow first process start. |
|
||||
| `ytdlpWebPlayer.env` | list | `[]` | Extra environment variables **appended** to `DATA_PATH`, `PORT`, `HOME` and `XDG_CACHE_HOME`. Rendered through `tpl`; `value`, `secretKeyRef`, `configMapKeyRef` forms. This is where the application is configured — see [The app's twenty switches are not chart values](#the-apps-twenty-switches-are-not-chart-values). |
|
||||
| `ytdlpWebPlayer.ports` | list | one entry: `containerPort: 5000`, `protocol: TCP`, `name: http` | Rendered verbatim as the container's port list. The entry **named `http`** is load-bearing: its number becomes `PORT`, the Service target and every probe target. A list without it fails the render with that message. |
|
||||
| `ytdlpWebPlayer.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `ytdlpWebPlayer.resources.requests` | object | `cpu: 0.5`, `memory: 512Mi`, `ephemeralStorage: 2Gi` | The ephemeral-storage number is the interesting one, not memory — 2Gi request yields a 6Gi limit. See [The video cache is an eviction risk](#the-video-cache-is-an-eviction-risk-not-a-disk). |
|
||||
| `ytdlpWebPlayer.resources.limits` | object | unset | An explicit limit always wins. **No CPU limit on purpose, and this is the chart where it matters most**: a CPU limit does not fail a request, it throttles ffmpeg, which arrives at the viewer as a stuttering stream with nothing in any log. |
|
||||
|
||||
### `storage`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `storage.dataSizeLimit` | string | `null` | Optional `sizeLimit` on the `/data` emptyDir. Null means the only ceiling is the container's ephemeral-storage limit. Hitting either has the same outcome — the kubelet evicts the pod — so this buys a clearer message, not a graceful failure. |
|
||||
| `storage.cacheSizeLimit` | string | `null` | Same for the `/cache` emptyDir (`HOME` and `XDG_CACHE_HOME`). |
|
||||
|
||||
### `ingress`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | Gates this chart's whole Ingress template. **Unlike `searxng`, it frees both `domain` and `clusterIssuer`** — nothing outside the Ingress template reads either: the app builds no absolute URLs and has no base-URL setting. |
|
||||
| `ingress.domain` | string | `null` | **Required while the Ingress is enabled.** Host. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. |
|
||||
|
||||
TLS is stored in `tls-<release>-ytdlp-web-player-ingress`.
|
||||
|
||||
### `oauth2-proxy` (subchart)
|
||||
|
||||
Everything under this key is passed to the oauth2-proxy chart, **not** read by this one — with three exceptions: `enabled` (the dependency condition) and `ingress.enabled` / `ingress.hosts` (the two-Ingress guard). The block stays fully open in `values.schema.json`: the chart owns neither the keys nor their meaning, and a closed copy would reject whatever upstream adds next release. Anything not listed here stays at the subchart's default.
|
||||
|
||||
Reaching the key from a template needs `index .Values "oauth2-proxy"` — `.Values.oauth2-proxy` parses as a subtraction. The `condition:` in `Chart.yaml` splits on dots only and needs no escaping.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `oauth2-proxy.enabled` | bool | `false` | The dependency condition. Off means nothing of the subchart renders and no proxy image is pulled — **and that the instance has no authentication**. |
|
||||
| `oauth2-proxy.ingress.enabled` | bool | `false` | With the proxy on, this is the Ingress that should be on and `ingress.enabled` the one that should be off. |
|
||||
| `oauth2-proxy.ingress.className` | string | `nginx` | Ingress class of the proxy's Ingress. |
|
||||
| `oauth2-proxy.ingress.hosts` | list | unset (commented example) | The single host the app would otherwise carry. Read by this chart's guard. |
|
||||
| `oauth2-proxy.ingress.tls` | list | unset (commented example) | The proxy owns its own TLS secret; this chart's `ingress.clusterIssuer` does not reach it. |
|
||||
| `oauth2-proxy.ingress.annotations` | object | `{}` | Where the cert-manager annotation and `nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"` belong. |
|
||||
| `oauth2-proxy.config.existingSecret` | string | `'{{ .Release.Name }}-ytdlp-web-player-oauth2-proxy-secret'` | The Secret with the three credentials. While it names this value, the chart renders the `OnePasswordItem` for it. The subchart runs the string through `tpl`, so `.Release.Name` resolves; `.Chart.Name` would **not** — inside the subchart that is `oauth2-proxy`, which is why the parent name is written out literally. |
|
||||
| `oauth2-proxy.config.configFile` | string | `email_domains = ["*"]`, `upstreams = ["http://{{ .Release.Name }}-ytdlp-web-player-service:80"]`, `reverse_proxy = true`, `session_cookie_minimal = true` | The proxy's whole configuration. **Add your provider settings here.** See [Three load-bearing lines in `configFile`](#three-load-bearing-lines-in-configfile). |
|
||||
| `oauth2-proxy.alphaConfig.enabled` | bool | `false` | **Leave this off.** With it on the subchart generates a configuration *without* upstreams and ignores `config.configFile` silently — the login succeeds and the app answers 404. It is off in the subchart's defaults too; it is spelled out here because switching it on looks like an upgrade. |
|
||||
|
||||
## Operating notes
|
||||
|
||||
### Without the proxy, this release is open to everyone who knows the URL
|
||||
|
||||
The application has **no authentication of any kind** and the chart adds none while `oauth2-proxy.enabled` is false. An anonymous visitor does not just read a page: they can make this pod fetch and transcode arbitrary videos from the internet — your egress, your CPU, your IP address in the logs of whatever site is being pulled from, and your ephemeral storage. Assume it is found: hosts get scanned, and certificate transparency logs publish every name cert-manager requests, so "nobody knows the URL" has a half-life of hours. `NOTES.txt` prints this warning whenever an Ingress is on and the proxy is off.
|
||||
|
||||
### Who owns the host
|
||||
|
||||
The proxy sits in the data path, so with `oauth2-proxy.enabled: true` the Ingress belongs to the **proxy** and this chart's `ingress.enabled` should be `false`; the app is then reachable only through its Service.
|
||||
|
||||
Leaving both on for the **same host** does not merge the routes — ingress-nginx's admission webhook rejects the second Ingress, which leaves a Fleet bundle in `ErrApplied` until `forceSyncGeneration` is bumped. The chart therefore **refuses to render that combination** and names the host in the failure. Leaving this chart's Ingress on for a **different** host is allowed and means exactly what it says: an unauthenticated way past the proxy. `NOTES.txt` warns about it.
|
||||
|
||||
### What the dependency costs
|
||||
|
||||
A dependency here is admissible, not free ([style guide §14](Chart-Style-Guide#14-chart-dependencies)). The bar it clears: the proxy would otherwise be a second Helm release that only exists to serve this one, upstream is the right owner of it, and it is optional and off by default. The price, all of it:
|
||||
|
||||
- **The subchart is invisible to everything downstream.** Its version is inside our packaged `.tgz`, so anything watching Helm release versions — Fleet's `fleet.yaml` included — sees only *our* chart version. A new oauth2-proxy release, a security fix included, reaches a cluster **only** when a new `ytdlp-web-player` version is published. Nothing reports the gap. Renovate's built-in `helmv3` manager opens the PR (no `customManagers` entry needed) and `ci/renovate-bump-chart.sh` bumps the chart patch that ships it; **that chain is the entire safety net**. The subchart version is independent of `appVersion` — raising it is a patch or minor bump of this chart.
|
||||
- **Upstream's chart is consumed as it comes.** We do not patch its templates, do not re-harden its workload and do not re-implement its values. Its resource limits, probes and security context follow **its** defaults, not this repo's; set them under `oauth2-proxy.resources` / `.securityContext` / `.livenessProbe` if you want this repo's shape.
|
||||
- **This repo's mechanisms stop at the subchart boundary.** `checksum/config`, the 1Password auto-restart annotation and `scaleDown` reach our templates only.
|
||||
- **Every pipeline needs a network fetch first.** `helm lint`, `package`, `template` and `install` all exit 1 while `charts/` is unfilled, so `ci/helm-dependencies.sh` runs before each — including the resource-quantity render test that covers every chart on every PR. A third-party Helm repository's bad day can therefore redden a PR that touched nothing related. `charts/*.tgz` and `Chart.lock` are not committed, which is why the pipelines run `helm dependency update` rather than `build`.
|
||||
- The subchart itself has a conditional dependency on `redis-ha` (off by default), which is why `helm dependency update` here pulls two repositories.
|
||||
|
||||
### Three load-bearing lines in `configFile`
|
||||
|
||||
Do not remove these while trimming the default:
|
||||
|
||||
| Line | What goes wrong without it |
|
||||
|---|---|
|
||||
| `reverse_proxy = true` | The proxy considers every request plain http and builds its redirect back to `http://` — behind a TLS-terminating Ingress that is a loop the moment a login completes. |
|
||||
| `session_cookie_minimal = true` | `id_token` and `access_token` go into the session cookie; a user with a longer group list pushes it past the browsers' 4 kB limit, the browser drops it, and the request comes back as **HTTP 502 after a successful login**, with `[AuthSuccess]` in the proxy's log. Incompatible with `pass_access_token`, `set_authorization_header` and `pass_authorization_header` — switch any of those on together with it and the proxy refuses to start. This app reads no identity headers at all. |
|
||||
| `nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"` (on the proxy's Ingress) | The other half of the same 4 kB trap: the response carrying the session cookie does not fit ingress-nginx's default 4k proxy buffer, and the browser gets a 502 after a successful login. |
|
||||
|
||||
`upstreams` is written in `config.configFile` rather than in the structured `config.upstreams` for one reason: the subchart emits `config.upstreams` with `toJson` and **without** `tpl`, so a `{{ .Release.Name }}` there ends up in the manifest verbatim. `config.configFile` is passed through `tpl`. A wrong upstream is not a render error — it is a 404 after a successful login, in production.
|
||||
|
||||
### The provider is the only gate, and it is wider than it looks
|
||||
|
||||
The default `email_domains = ["*"]` admits every identity the provider hands over. Which people that is, is decided **entirely** by the group or policy binding on the provider application, and this app has no second login behind the proxy. A forgotten or too-wide binding is not "one more user", it is every account the provider knows. Check that binding when you set this up.
|
||||
|
||||
### Repointing the proxy secret does not restart it
|
||||
|
||||
The `OnePasswordItem` is this chart's, but it is consumed by the **subchart's** pod template, which this chart cannot annotate — and Helm never sees a Secret's content anyway ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)):
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout restart deployment/<release>-oauth2-proxy
|
||||
```
|
||||
|
||||
The app's own Deployment carries **no** `checksum/config`, and nothing is missing: it consumes no ConfigMap and no Secret — it is configured purely through environment variables, which are part of the pod template already, so a changed value rolls the pods by itself.
|
||||
|
||||
### A Ready pod does not mean a video will play
|
||||
|
||||
All three probes request `/`. That is the only cheap path the app has, and it keeps a slow upstream site from restarting the pod. The price is that egress problems are invisible to Kubernetes: with DNS broken or outbound traffic blocked, `/` still answers 200, the rollout goes green, and every playback fails. A failing fetch shows up as a yt-dlp error line in the container log, never as a probe failure:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> logs deployment/<release>-ytdlp-web-player-deployment
|
||||
```
|
||||
|
||||
### The video cache is an eviction risk, not a disk
|
||||
|
||||
`DATA_PATH` is `/data`, backed by an emptyDir. Nothing survives a restart, a node drain, an eviction or a `helm upgrade`; every cached video is fetched again on demand. That is the intended shape — the app expires files itself after `MAX_VIDEO_AGE` — but do not treat it as storage.
|
||||
|
||||
An emptyDir counts against the container's **ephemeral-storage limit**. Going over it is not an application error: the kubelet evicts the whole pod, the reason sits on the pod rather than in any log, and everything in flight goes with it. Raising the limit is the wrong first move; the knobs that decide how much lands there are in `ytdlpWebPlayer.env`:
|
||||
|
||||
| Variable | Upstream default | What it decides |
|
||||
|---|---|---|
|
||||
| `MAX_VIDEO_DURATION` | `36000` | Ten hours — one enormous single file |
|
||||
| `MAX_VIDEO_AGE` | `3600` | Seconds a file survives after the last access |
|
||||
| `MAX_PROCESSES` | `5` | How many concurrent ffmpeg/yt-dlp runs can arrive at once |
|
||||
|
||||
### `/tmp` is not mounted, `/cache` is — both measured
|
||||
|
||||
Two runs of the image at uid 20000 with a read-only root filesystem, driven through metadata extraction, HLS transcoding, sprite generation and audio extraction:
|
||||
|
||||
- **`/tmp` is never written to.** Not once. The usual "read-only rootfs, so mount an emptyDir on `/tmp`" is dead weight for this image and deliberately absent.
|
||||
- **`$HOME/.cache` *is* written to.** Without it the log carries ten `OSError: [Errno 30] Read-only file system: '/.cache'` lines. Both bundled tools use it: deno for its module cache, yt-dlp for the JavaScript challenge-solver library it fetches from github.com. The failure is a **warning**, not an error — and what it costs is silent: that library is re-downloaded on every single extraction, which turns a github.com outage, or an egress rule that does not expect it, into broken playback with a warning nobody read.
|
||||
|
||||
`HOME` and `XDG_CACHE_HOME` are set to `/cache` rather than relying on a mount at `/.cache`, so the path does not depend on which uid the pod ends up running as.
|
||||
|
||||
### yt-dlp does not update itself here
|
||||
|
||||
On start and periodically the app runs `pip install --upgrade yt-dlp` into its own site-packages. With `readOnlyRootFilesystem: true` that write cannot happen; the app catches the failure and carries on with the version baked into the image (verified — it starts, serves and transcodes normally). That is the right trade in a cluster, but know the consequence: when a video site changes its extraction and playback breaks, waiting does not fix it. The fix arrives as a new app image, i.e. a new `appVersion` of this chart. There is no in-place update path.
|
||||
|
||||
### The app's twenty switches are not chart values
|
||||
|
||||
The upstream offers around twenty environment switches (`APP_TITLE`, `THEME_COLOR`, `GENERATE_SPRITE_BELOW`, `AMOLED_BG`, `MAX_VIDEO_AGE`, `MAX_VIDEO_DURATION`, `DEFAULT_QUALITY`, `MAX_QUALITY`, `AUTOPLAY`, `MIN_LIVE_BUFFER`, `ALWAYS_TRANSCODE`, `DISABLE_TRANSCODING`, `MAX_PROCESSES`, `AUTOSKIP_SB_SEGMENTS`, `COOKIES_ONLY_ON_FAILURE`, `PLAYLIST_SUPPORT`, `AUTO_BG_PLAYBACK`, `AUDIO_VISUALIZER`, `PROXY` as of v1.2.0). The chart mirrors none of them: a chart that rebuilds every option is a lie one upstream release later, and every one would have to be kept in `values.schema.json` too. `src/example.env` upstream is the authoritative list.
|
||||
|
||||
**Do not set `DATA_PATH`, `PORT`, `HOME` or `XDG_CACHE_HOME`** in `ytdlpWebPlayer.env`. The entries are appended, and Kubernetes applies the **last** entry of the list — a duplicate silently wins and points the app away from its writable volume, off the port the Service and all three probes use, or its caches back onto the read-only root filesystem.
|
||||
|
||||
### Documented deviation: the uid
|
||||
|
||||
The upstream image carries no `USER` instruction and runs as root by default (`python:3.13-alpine`, `apk add ffmpeg deno`, no `USER`). That is a default, not a requirement: measured, the container runs as an arbitrary uid with `readOnlyRootFilesystem: true` and the full hardening baseline, serving the UI and completing a real transcode. The chart therefore pins a non-root uid rather than following the image, and **the deviation is about the number only** — no part of the hardening baseline is relaxed. 20000 is arbitrary on purpose: nothing in the image belongs to it, `/app` is world-readable and read-only, and no PersistentVolume ties data to the number.
|
||||
|
||||
### More than one replica shares nothing
|
||||
|
||||
Each pod keeps its own `/data` emptyDir, so a video fetched by one is invisible to the other, and without session affinity the HLS segment requests of a single playback are spread across both — which looks like a stuttering or failing stream rather than an error. Pin clients to a pod at the ingress, or stay at one replica.
|
||||
|
||||
### Not measured
|
||||
|
||||
HLS playback **through** the proxy. Rendering and wiring are verified; a long stream through proxy and ingress is not. Three places to look first if playback stalls where a direct ingress works: proxy-buffering in ingress-nginx, its 60-second proxy-read/send timeouts, and the proxy's own upstream timeout. Browser extension and PWA are unverified too — an expired session answers with a redirect to the provider, not a 401, and a `fetch()` sees only an opaque redirect.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **Nothing to back up.** No PVC, no database, no persisted state; both volumes are caches refilled from the network. A rollback carries no data risk.
|
||||
- **Raising the subchart version is the only path a proxy update has.** Keep that line moving — see [What the dependency costs](#what-the-dependency-costs).
|
||||
- An app-version bump is the only way a yt-dlp fix reaches the cluster.
|
||||
|
||||
## What this chart deliberately does not do
|
||||
|
||||
No `cookies.txt`: videos that require a login on the source site do not work, and there is no value to make them work. No PersistentVolume. No mirror of the upstream's environment switches as chart values.
|
||||
|
||||
## CI
|
||||
|
||||
`ci/ytdlp-web-player/` holds only `test-values.yaml` — the app has no secrets, no PVC and no external dependency to fake, so there is no `fixtures.yaml`.
|
||||
|
||||
**The proxy stays off in CI, and that is not an oversight.** Two independent reasons, either one sufficient: enabling it would test somebody else's chart (what this repo owns is the *wiring*, and wiring is a render-time property checked by rendering, not by installing); and it could not pass anyway, because oauth2-proxy fetches its provider's OIDC discovery document at startup and never becomes Ready without one. There is no provider in the CI cluster, and standing one up is a test of the fixture, not of the chart.
|
||||
|
||||
Because the chart declares a dependency, `ci/helm-dependencies.sh` runs before lint, package, render and install — see [CI/CD](CI-CD).
|
||||
|
||||
## Renovate
|
||||
|
||||
Two blocks watch this chart. The built-in `helmv3` manager reads the `dependencies:` block and opens the subchart PR (labelled `subchart`); no `customManagers` entry is needed or would help, because the repository URL and the version sit in one file. A `customManagers` block tracks `appVersion` against `docker.io/matszwe02/ytdlp_web_player` with an anchored `versioning` regex: the registry carries four versioned tags and the two floating ones, `latest` and `stable`, **both of which move** — `stable` was republished at the same minute as `v1.2.0`. The anchors requiring a `v` prefix and three numeric parts are what keeps them out of the value that selects the image. The `v` itself stays outside every capture group: it carries no ordering, and a capture group is a claim about how a value is compared. See [Renovate](Renovate).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements, and §14 on dependencies
|
||||
- [Versioning](Versioning) — what a bump of `1.0.0+upv1.2.0` means
|
||||
- [Renovate](Renovate)
|
||||
- [Lessons learned](Lessons-Learned)
|
||||
- [Chart index](Charts)
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
# zipline
|
||||
|
||||
Deploys [Zipline](https://zipline.diced.sh/), a file- and image-sharing server, as a single-replica StatefulSet with one upload volume. Postgres is **not** part of this chart. Zipline is also the repo's reference case for a probe split that does not lie: liveness probes `/`, readiness and startup probe `/api/healthcheck`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chart version | `7.1.0+up4.7.0` |
|
||||
| App version | `4.7.0` (image `ghcr.io/diced/zipline`) |
|
||||
| Workloads | StatefulSet (1 replica) |
|
||||
| Storage | one RWO PVC, three `subPath` mounts (`uploads`, `public`, `themes`) + `/tmp` emptyDir |
|
||||
| Ingress | yes; `ingress.domain` required **always**, `ingress.clusterIssuer` only while the Ingress is enabled |
|
||||
| External dependencies | PostgreSQL |
|
||||
| Secrets | 2 × `OnePasswordItem` |
|
||||
| Install-test | `ci/zipline/` (fixtures + test values) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **1Password operator** in the cluster, plus two 1Password items — see [the fields below](#what-the-1password-items-must-contain).
|
||||
- **A reachable PostgreSQL server before the first start.** The chart deploys none. Readiness and startup hit `/api/healthcheck`, which checks the database, so a missing server shows up as a pod that never turns Ready — and, because liveness does not check it, as a pod that never restarts either.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller.
|
||||
- **A StorageClass** for `config.storageClass` (default `longhorn`), ideally one that supports volume expansion.
|
||||
|
||||
## Installation
|
||||
|
||||
The registry URL and its credentials live in 1Password (item `jlab-registry-access`: `username`, `password`, `charts-url`) and never in a repository or a shell history file.
|
||||
|
||||
```bash
|
||||
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
|
||||
helm repo update
|
||||
|
||||
helm install zipline jlab/zipline \
|
||||
--namespace zipline --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
Minimal `my-values.yaml`:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
databaseSecretRef: op://vault/zipline-db
|
||||
ziplineSecretRef: op://vault/zipline-core
|
||||
|
||||
ingress:
|
||||
domain: share.example.com
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
|
||||
|
||||
### What the 1Password items must contain
|
||||
|
||||
The chart renders two `OnePasswordItem` CRs; the operator turns each into a Kubernetes Secret whose **keys are the field names of the 1Password item**. A missing or misspelled field does not fail the install: the Secret exists, so the operator looks like it did its job, and the pod then hangs in `CreateContainerConfigError` naming the key it could not find.
|
||||
|
||||
| Values key | Kubernetes Secret | Fields |
|
||||
|---|---|---|
|
||||
| `secrets.databaseSecretRef` | `<release>-zipline-db-secret` | `connection-url` → `DATABASE_URL`, a full PostgreSQL URL, e.g. `postgresql://zipline:password@postgres.example.com:5432/zipline` |
|
||||
| `secrets.ziplineSecretRef` | `<release>-zipline-secret` | `server-secret` → `CORE_SECRET`, signs sessions and tokens (`openssl rand -base64 32`) |
|
||||
|
||||
What the operator actually delivered:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> get secret <release>-zipline-db-secret -o jsonpath='{.data}'
|
||||
```
|
||||
|
||||
## Configurable values
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `scaleDown` | bool | `false` | Renders every workload with `replicas: 0` without uninstalling. Takes precedence over `zipline.replicas`. PVC, both Secrets, Service and Ingress stay. |
|
||||
| `global` | object | unset | Helm's conventional cross-chart values. Unread by this chart; kept open in the schema so an umbrella install cannot break on it. |
|
||||
|
||||
### `secrets`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `secrets.databaseSecretRef` | string | `null` | **Required.** `op://` path of the Postgres item. |
|
||||
| `secrets.ziplineSecretRef` | string | `null` | **Required.** `op://` path of the app-secret item. |
|
||||
|
||||
### `config`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `config.storage` | string | `50Gi` | Size of the uploads PVC. **Required** — it has a default, but `required()` rejects an explicit `null`. Growing it only works on a StorageClass that allows expansion; shrinking is refused by Kubernetes and fails the next upgrade on the PVC. |
|
||||
| `config.storageClass` | string | `longhorn` | StorageClass of the uploads PVC. **Required**, same mechanics as above; this chart pins a class rather than falling back to the cluster default. |
|
||||
| `config.maxUploadFileSize` | string | `30m` | Rendered into the ingress-nginx `proxy-body-size` / `client-max-body-size` annotations, so it takes effect **only while the Ingress is enabled**. See [Two upload ceilings](#two-upload-ceilings-and-the-one-that-actually-bites). |
|
||||
|
||||
### `zipline`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `zipline.replicas` | int | `1` | Replica count; `0` is allowed. See [One replica, one volume](#one-replica-one-volume) before raising it. |
|
||||
| `zipline.securityContext.pod` | object | non-root `1000:1000`, `fsGroup: 1000`, `fsGroupChangePolicy: Always`, `seccompProfile: RuntimeDefault` | Passed verbatim into the pod spec. The image ships no dedicated user; the Node server runs fine as an arbitrary non-root uid, and `fsGroup` is what makes the uploads PVC writable. The seccomp profile sits at pod level on purpose: it covers every container including init containers. |
|
||||
| `zipline.securityContext.container` | object | `readOnlyRootFilesystem: true`, `runAsNonRoot: true`, all caps dropped, no privilege escalation | Passed verbatim into the container spec. |
|
||||
| `zipline.livenessProbe` | object | `GET /` on port `zipline-server`, period 10s, timeout 5s, 3 failures | Deliberately **not** the health endpoint — see below. |
|
||||
| `zipline.readinessProbe` | object | `GET /api/healthcheck`, period 10s, timeout 5s, 3 failures | Checks server **and** database. |
|
||||
| `zipline.startupProbe` | object | `GET /api/healthcheck`, period 10s, timeout 5s, 30 failures (= 300s) | Gates liveness and readiness. The budget covers the startup migrations. |
|
||||
| `zipline.env` | list | `[]` | Extra environment variables appended to the chart's own. Rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. **Additive, not overriding** — see [Environment variables](Chart-Style-Guide#10-environment-variables). |
|
||||
| `zipline.resources.limitFactor` | number | `3` | Memory and ephemeral-storage limits are computed as factor × request. |
|
||||
| `zipline.resources.requests` | object | `cpu: 0.1`, `memory: 265Mi`, `ephemeralStorage: 100Mi` | |
|
||||
| `zipline.resources.limits` | object | unset (computed: `memory: 795Mi`, `ephemeral-storage: 300Mi`) | An explicit limit always wins. A CPU limit is only rendered when set here. |
|
||||
|
||||
An erased block (`zipline.securityContext:` with no children, a `null` probe, a `null` `resources`) falls back to the chart's own defaults instead of disappearing — [style guide §12.2](Chart-Style-Guide#12-values-that-must-not-lie). `config` is deliberately *not* null-safe: its two required keys fail loudly.
|
||||
|
||||
### `ingress`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | `false` omits the Ingress and frees `clusterIssuer` — but **not** `domain`. |
|
||||
| `ingress.domain` | string | `null` | **Required, always.** Ingress host *and* `CORE_DEFAULT_DOMAIN`, the host Zipline puts into every share link it hands out. An empty value would hand out unusable links silently, so the StatefulSet carries its own `required()` ([style guide §12.1](Chart-Style-Guide#12-values-that-must-not-lie)). |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager and body-size annotations the chart always sets. |
|
||||
|
||||
TLS is stored in `tls-<release>-zipline-ingress`.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### Liveness does not check the database — on purpose
|
||||
|
||||
This is the positive example of [style guide §12.3](Chart-Style-Guide#12-values-that-must-not-lie). `/api/healthcheck` checks the server *and* the database, so it belongs on readiness and startup; liveness uses `/`, so a database outage cannot restart the pod. Do not "harmonize" the three paths.
|
||||
|
||||
Know the resulting picture before you meet it: during a database outage the container keeps running and stays quiet, the pod goes **NotReady**, its endpoint is dropped and the Ingress answers **503** — while the pod's log and its restart count say nothing at all. Look at the database first.
|
||||
|
||||
The converse also holds: a Ready pod here really does mean "server up, database answering", which is more than most readiness probes prove.
|
||||
|
||||
### The first visitor chooses the administrator
|
||||
|
||||
Against an empty database Zipline has no users and sends every visitor to its setup page to name the super administrator and set its password; the endpoint refuses to run a second time once an account exists. **Whoever reaches the URL first owns the instance.** Do the setup immediately after the first rollout, not the next morning. Against a database that already carries a Zipline schema the step is skipped and the normal login appears.
|
||||
|
||||
### Startup runs the database migrations
|
||||
|
||||
Zipline migrates its schema on start, before serving anything. The startup probe covers that window (300s by default) and keeps liveness and readiness off until it is through; if a migration needs longer, the kubelet kills the container and the next attempt starts the migration over. A failed migration looks like `CrashLoopBackOff` and only the log says why:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> logs statefulset/<release>-zipline-sfs
|
||||
```
|
||||
|
||||
Raise `zipline.startupProbe.failureThreshold` *before* a large jump, not after the crash loop has started.
|
||||
|
||||
### Two upload ceilings, and the one that actually bites
|
||||
|
||||
The app-side limit is pinned by the chart at `UPLOADER_ADMIN_LIMIT=500mb` and is **not exposed as a value**. In front of it the Ingress caps the request body at `config.maxUploadFileSize` (`30m`), which is therefore the ceiling users meet: anything larger is rejected by ingress-nginx with a 413 before Zipline sees a byte, so the error page comes from the proxy and not from the app. Raise `config.maxUploadFileSize` to move it. With `ingress.enabled: false` the value is rendered nowhere — whatever proxy sits in front needs its own body-size limit.
|
||||
|
||||
### Chunks go through `/tmp`, and `/tmp` is accounted for
|
||||
|
||||
The root filesystem is read-only, so `/tmp` is an emptyDir — and an emptyDir counts against the container's ephemeral-storage limit (`300Mi` with the defaults). An upload that pushes it over does not fail as a request: the kubelet **evicts the whole pod**, and the reason is on the pod, not in Zipline's log. Where uploads approach the 500mb the app allows, raise `zipline.resources.requests.ephemeralStorage`; the limit follows through `limitFactor`.
|
||||
|
||||
### One replica, one volume
|
||||
|
||||
`uploads`, `public` and `themes` share a single **ReadWriteOnce** PVC, `<release>-zipline-vc`, each on its own `subPath`. It is a plain chart object, not a `volumeClaimTemplate`, so every replica would mount the same volume and the extra pods stay stuck before start. This chart is built for one replica.
|
||||
|
||||
The `-vc` suffix is a documented exception from the `-<kind>` naming scheme: renaming the PVC would provision a new, empty volume ([style guide §1](Chart-Style-Guide#1-naming)).
|
||||
|
||||
### Rotating a secret does not restart the pod
|
||||
|
||||
Helm never sees a `OnePasswordItem`'s content, so it cannot hash it ([style guide §9](Chart-Style-Guide#9-config-changes-must-restart-the-workload)). Pointing a `*SecretRef` at a **different** item rolls the pod; editing a value **inside** the same item does not:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> rollout restart statefulset/<release>-zipline-sfs
|
||||
```
|
||||
|
||||
A new `CORE_SECRET` logs every user out.
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **Back up the database before every app-version bump.** Migrations run in place and are one-way; `helm rollback` returns the old image to a schema the new one has already rewritten.
|
||||
- **Backup is two things at once.** The database holds users, metadata and one row per file; the PVC holds the bytes. Snapshot them together.
|
||||
- **The PVC belongs to this release.** It carries no resource-policy annotation, so `helm uninstall <release>` deletes it and, with a `Delete` reclaim policy, every uploaded file goes with it. The external database survives, which makes an accidental uninstall look recoverable while the files are already gone. Adding the annotation to the live PVC afterwards does not help — Helm reads it from the release manifest.
|
||||
- Growing `config.storage` needs a StorageClass that allows volume expansion; shrinking is refused and fails the next `helm upgrade` on the PVC instead of touching data.
|
||||
|
||||
## CI
|
||||
|
||||
`ci/zipline/test-values.yaml` pins `storageClass: local-path`, `1Gi`, the host `zipline.ci.local` and two `op://` paths against the stub CRD. `ci/zipline/fixtures.yaml` supplies the two Kubernetes Secrets the 1Password operator would otherwise materialize, plus a throwaway Postgres 16 Deployment and Service. Because startup and readiness check the database, a green install-test here means the app really connected. The test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — §12.3, whose reference implementation this chart is
|
||||
- [Versioning](Versioning) — what a bump of `7.1.0+up4.7.0` means
|
||||
- [Lessons learned](Lessons-Learned)
|
||||
- [Chart index](Charts)
|
||||
Reference in new issue
Block a user