diff --git a/Chart-Style-Guide.md b/Chart-Style-Guide.md index 277ac77..ac59ec4 100644 --- a/Chart-Style-Guide.md +++ b/Chart-Style-Guide.md @@ -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 ''`, 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 diff --git a/Lessons-Learned.md b/Lessons-Learned.md index 91040ca..5289b6e 100644 --- a/Lessons-Learned.md +++ b/Lessons-Learned.md @@ -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.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. diff --git a/calibre-web-automated.md b/calibre-web-automated.md index 27c2502..a95c889 100644 --- a/calibre-web-automated.md +++ b/calibre-web-automated.md @@ -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` | `-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 logs -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) diff --git a/minecraft-server.md b/minecraft-server.md index 9323424..1f73215 100644 --- a/minecraft-server.md +++ b/minecraft-server.md @@ -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, `-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` | `-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 get secret -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) diff --git a/searxng.md b/searxng.md index 66a6e69..2270205 100644 --- a/searxng.md +++ b/searxng.md @@ -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) diff --git a/urlaubsverwaltung.md b/urlaubsverwaltung.md index 9b8c121..1522167 100644 --- a/urlaubsverwaltung.md +++ b/urlaubsverwaltung.md @@ -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 get secret -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 rollout restart deployment/-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)