voucher-vault: strict values.schema.json and null-safe hardening
No ingress gate: this chart already gates its Ingress correctly. Two changes plus one guard that the existing gate turned out to need. 1. values.schema.json (#68): additionalProperties: false on every chart-owned object level. Lists that go verbatim into the pod spec (ports, storage.volumes, storage.volumeMounts) stay open. 2. Null-safety for hardening, probes and resources (#99). Two specifics of this chart: - The probes were wrapped in "if" guards, so an erased probe block did not render "livenessProbe: null" - it removed the probe entirely and silently. The guards are gone; an empty block now means chart defaults, so the workload keeps its probes. - The rebuilt probes keep the Host: 127.0.0.1 header. Django's ALLOWED_HOSTS only accepts 127.0.0.1 and the configured DOMAIN, so a probe without it would get 400 and the pod would never be ready. 3. ingress.domain is required() where it is read. The dependency lives in values.yaml, not in a template: the DOMAIN env entry is a tpl string feeding Django's ALLOWED_HOSTS. With the existing gate and no domain the app rendered DOMAIN: "" and answered 400 to every real request - silently, and already before this change. It now fails loudly. Apart from an added comment, the rendered manifest is unchanged.
This commit is contained in:
5 files changed
+390
-15
No files matched your search
@@ -114,3 +114,164 @@ 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": 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 or the resources block. 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 "voucher-vault.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 "voucher-vault.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.voucherVault.securityContext" can itself be erased.
|
||||
*/}}
|
||||
{{- define "voucher-vault.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.
|
||||
|
||||
The probes carry a Host header of 127.0.0.1: Django's ALLOWED_HOSTS only
|
||||
accepts 127.0.0.1 and the configured DOMAIN, so probing with the default Host
|
||||
(the pod IP) would always return 400. A rebuilt default probe must keep that
|
||||
header, otherwise the pod never becomes ready.
|
||||
*/}}
|
||||
{{- define "voucher-vault.podSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "voucher-vault.subBlock" (dict "block" . "key" "pod" "path" "voucherVault.securityContext"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"runAsNonRoot" true
|
||||
"runAsUser" 33
|
||||
"runAsGroup" 33
|
||||
"fsGroup" 33
|
||||
"fsGroupChangePolicy" "Always" -}}
|
||||
{{- include "voucher-vault.defaultedDict" (dict "defaults" $defaults "given" $given "path" "voucherVault.securityContext.pod") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "voucher-vault.containerSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "voucher-vault.subBlock" (dict "block" . "key" "container" "path" "voucherVault.securityContext"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"allowPrivilegeEscalation" false
|
||||
"readOnlyRootFilesystem" true
|
||||
"runAsNonRoot" true
|
||||
"capabilities" (dict "drop" (list "ALL")) -}}
|
||||
{{- include "voucher-vault.defaultedDict" (dict "defaults" $defaults "given" $given "path" "voucherVault.securityContext.container") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "voucher-vault.probeHttpGet" -}}
|
||||
{{- dict "path" "/" "port" "http" "httpHeaders" (list (dict "name" "Host" "value" "127.0.0.1")) | toYaml -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "voucher-vault.livenessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (fromYaml (include "voucher-vault.probeHttpGet" .))
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "voucher-vault.defaultedDict" (dict "defaults" $defaults "given" . "path" "voucherVault.livenessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "voucher-vault.readinessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (fromYaml (include "voucher-vault.probeHttpGet" .))
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "voucher-vault.defaultedDict" (dict "defaults" $defaults "given" . "path" "voucherVault.readinessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "voucher-vault.startupProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (fromYaml (include "voucher-vault.probeHttpGet" .))
|
||||
"periodSeconds" 5
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 30 -}}
|
||||
{{- include "voucher-vault.defaultedDict" (dict "defaults" $defaults "given" . "path" "voucherVault.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.
|
||||
The ephemeral-storage request matters here beyond the usual reason: the
|
||||
static-files emptyDir holds ~90Mi of assets and counts toward it.
|
||||
*/}}
|
||||
{{- define "voucher-vault.effectiveResources" -}}
|
||||
{{- $defaults := dict
|
||||
"limitFactor" 3
|
||||
"requests" (dict "cpu" 0.1 "memory" "256Mi" "ephemeral-storage" "128Mi") -}}
|
||||
{{- $merged := fromYaml (include "voucher-vault.defaultedDict" (dict "defaults" $defaults "given" . "path" "voucherVault.resources")) -}}
|
||||
{{- include "voucher-vault.resources" $merged -}}
|
||||
{{- end -}}
|
||||
@@ -18,7 +18,7 @@ spec:
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
{{- toYaml .Values.voucherVault.securityContext.pod | nindent 8 }}
|
||||
{{- include "voucher-vault.podSecurityContext" .Values.voucherVault.securityContext | nindent 8 }}
|
||||
initContainers:
|
||||
# STATIC_ROOT (/opt/app/myapp/static) doubles as the app's own static
|
||||
# source directory, so it cannot simply be shadowed by an empty
|
||||
@@ -33,7 +33,7 @@ spec:
|
||||
# on the mountpoint itself, which the non-root uid may not own.
|
||||
command: ["sh", "-c", "cp -a /opt/app/myapp/static/* /static-seed/"]
|
||||
securityContext:
|
||||
{{- toYaml .Values.voucherVault.securityContext.container | nindent 12 }}
|
||||
{{- include "voucher-vault.containerSecurityContext" .Values.voucherVault.securityContext | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: static-files
|
||||
mountPath: /static-seed
|
||||
@@ -73,22 +73,19 @@ spec:
|
||||
{{- end }}
|
||||
ports:
|
||||
{{- toYaml .Values.voucherVault.ports | nindent 12 }}
|
||||
{{- if .Values.voucherVault.livenessProbe }}
|
||||
# No "if" guards here any more: an erased probe block used to omit the
|
||||
# probe entirely and silently. It now falls back to the chart
|
||||
# defaults (#99), so the workload keeps its probes.
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.voucherVault.livenessProbe | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.voucherVault.readinessProbe }}
|
||||
{{- include "voucher-vault.livenessProbe" .Values.voucherVault.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- toYaml .Values.voucherVault.readinessProbe | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.voucherVault.startupProbe }}
|
||||
{{- include "voucher-vault.readinessProbe" .Values.voucherVault.readinessProbe | nindent 12 }}
|
||||
startupProbe:
|
||||
{{- toYaml .Values.voucherVault.startupProbe | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- include "voucher-vault.startupProbe" .Values.voucherVault.startupProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- include "voucher-vault.resources" .Values.voucherVault.resources | nindent 12 }}
|
||||
{{- include "voucher-vault.effectiveResources" .Values.voucherVault.resources | nindent 12 }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.voucherVault.securityContext.container | nindent 12 }}
|
||||
{{- include "voucher-vault.containerSecurityContext" .Values.voucherVault.securityContext | nindent 12 }}
|
||||
volumeMounts:
|
||||
# Writable emptyDirs over the paths the image writes at runtime,
|
||||
# so the root filesystem can stay read-only (see securityContext
|
||||
|
||||
Reference in new issue
Block a user