template-chart + README: values.schema.json, gate and null-safe helpers

Closes out the schema programme (#68) by moving what the 18 charts taught
into the reference implementation and the binding guidelines.

template-chart:
- values.schema.json as the reference schema: additionalProperties false
  on every chart-owned level, pod-spec passthrough left open, and the
  reasoning inline in the descriptions so a copy carries it along.
- defaultedDict and subBlock plus the per-block fallbacks for both
  security contexts, the three probes and resources, wired into both the
  Deployment and the StatefulSet.
- the reference itself violated guideline 1: its env block reads
  ingress.domain, so with the Ingress gated off it rendered an empty
  value. That read is now required(), and the comment says why - the
  template is what people copy, so it has to teach the guard, not the
  hole.

README, new section "Values that must not lie" with the five rules:
1. a value read outside its obvious template needs its own guard
   (matrix-synapse published '{"m.server": ":443"}' to federation without
   it, rendering cleanly and reporting healthy)
2. probes, contexts and resources through the null-safe helper, never
   behind an "if" - a guard removes the probe instead of showing a null
3. no liveness on an external dependency where a dependency-free path
   exists, otherwise document why on the helper
4. a fallback reproduces THIS chart's defaults, not the repo standard -
   calibre's readOnlyRootFilesystem: false is what keeps the runtime uid
   at 1000 instead of 911 and existing volume data readable
5. a probe default carries its context - port name, Host header, exec
   command - or the probe vanishes (teamspeak) or gets 403 (patchmon)

Plus the two mechanics that are easiest to lose: null reaches schema
validation only for keys WITHOUT a chart default (which is why the helper
and the ["object","null"] types each cover a case the other cannot), and
schemas must be validated against real bundle values - that step caught a
draft of mine that would have broken three live paperless releases.

CLAUDE.md: the new-chart checklist now names the schema and the
adaptation step, since the copied file is chart-specific and wrong until
it is adapted.

freesailarr is not covered: it is not on main yet (#59).
This commit is contained in:
jklein committed 2026-08-23 22:35:03 +02:00
1 parent c2f12bec70
commit b218f64eac
8 files changed
+424 -18

No files matched your search

+168
View File
@@ -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" <dict> "given" <dict|nil> "path" <string, optional
values path used in error messages>. 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" <dict|nil> "key" <string> "path" <string>.
Returns YAML of dict "value" <the sub-block, may be nil>, after rejecting a
non-mapping block with a message that names the values path. Needed because
".Values.<app>.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 -}}
+11 -6
View File
@@ -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
+11 -6
View File
@@ -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