diff --git a/CLAUDE.md b/CLAUDE.md index a866165..3e971f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ helm template --set ingress.domain=example.com --set ingress.cluste ## Creating a new chart -Copy `template-chart/` and replace every `xxx` placeholder — in file names (`templates/xxx.deployment.yaml` → `templates/.deployment.yaml`), in `Chart.yaml`, and in the `values.yaml` keys and their `.Values.xxx.*` references inside templates. Then wire up CI: +Copy `template-chart/` and replace every `xxx` placeholder — in file names (`templates/xxx.deployment.yaml` → `templates/.deployment.yaml`), in `Chart.yaml`, in the `values.yaml` keys and their `.Values.xxx.*` references inside templates, and in `values.schema.json` (both the values paths and the `xxx.` helper prefixes). The schema and the null-safe fallback helpers come with the template but their content is chart-specific — adapt them along the five points of the README section "Values that must not lie", and validate the result against the chart's real bundle values, not only against its defaults. Then wire up CI: 1. Add `.github/workflows/publish-.yml` (copy an existing one, e.g. `publish-outline.yml`) — it triggers on pushes to `main` touching `/**` and calls the reusable `publish-helm-chart.yml`. 2. Add the chart name to the `chart-name` choice list in `.github/workflows/change-app-version.yml`. diff --git a/README.md b/README.md index 284e169..5031c29 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo | `homeassistant` | Home Assistant | StatefulSet, has `NOTES.txt` and Helm tests | | `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service | | `minecraft-router` | itzg/mc-router | Deployment + routing ConfigMap + NodePort Service (TCP only, no ingress); `mappings` is a required value | -| `minecraft-server` | itzg/minecraft-server | StatefulSet + PVC + Service + init-script ConfigMap (no ingress); service type is a value (`ClusterIP` by default, routed via `minecraft-router`). Published once per Java line — the image tag comes from `.Chart.AppVersion`, so the Java line is chosen by picking the chart version, and `minecraft.version` ships a default matching that line. Since chart 2.0.0 the tag is an **immutable dated upstream release** (`appVersion: -java8` / `-java17` / `-java21`, e.g. `2026.8.2-java8`) instead of the continuously rebuilt rolling line tag. First chart with a strict `values.schema.json` (pilot, #68) | +| `minecraft-server` | itzg/minecraft-server | StatefulSet + PVC + Service + init-script ConfigMap (no ingress); service type is a value (`ClusterIP` by default, routed via `minecraft-router`). Published once per Java line — the image tag comes from `.Chart.AppVersion`, so the Java line is chosen by picking the chart version, and `minecraft.version` ships a default matching that line. Since chart 2.0.0 the tag is an **immutable dated upstream release** (`appVersion: -java8` / `-java17` / `-java21`, e.g. `2026.8.2-java8`) instead of the continuously rebuilt rolling line tag. Pilot for the strict `values.schema.json` (#68), which is now binding for every chart | | `outline` | Outline | StatefulSet, separate DB/Redis secrets | | `paperless-ngx` | Paperless-ngx | Largest chart: consume CronJob, SMB-connector PV/PVC, pre/post-consumption scripts ConfigMap | | `patchmon` | PatchMon | Two Deployments (frontend + backend), OIDC secret | @@ -56,7 +56,7 @@ Fixture images avoid Docker Hub where possible (`public.ecr.aws`/`ghcr.io`) to d ## Creating a new chart -1. Copy `template-chart/` to `/` and replace every `xxx` placeholder: template file names (`xxx.deployment.yaml` → `.deployment.yaml`), `Chart.yaml`, the `values.yaml` keys, and the `.Values.xxx.*` references inside the templates. +1. Copy `template-chart/` to `/` and replace every `xxx` placeholder: template file names (`xxx.deployment.yaml` → `.deployment.yaml`), `Chart.yaml`, the `values.yaml` keys, the `.Values.xxx.*` references inside the templates, **and both the `xxx.` helper prefixes and the values paths in `values.schema.json`**. Then walk the five points in "Values that must not lie" below — the schema and the null-safe fallbacks are copied with the template, but their content is chart-specific and wrong until you adapt it. 2. Add `.github/workflows/publish-.yml` (copy an existing one, e.g. `publish-outline.yml`). 3. Add the chart name to the choice list in `.github/workflows/change-app-version.yml`. @@ -162,7 +162,34 @@ These guidelines are **binding for every current and future chart** in this repo - Every chart has a global `scaleDown` flag (default `false`): `scaleDown: true` takes precedence over all `replicas` values, renders every Deployment/StatefulSet with `replicas: 0` and suspends CronJobs. It **stops** the app but does not uninstall it — PVCs, Secrets, Services and Ingress stay in place; setting it back to `false` brings the workloads back up. - Implemented via a per-chart `.replicas` helper in `_helpers.tpl` (reference in `template-chart`); the helper uses an explicit nil check (`kindIs "invalid"`) because Helm's `default` would swallow an explicit `0`. -### 12. Everything else +### 12. Values that must not lie (#68, #93, #99) + +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 (#68) found three of them in one chart, one of them nested three levels deep and quietly leaving a game world unpinned. Objects that are 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. + +Reference implementation: `template-chart/values.schema.json` and `template-chart/templates/_helpers.tpl`. + +**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"} {"m.homeserver": {"base_url": "https://"}} +``` + +— 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, which is 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. + +**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.** Two different symptoms, both from real charts: `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 (patchmon derives the header from `ingress.domain`). + +**Two things about the 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* schema 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: each covers a case the other cannot. 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 bundle in `cluster-config/fleet-cd/` **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 step caught a schema of mine that would have broken three live paperless releases, because all three write `tikaGotenberg.enabled` as the string `"true"` and the first draft demanded a boolean. 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`. - `helm lint --strict` must pass without findings. diff --git a/template-chart/Chart.yaml b/template-chart/Chart.yaml index 30d46e3..478a980 100644 --- a/template-chart/Chart.yaml +++ b/template-chart/Chart.yaml @@ -5,7 +5,7 @@ icon: https://helm.sh/img/helm.svg type: application -version: 2.0.0+upxxx +version: 3.0.0+upxxx appVersion: "xxx" maintainers: diff --git a/template-chart/templates/_helpers.tpl b/template-chart/templates/_helpers.tpl index 5342f44..62d13b5 100644 --- a/template-chart/templates/_helpers.tpl +++ b/template-chart/templates/_helpers.tpl @@ -114,3 +114,171 @@ template-chart is the reference implementation. 1 {{- end -}} {{- end -}} + +{{/* +Merge a user-supplied values sub-tree onto a dict of chart defaults so that a +null / empty override falls back to the defaults instead of erasing them. + +Input: dict "defaults" "given" "path" . Output: YAML of the merged dict (consume +it with fromYaml). + +Why this exists (issues #61 and #99): Helm merges values maps recursively, but +an override key written without children — e.g. a leftover + + securityContext: + +after deleting its last remaining sub-key — is YAML null. Where the chart +defines a default, Helm treats that null as a deletion of the default; the +template then renders "securityContext: null": no readOnlyRootFilesystem, no +dropped capabilities, no allowPrivilegeEscalation: false. The workload starts +and reports healthy while being unhardened, so nothing points at the mistake. +The same shape erases a probe (it disappears) or the resources block (no +requests, no computed limits). This helper defines the opposite semantics: an +empty block means "chart defaults apply". + +Rules: + - a nil "given" is treated as an empty dict (all defaults apply). + - a key whose override value is null keeps the default (nulls never delete). + - a key present in both as a map is merged recursively, so a partially + filled block only overrides the keys it actually sets. + - a scalar override for a key the defaults define as a map is rejected with + a message naming the values path, instead of failing later with a template + field error. + - any other set value wins as given, including an explicit false or 0 — + Helm's "default" and sprig's "mergeOverwrite" would both swallow those + zero values, hence the explicit kindIs "invalid" nil check. + - keys only present in the override are passed through unchanged. + +NOTE: like the helpers above, this is duplicated into every chart's +templates/_helpers.tpl with the chart's name as define prefix, because charts +in this repo deliberately have no dependencies. This file is the reference +implementation. +*/}} +{{- define "xxx.defaultedDict" -}} +{{- $defaults := .defaults | default dict -}} +{{- $given := .given -}} +{{- $path := .path | default "value" -}} +{{- if kindIs "invalid" $given -}} +{{- $given = dict -}} +{{- end -}} +{{- if not (kindIs "map" $given) -}} +{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}} +{{- end -}} +{{- $out := deepCopy $defaults -}} +{{- range $key, $value := $given -}} +{{- if not (kindIs "invalid" $value) -}} +{{- $default := index $defaults $key -}} +{{- if and (kindIs "map" $value) (kindIs "map" $default) -}} +{{- $_ := set $out $key (fromYaml (include "xxx.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}} +{{- else if kindIs "map" $default -}} +{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}} +{{- else -}} +{{- $_ := set $out $key $value -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- toYaml $out -}} +{{- end -}} + +{{/* +Pick one sub-block out of a values block that may itself be null. + +Input: dict "block" "key" "path" . +Returns YAML of dict "value" , after rejecting a +non-mapping block with a message that names the values path. Needed because +".Values..securityContext" can itself be erased, and indexing into nil +inside a template is not an error but silently yields nothing. +*/}} +{{- define "xxx.subBlock" -}} +{{- $block := .block -}} +{{- if kindIs "invalid" $block -}} +{{- $block = dict -}} +{{- end -}} +{{- if not (kindIs "map" $block) -}} +{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}} +{{- end -}} +{{- toYaml (dict "value" (index $block .key)) -}} +{{- end -}} + +{{/* +The effective security contexts, probes and resources (issue #99). + +The defaults below MUST stay in sync with the corresponding blocks in +values.yaml, which stays the documented, user-facing place for them; this copy +is only the fallback for the case where an override has erased the block. + +Two rules that these fallbacks exist to demonstrate (README, "Chart style +guidelines"): + + - Reproduce THIS chart's defaults, not the repo standard. Where a chart + deviates on purpose (a root-starting image, a non-default limitFactor, a + pinned uid), the fallback must reproduce the deviation. Restoring the + "clean" standard here is not tidying, it is an outage - in one chart it + would move the runtime uid from 1000 to 911 and make existing volume data + inaccessible. + - Carry the probe's CONTEXT, not just its timings. A probe default without + its port name, Host header or exec command is worse than useless: in one + chart the probe vanished entirely, in another it answered 403 and the pod + never became Ready. +*/}} +{{- define "xxx.podSecurityContext" -}} +{{- $given := (fromYaml (include "xxx.subBlock" (dict "block" . "key" "pod" "path" "xxx.securityContext"))).value -}} +{{- $defaults := dict + "runAsNonRoot" true + "runAsUser" 20000 + "runAsGroup" 1000 + "fsGroup" 1000 + "fsGroupChangePolicy" "Always" -}} +{{- include "xxx.defaultedDict" (dict "defaults" $defaults "given" $given "path" "xxx.securityContext.pod") -}} +{{- end -}} + +{{- define "xxx.containerSecurityContext" -}} +{{- $given := (fromYaml (include "xxx.subBlock" (dict "block" . "key" "container" "path" "xxx.securityContext"))).value -}} +{{- $defaults := dict + "allowPrivilegeEscalation" false + "readOnlyRootFilesystem" true + "runAsNonRoot" true + "capabilities" (dict "drop" (list "ALL") "add" (list)) -}} +{{- include "xxx.defaultedDict" (dict "defaults" $defaults "given" $given "path" "xxx.securityContext.container") -}} +{{- end -}} + +{{- define "xxx.livenessProbe" -}} +{{- $defaults := dict + "httpGet" (dict "path" "/" "port" "http") + "periodSeconds" 10 + "timeoutSeconds" 5 + "failureThreshold" 3 -}} +{{- include "xxx.defaultedDict" (dict "defaults" $defaults "given" . "path" "xxx.livenessProbe") -}} +{{- end -}} + +{{- define "xxx.readinessProbe" -}} +{{- $defaults := dict + "httpGet" (dict "path" "/" "port" "http") + "periodSeconds" 10 + "timeoutSeconds" 5 + "failureThreshold" 3 -}} +{{- include "xxx.defaultedDict" (dict "defaults" $defaults "given" . "path" "xxx.readinessProbe") -}} +{{- end -}} + +{{- define "xxx.startupProbe" -}} +{{- $defaults := dict + "httpGet" (dict "path" "/" "port" "http") + "periodSeconds" 5 + "timeoutSeconds" 5 + "failureThreshold" 30 -}} +{{- include "xxx.defaultedDict" (dict "defaults" $defaults "given" . "path" "xxx.startupProbe") -}} +{{- end -}} + +{{/* +The effective resources block, fed into the resources helper above so that an +erased "resources:" still yields the chart's requests and computed limits +instead of a container without any resource accounting. +*/}} +{{- define "xxx.effectiveResources" -}} +{{- $defaults := dict + "limitFactor" 3 + "requests" (dict "cpu" 0.1 "memory" "512Mi" "ephemeral-storage" "10Mi") -}} +{{- $merged := fromYaml (include "xxx.defaultedDict" (dict "defaults" $defaults "given" . "path" "xxx.resources")) -}} +{{- include "xxx.resources" $merged -}} +{{- end -}} diff --git a/template-chart/templates/xxx.deployment.yaml b/template-chart/templates/xxx.deployment.yaml index 016ab6d..7314e1b 100644 --- a/template-chart/templates/xxx.deployment.yaml +++ b/template-chart/templates/xxx.deployment.yaml @@ -24,7 +24,7 @@ spec: spec: automountServiceAccountToken: false securityContext: - {{- toYaml .Values.xxx.securityContext.pod | nindent 8 }} + {{- include "xxx.podSecurityContext" .Values.xxx.securityContext | nindent 8 }} containers: - name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment image: "registry/image:{{ .Chart.AppVersion }}" @@ -52,14 +52,19 @@ spec: {{- end }} ports: {{- toYaml .Values.xxx.ports | nindent 12 }} + # Probes, resources and both security contexts go through the + # null-safe helpers (#99): an erased block means "chart defaults + # apply" instead of a vanished probe or an unhardened container. + # Never wrap these in an "if" - a guard hides the erased case + # completely instead of rendering a visible null. livenessProbe: - {{- toYaml .Values.xxx.livenessProbe | nindent 12 }} + {{- include "xxx.livenessProbe" .Values.xxx.livenessProbe | nindent 12 }} readinessProbe: - {{- toYaml .Values.xxx.readinessProbe | nindent 12 }} + {{- include "xxx.readinessProbe" .Values.xxx.readinessProbe | nindent 12 }} startupProbe: - {{- toYaml .Values.xxx.startupProbe | nindent 12 }} + {{- include "xxx.startupProbe" .Values.xxx.startupProbe | nindent 12 }} resources: - {{- include "xxx.resources" .Values.xxx.resources | nindent 12 }} + {{- include "xxx.effectiveResources" .Values.xxx.resources | nindent 12 }} securityContext: - {{- toYaml .Values.xxx.securityContext.container | nindent 12 }} + {{- include "xxx.containerSecurityContext" .Values.xxx.securityContext | nindent 12 }} restartPolicy: Always diff --git a/template-chart/templates/xxx.sfs.yaml b/template-chart/templates/xxx.sfs.yaml index 689f4b6..6dbbb33 100644 --- a/template-chart/templates/xxx.sfs.yaml +++ b/template-chart/templates/xxx.sfs.yaml @@ -25,7 +25,7 @@ spec: spec: automountServiceAccountToken: false securityContext: - {{- toYaml .Values.xxx.securityContext.pod | nindent 8 }} + {{- include "xxx.podSecurityContext" .Values.xxx.securityContext | nindent 8 }} containers: - name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs image: "registry/image:{{ .Chart.AppVersion }}" @@ -53,14 +53,19 @@ spec: {{- end }} ports: {{- toYaml .Values.xxx.ports | nindent 12 }} + # Probes, resources and both security contexts go through the + # null-safe helpers (#99): an erased block means "chart defaults + # apply" instead of a vanished probe or an unhardened container. + # Never wrap these in an "if" - a guard hides the erased case + # completely instead of rendering a visible null. livenessProbe: - {{- toYaml .Values.xxx.livenessProbe | nindent 12 }} + {{- include "xxx.livenessProbe" .Values.xxx.livenessProbe | nindent 12 }} readinessProbe: - {{- toYaml .Values.xxx.readinessProbe | nindent 12 }} + {{- include "xxx.readinessProbe" .Values.xxx.readinessProbe | nindent 12 }} startupProbe: - {{- toYaml .Values.xxx.startupProbe | nindent 12 }} + {{- include "xxx.startupProbe" .Values.xxx.startupProbe | nindent 12 }} resources: - {{- include "xxx.resources" .Values.xxx.resources | nindent 12 }} + {{- include "xxx.effectiveResources" .Values.xxx.resources | nindent 12 }} securityContext: - {{- toYaml .Values.xxx.securityContext.container | nindent 12 }} + {{- include "xxx.containerSecurityContext" .Values.xxx.securityContext | nindent 12 }} restartPolicy: Always diff --git a/template-chart/values.schema.json b/template-chart/values.schema.json new file mode 100644 index 0000000..2f39f10 --- /dev/null +++ b/template-chart/values.schema.json @@ -0,0 +1,192 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "title": "xxx values", + "description": "Reference values schema (issue #68). Copy this file alongside the rest of template-chart and replace every xxx. Two rules carry the whole idea: additionalProperties is false on every chart-owned object level, so a key the chart does not read is rejected at render time instead of being silently ignored; and the objects that go verbatim into the Kubernetes pod spec (probes, security contexts, global, free-form annotations) stay open, because the chart does not interpret their contents and cannot judge which keys are meaningful. The hardening, probe and resources blocks additionally accept null - see the note on that in the definitions below.", + "type": "object", + "additionalProperties": false, + "properties": { + "global": { + "description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.", + "type": "object" + }, + "scaleDown": { + "type": "boolean" + }, + "secrets": { + "type": "object", + "additionalProperties": false, + "properties": { + "xxxSecretRef": { + "description": "op:// item path. Name the key exactly as the template reads it - a plausible abbreviation is a dead key, and this schema is what turns it into a named error instead of a missing secret.", + "type": ["string", "null"] + } + } + }, + "xxx": { + "type": "object", + "additionalProperties": false, + "properties": { + "replicas": { + "type": "integer", + "minimum": 0 + }, + "resources": { + "$ref": "#/definitions/resources" + }, + "livenessProbe": { + "$ref": "#/definitions/probe" + }, + "readinessProbe": { + "$ref": "#/definitions/probe" + }, + "startupProbe": { + "$ref": "#/definitions/probe" + }, + "securityContext": { + "$ref": "#/definitions/securityContext" + }, + "ports": { + "description": "Rendered verbatim as the container's ports list; kept open because the chart does not interpret the entries.", + "type": ["array", "null"] + }, + "env": { + "$ref": "#/definitions/env" + } + } + }, + "storage": { + "description": "The data PVC. Deliberately NOT null-safe: an erased block yields an invalid PVC, which fails loudly. Issue #99 covers only the silent class - an error that shows itself needs no fallback, and every extra bit of template logic has to be read by someone.", + "type": "object", + "additionalProperties": false, + "properties": { + "amount": { + "type": ["string", "null"] + }, + "storageClass": { + "type": ["string", "null"] + } + } + }, + "ingress": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Gates the whole Ingress template (issue #93). Whether switching it off also frees domain depends on the chart: if anything outside the Ingress template reads ingress.domain, that read needs its own required() and the value stays mandatory. Check, do not assume (guideline 1).", + "type": "boolean" + }, + "clusterIssuer": { + "type": ["string", "null"] + }, + "domain": { + "type": ["string", "null"] + }, + "className": { + "type": ["string", "null"] + }, + "annotations": { + "description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.", + "type": "object" + } + } + } + }, + "definitions": { + "resources": { + "description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.", + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "limitFactor": { + "type": "number", + "exclusiveMinimum": 0 + }, + "requests": { + "$ref": "#/definitions/resourceList" + }, + "limits": { + "$ref": "#/definitions/resourceList" + } + } + }, + "resourceList": { + "description": "Both spellings of the ephemeral-storage key are accepted because the resources helper normalizes camelCase to the Kubernetes key.", + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "cpu": { + "type": ["string", "number", "null"] + }, + "memory": { + "type": ["string", "number", "null"] + }, + "ephemeral-storage": { + "type": ["string", "number", "null"] + }, + "ephemeralStorage": { + "type": ["string", "number", "null"] + } + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "valueFrom": { + "type": "object", + "additionalProperties": false, + "properties": { + "secretKeyRef": { + "$ref": "#/definitions/keyRef" + }, + "configMapKeyRef": { + "$ref": "#/definitions/keyRef" + } + } + } + } + } + }, + "keyRef": { + "type": "object", + "additionalProperties": false, + "required": ["name", "key"], + "properties": { + "name": { + "type": "string" + }, + "key": { + "type": "string" + } + } + }, + "securityContext": { + "description": "May be null / empty at any level: the chart then falls back to its hardening defaults (issue #99) instead of rendering an unhardened workload. Where a chart deviates from the repo hardening standard on purpose, its fallback reproduces the DEVIATION, not the standard (guideline 4).", + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "pod": { + "description": "Merged onto the chart's pod hardening defaults and rendered as the pod's securityContext; kept open because the chart does not interpret the individual fields.", + "type": ["object", "null"] + }, + "container": { + "description": "Merged onto the chart's container hardening defaults and rendered as the container's securityContext; kept open because the chart does not interpret the individual fields.", + "type": ["object", "null"] + } + } + }, + "probe": { + "description": "Merged onto the chart's probe defaults and rendered as a container probe; kept open because the chart does not interpret the individual fields. May be null / empty, which means the defaults apply (issue #99) rather than the probe disappearing. The chart's default MUST carry the probe's context - port name, Host header, exec command - not just its timings (guideline 5).", + "type": ["object", "null"] + } + } +} diff --git a/template-chart/values.yaml b/template-chart/values.yaml index 61c3207..d422ffb 100644 --- a/template-chart/values.yaml +++ b/template-chart/values.yaml @@ -63,8 +63,13 @@ xxx: protocol: TCP name: http env: + # Reference for guideline 1: ingress.domain is read HERE, outside the + # Ingress template, so it needs its own required(). Without it, switching + # the Ingress off via ingress.enabled would render an empty value and the + # workload would start with a broken address - silently. Copy this shape + # whenever a chart uses the domain for anything but the Ingress host. - name: NORMAL_ENV - value: "{{ .Values.ingress.domain }}" + value: '{{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}' - name: ENV_FROM_SECRET valueFrom: secretKeyRef: @@ -81,6 +86,10 @@ storage: storageClass: null ingress: + # Gates the whole Ingress template. Note that ingress.domain is ALSO read by + # the env block above, so it stays required even with the Ingress switched + # off; only clusterIssuer becomes unnecessary. Check this per chart instead + # of assuming either way (guideline 1). enabled: true clusterIssuer: null domain: null