required() for the four mandatory OnePasswordItem references (#18)

Chart pages: calibre-web-automated 7.0.0, minecraft-server 6.0.0 and
urlaubsverwaltung 10.0.0 now guard their secret references, so the values
tables no longer say "not guarded by a required in the template". searxng
1.0.6 is a version snapshot only - its valkey reference was already correctly
optional and stays that way.

Style guide 12: the bullet on how null reaches the schema was wrong in the
case it was being applied to. A null chart default is NOT coalesced away; only
a user null over a non-null default is. Added the rule that follows from it -
a value with no valid default cannot be enforced by values-schema at all while
helm lint --strict has to pass on the defaults, so required() is the whole
enforcement and a required array would be a guard that cannot fire.

Lessons learned: both halves of that, since a safeguard that looks like one
and never fires is the same failure shape as the missing guard it replaced.
jklein committed 2026-09-22 20:06:08 +00:00
1 parent 313ec47607
commit d19d0bd89f
6 files changed
+31 -16

No files matched your search

+7 -1
@@ -172,7 +172,13 @@ Two mechanics come with it: Go's `not` treats **every non-empty string as true**
### 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.
- **`null` reaches the schema in two cases out of three, and which one you are in depends on the chart's default, not on what the user wrote.** Measured on Helm 3.22.0 with a probe chart, because the wording here was wrong before and was being applied to exactly the case it got wrong:
- a user `null` over a **non-null** chart default (`--set x=null`, or `x: null` in a values file) is treated as a deletion and the key is removed *before* validation — the schema never sees it, and only the helper can catch it;
- a **`null` chart default** survives into validation, with or without a user `null` on top of it, because there is nothing to delete it against: the schema sees the key present with value `null`;
- a key **without any default** (e.g. `resources.limits`) likewise survives, and a strict `"type": "object"` aborts the render.
That is why the helper **and** the widened `["object", "null"]` / `["string", "null"]` types are both needed. Everything else stays strictly typed.
- **A value that has no valid default cannot be enforced by `values.schema.json` at all — `required()` is the whole enforcement.** The key ships as `null`, so it is *present* at validation time, and JSON Schema's `required` tests presence and not null-ness: the array never fires, under any input. Both ways of making it fire break the rule that `helm lint --strict` passes on chart defaults, because lint validates those defaults — dropping the `null` default gives `missing property '<key>'`, narrowing `["string", "null"]` to `"string"` gives `got null, want string`, each a red pipeline on a chart nobody touched. So such a key is guarded with `required()` in the template and **described** as required in the schema, and carries no `required` array. See [Lessons learned](Lessons-Learned#a-guard-that-never-fires-still-looks-like-a-guard).
- **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
+9
@@ -130,10 +130,19 @@ Kubernetes cannot order two regular containers, and an init container waiting fo
**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.
## A guard that never fires still looks like a guard
Five `OnePasswordItem` templates rendered `itemPath: {{ .Values.secrets.<x>Ref }}` without a `required()`. Unset, the CR is created with an **empty** `itemPath`: `helm install` succeeds, the object exists, and nothing reports a problem. The operator has nothing to resolve, the Kubernetes Secret never appears, and the failure arrives minutes later in the pod — `CreateContainerConfigError`, or a mount that fails with `couldn't find key ...` so the container is never created at all. Four of the five were not optional; the fifth, searxng's valkey reference, was correctly optional and gated with `{{- if }}`, which is the other half of the rule.
The second finding came out of fixing the first, and is the more general one. The plan was to back each guard with a `required` array in `values.schema.json`. Measured on Helm 3.22.0 with a probe chart, that array **could never fire**: these keys ship with `null` as their default, so the key is *present* at validation time, and JSON Schema's `required` tests presence, not null-ness. Only a user `null` written over a **non-null** default is coalesced away — a `null` default is not. And both ways of making the array fire (dropping the default, or narrowing `["string", "null"]` to `"string"`) turn `helm lint --strict` red on the chart's own defaults, which the pipeline runs.
**Now:** a value with no valid default is enforced by `required()` in the template, with a message naming the values path, and **described** as required in the schema — never by a `required` array, which would be a safeguard that cannot fire, dressed as one that can. An optional reference says so in `values.yaml` *and* in the schema description, and its template renders nothing when the value is empty. See [§12](Chart-Style-Guide#12-values-that-must-not-lie).
## 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.
- **A safeguard is only a safeguard if it can fail.** Both the missing `required()` and the `required` array that replaced it read as protection; neither could ever have fired.
- **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.
+4 -4
@@ -4,7 +4,7 @@ Deploys [Calibre-Web Automated](https://github.com/crocodilestick/Calibre-Web-Au
| | |
|---|---|
| Chart version | `6.1.0+upv4.0.6` |
| Chart version | `7.0.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` |
@@ -60,7 +60,7 @@ The chart renders one `OnePasswordItem`; the operator turns it into a Kubernetes
|---|---|---|
| `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.
**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. `secrets.cwaSecretRef` itself is guarded with `required()` since chart 7.0.0: left unset, the render aborts. Until then it produced a CR with an empty `itemPath` — the operator had nothing to resolve, the Secret never appeared and the pod could not start, with nothing in the install output saying so.
## Configurable values
@@ -75,7 +75,7 @@ The chart renders one `OnePasswordItem`; the operator turns it into a Kubernetes
| 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. |
| `secrets.cwaSecretRef` | string | `null` | **Required**, guarded by `required()` since 7.0.0 — unset, the render aborts with `A 1Password item reference must be provided (secrets.cwaSecretRef)`. `op://` path of the item carrying `hardcover-api-token` and `calibre-plugins`. |
### `cwa`
@@ -185,6 +185,6 @@ kubectl -n <namespace> logs <release>-calibre-web-automated-sfs-0 -f
- [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
- [Versioning](Versioning) — what a bump of `7.0.0+upv4.0.6` means
- [Renovate](Renovate) — how `appVersion` gets updated
- [Chart index](Charts)
+5 -5
@@ -4,7 +4,7 @@ Deploys a Minecraft server ([itzg/minecraft-server](https://github.com/itzg/dock
| | |
|---|---|
| Chart version | `5.0.0+up2026.8.2.java25` |
| Chart version | `6.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`) |
@@ -56,7 +56,7 @@ The chart renders a `OnePasswordItem` CR; the operator turns it into a Kubernete
|---|---|---|
| `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:
With both features off the item may be empty — but the values key is still required. Since chart 6.0.0 `required()` enforces that: unset, the render aborts instead of producing a CR with an `itemPath` of `null`. What the operator actually delivered:
```bash
kubectl -n <namespace> get secret <release>-minecraft-server-secret -o jsonpath='{.data}'
@@ -83,7 +83,7 @@ The whole `image` block may be `null`; the tag then falls back to `appVersion`,
| 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. |
| `secrets.minecraftServerRef` | string | `null` | **Required, always** — the CR is rendered unconditionally, and `required()` enforces the value since 6.0.0 (the render aborts, naming the key). `op://` item path; the item needs fields only for the features actually switched on. |
### `server`
@@ -209,7 +209,7 @@ 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 called `tagOverride`, not `tag`, because it **decouples the running image from the chart version**: `helm list` then reports `6.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.
@@ -326,7 +326,7 @@ 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
- [Versioning](Versioning) — what a bump of `6.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)
+2 -2
@@ -4,7 +4,7 @@ Deploys [SearXNG](https://docs.searxng.org/), a metasearch engine, as a stateles
| | |
|---|---|
| Chart version | `1.0.5+up2026.9.13.3b8009065` |
| Chart version | `1.0.6+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`) |
@@ -221,7 +221,7 @@ 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
- [Versioning](Versioning) — what a bump of `1.0.6+up2026.9.13.3b8009065` means
- [Renovate](Renovate) — why this chart carries a `versioning` override
- [Lessons learned](Lessons-Learned)
- [Chart index](Charts)
+4 -4
@@ -4,7 +4,7 @@ Deploys [Urlaubsverwaltung](https://urlaubsverwaltung.cloud/), a leave and absen
| | |
|---|---|
| Chart version | `9.0.2+up6.12.0` |
| Chart version | `10.0.0+up6.12.1` |
| 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 |
@@ -76,8 +76,8 @@ kubectl -n <namespace> get secret <release>-urlaubsverwaltung-db-secret -o jsonp
| 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. |
| `secrets.databaseSecretRef` | string | `null` | **Required**, guarded by `required()` since 10.0.0 — unset, the render aborts with `A database Secret Reference must be provided (secrets.databaseSecretRef)`. `op://` path of the database item. Before 10.0.0 an unset value rendered a CR with an empty `itemPath`, installed cleanly, and the pod never started. |
| `secrets.oidcSecretRef` | string | `null` | **Required**, same guard since 10.0.0. `op://` path of the OIDC item. There is no non-OIDC login path in this chart, and the provider is resolved at boot rather than at login — see [The identity provider is required at boot](#the-identity-provider-is-required-at-boot-not-at-login). |
### `urlaubsverwaltung`
@@ -215,6 +215,6 @@ kubectl -n <namespace> rollout restart deployment/<release>-urlaubsverwaltung-de
- [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
- [Versioning](Versioning) — what a bump of `10.0.0+up6.12.1` means
- [Renovate](Renovate) — how `appVersion` gets updated
- [Chart index](Charts)