calibre-web-automated: strict schema, ingress gate and null-safe hardening

Three changes, all about values that do not do what they promise:

1. values.schema.json (#68): additionalProperties: false on every
   chart-owned object level.

2. Ingress gate (#93): the Ingress template is wrapped in
   {{- if .Values.ingress.enabled }} and the key is added to values.yaml
   with default true, so behaviour is unchanged.

   The domain checkpoint from rallly ran over values.yaml and templates
   and came back empty here: ingress.domain is read nowhere but in the
   Ingress template. Unlike rallly, zipline, voucher-vault and outline,
   this gate therefore frees both domain and clusterIssuer, and no extra
   required() is needed.

3. Null-safety for hardening, probes and resources (#99).

   This chart's hardening is a documented deviation from the repo
   standard, and the fallbacks reproduce it exactly instead of restoring
   the standard: no runAs* values (s6-overlay boots as root and drops to
   PUID/PGID 1000), readOnlyRootFilesystem stays false (read-only mode
   disables the PUID/PGID remapping, silently moving the runtime uid from
   1000 to 911 and breaking existing volume data), and capabilities.add
   keeps its six entries that the s6 init and supervision need. A
   "cleaned up" standard default here would not be tidier, it would be an
   outage.

cwa.storage stays deliberately not null-safe: an erased value yields an
invalid PVC, which is the loud class #99 excludes.

Rendering with normal values is byte-identical to 4.0.0.
This commit is contained in:
jklein committed 2026-08-23 21:34:32 +02:00
1 parent 81549d6aab
commit 6c04a68763
6 files changed
+371 -7

No files matched your search

@@ -114,3 +114,173 @@ 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 (issue #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". 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.
*/}}
{{- define "calibre-web-automated.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 "calibre-web-automated.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.cwa.securityContext" can itself be erased.
*/}}
{{- define "calibre-web-automated.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.
IMPORTANT - this chart's hardening is a DOCUMENTED DEVIATION from the repo
standard, and these fallbacks reproduce it exactly rather than "cleaning it
up". Restoring the repo-standard hardening here would break the workload:
- No runAsNonRoot / runAsUser / runAsGroup: the CWA image boots via the
s6-overlay init system, whose stage-1/2 setup runs as uid 0 and then drops
the application to the PUID/PGID user (1000).
- readOnlyRootFilesystem stays false: in read-only mode the image disables
the PUID/PGID remapping, so the runtime uid would silently change from
1000 to the built-in 911 and break existing volume data, and its init
fails to write the ImageMagick policy under /etc.
- capabilities.add is NOT empty: the s6 init/supervision needs
CHOWN/DAC_OVERRIDE/FOWNER/SETGID/SETUID for the ownership fix-up and the
root->PUID drop, plus KILL so the root supervisor can signal the uid-1000
services (without it the pod cannot shut down cleanly and gets SIGKILLed).
Verified against v4.0.6.
- The pod context sets only fsGroup / fsGroupChangePolicy, no runAs* values.
The probes address the container port by its name ("http"), so a rebuilt
default probe needs no further context.
*/}}
{{- define "calibre-web-automated.podSecurityContext" -}}
{{- $given := (fromYaml (include "calibre-web-automated.subBlock" (dict "block" . "key" "pod" "path" "cwa.securityContext"))).value -}}
{{- $defaults := dict
"fsGroup" 1000
"fsGroupChangePolicy" "Always" -}}
{{- include "calibre-web-automated.defaultedDict" (dict "defaults" $defaults "given" $given "path" "cwa.securityContext.pod") -}}
{{- end -}}
{{- define "calibre-web-automated.containerSecurityContext" -}}
{{- $given := (fromYaml (include "calibre-web-automated.subBlock" (dict "block" . "key" "container" "path" "cwa.securityContext"))).value -}}
{{- $defaults := dict
"allowPrivilegeEscalation" false
"readOnlyRootFilesystem" false
"capabilities" (dict
"drop" (list "ALL")
"add" (list "CHOWN" "DAC_OVERRIDE" "FOWNER" "KILL" "SETGID" "SETUID")) -}}
{{- include "calibre-web-automated.defaultedDict" (dict "defaults" $defaults "given" $given "path" "cwa.securityContext.container") -}}
{{- end -}}
{{- define "calibre-web-automated.livenessProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/" "port" "http")
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "calibre-web-automated.defaultedDict" (dict "defaults" $defaults "given" . "path" "cwa.livenessProbe") -}}
{{- end -}}
{{- define "calibre-web-automated.readinessProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/" "port" "http")
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "calibre-web-automated.defaultedDict" (dict "defaults" $defaults "given" . "path" "cwa.readinessProbe") -}}
{{- end -}}
{{- define "calibre-web-automated.startupProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/" "port" "http")
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 30 -}}
{{- include "calibre-web-automated.defaultedDict" (dict "defaults" $defaults "given" . "path" "cwa.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 "calibre-web-automated.effectiveResources" -}}
{{- $defaults := dict
"limitFactor" 3
"requests" (dict "cpu" 0.1 "memory" "512Mi" "ephemeralStorage" "100Mi") -}}
{{- $merged := fromYaml (include "calibre-web-automated.defaultedDict" (dict "defaults" $defaults "given" . "path" "cwa.resources")) -}}
{{- include "calibre-web-automated.resources" $merged -}}
{{- end -}}
@@ -1,3 +1,4 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -26,3 +27,4 @@ spec:
- hosts:
- {{ required "A Domain/Host must be provided" .Values.ingress.domain }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
{{- end }}
@@ -18,7 +18,7 @@ spec:
spec:
automountServiceAccountToken: false
securityContext:
{{- toYaml .Values.cwa.securityContext.pod | nindent 8 }}
{{- include "calibre-web-automated.podSecurityContext" .Values.cwa.securityContext | nindent 8 }}
containers:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-container
image: "ghcr.io/crocodilestick/calibre-web-automated:{{ .Chart.AppVersion }}"
@@ -79,15 +79,15 @@ spec:
- mountPath: "/cwa-book-ingest"
name: cwa-consume
livenessProbe:
{{- toYaml .Values.cwa.livenessProbe | nindent 12 }}
{{- include "calibre-web-automated.livenessProbe" .Values.cwa.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.cwa.readinessProbe | nindent 12 }}
{{- include "calibre-web-automated.readinessProbe" .Values.cwa.readinessProbe | nindent 12 }}
startupProbe:
{{- toYaml .Values.cwa.startupProbe | nindent 12 }}
{{- include "calibre-web-automated.startupProbe" .Values.cwa.startupProbe | nindent 12 }}
resources:
{{- include "calibre-web-automated.resources" .Values.cwa.resources | nindent 12 }}
{{- include "calibre-web-automated.effectiveResources" .Values.cwa.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.cwa.securityContext.container | nindent 12 }}
{{- include "calibre-web-automated.containerSecurityContext" .Values.cwa.securityContext | nindent 12 }}
restartPolicy: Always
volumes:
# Deliberate naming exception (see README): both PVCs keep their