gotenberg: null-safe hardening, probes and resources
Retrofit of #99 for a chart that already carries a values.schema.json, plus the schema type change that goes with it. An override written without children is YAML null. Where the chart defines a default, Helm's coalescing deletes the default before schema validation runs, so the schema never sees the null and cannot reject it: the container rendered "securityContext: null" - unhardened, healthy, silent. Where the chart defines no default (resources.limits), the null survives into validation and a strict "object" type aborted the render. The defaultedDict helper from #96 handles the first case; widening the types of exactly these blocks to ["object", "null"] handles the second. All other keys stay strict, additionalProperties: false is untouched. The probes address the container port by name, so a rebuilt default probe needs no further context. Rendering with normal values is byte-identical to 5.0.0.
This commit is contained in:
5 files changed
+175
-17
No files matched your search
@@ -5,7 +5,7 @@ icon: https://gotenberg.dev/img/logo.png
|
|||||||
|
|
||||||
type: application
|
type: application
|
||||||
|
|
||||||
version: 5.1.0+up8.36.0
|
version: 6.0.0+up8.36.0
|
||||||
appVersion: "8.36.0"
|
appVersion: "8.36.0"
|
||||||
|
|
||||||
maintainers:
|
maintainers:
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{{- $replicas := include "gotenberg.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.gotenberg.replicas) -}}
|
{{- $replicas := include "gotenberg.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.gotenberg.replicas) -}}
|
||||||
{{- $limits := (include "gotenberg.resources" .Values.gotenberg.resources | fromYaml).limits | default dict -}}
|
{{- /* effectiveResources, not resources: an erased "resources:" block falls back
|
||||||
|
to the chart defaults in the Deployment (#99), so the notes must report the
|
||||||
|
limits the release actually gets, not "(none set)". */ -}}
|
||||||
|
{{- $limits := (include "gotenberg.effectiveResources" .Values.gotenberg.resources | fromYaml).limits | default dict -}}
|
||||||
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
|
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
|
||||||
Gotenberg {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
Gotenberg {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||||
|
|
||||||
|
|||||||
@@ -114,3 +114,156 @@ template-chart is the reference implementation.
|
|||||||
1
|
1
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- 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 (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 "gotenberg.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 "gotenberg.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.gotenberg.securityContext" can itself be erased.
|
||||||
|
*/}}
|
||||||
|
{{- define "gotenberg.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 address the container port by its name ("gotenberg-port"), so a
|
||||||
|
rebuilt default probe needs no further context.
|
||||||
|
*/}}
|
||||||
|
{{- define "gotenberg.podSecurityContext" -}}
|
||||||
|
{{- $given := (fromYaml (include "gotenberg.subBlock" (dict "block" . "key" "pod" "path" "gotenberg.securityContext"))).value -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"runAsNonRoot" true
|
||||||
|
"runAsUser" 1001
|
||||||
|
"runAsGroup" 1001 -}}
|
||||||
|
{{- include "gotenberg.defaultedDict" (dict "defaults" $defaults "given" $given "path" "gotenberg.securityContext.pod") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "gotenberg.containerSecurityContext" -}}
|
||||||
|
{{- $given := (fromYaml (include "gotenberg.subBlock" (dict "block" . "key" "container" "path" "gotenberg.securityContext"))).value -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"allowPrivilegeEscalation" false
|
||||||
|
"readOnlyRootFilesystem" true
|
||||||
|
"runAsNonRoot" true
|
||||||
|
"capabilities" (dict "drop" (list "ALL")) -}}
|
||||||
|
{{- include "gotenberg.defaultedDict" (dict "defaults" $defaults "given" $given "path" "gotenberg.securityContext.container") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "gotenberg.livenessProbe" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"httpGet" (dict "path" "/health" "port" "gotenberg-port")
|
||||||
|
"periodSeconds" 10
|
||||||
|
"timeoutSeconds" 5
|
||||||
|
"failureThreshold" 3 -}}
|
||||||
|
{{- include "gotenberg.defaultedDict" (dict "defaults" $defaults "given" . "path" "gotenberg.livenessProbe") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "gotenberg.readinessProbe" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"httpGet" (dict "path" "/health" "port" "gotenberg-port")
|
||||||
|
"periodSeconds" 10
|
||||||
|
"timeoutSeconds" 5
|
||||||
|
"failureThreshold" 3 -}}
|
||||||
|
{{- include "gotenberg.defaultedDict" (dict "defaults" $defaults "given" . "path" "gotenberg.readinessProbe") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "gotenberg.startupProbe" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"httpGet" (dict "path" "/health" "port" "gotenberg-port")
|
||||||
|
"periodSeconds" 5
|
||||||
|
"timeoutSeconds" 5
|
||||||
|
"failureThreshold" 30 -}}
|
||||||
|
{{- include "gotenberg.defaultedDict" (dict "defaults" $defaults "given" . "path" "gotenberg.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 "gotenberg.effectiveResources" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"limitFactor" 3
|
||||||
|
"requests" (dict "cpu" 0.1 "memory" "512Mi" "ephemeralStorage" "10Mi") -}}
|
||||||
|
{{- $merged := fromYaml (include "gotenberg.defaultedDict" (dict "defaults" $defaults "given" . "path" "gotenberg.resources")) -}}
|
||||||
|
{{- include "gotenberg.resources" $merged -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -17,7 +17,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
automountServiceAccountToken: false
|
automountServiceAccountToken: false
|
||||||
securityContext:
|
securityContext:
|
||||||
{{- toYaml .Values.gotenberg.securityContext.pod | nindent 8 }}
|
{{- include "gotenberg.podSecurityContext" .Values.gotenberg.securityContext | nindent 8 }}
|
||||||
containers:
|
containers:
|
||||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
- name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||||
image: "gotenberg/gotenberg:{{ .Chart.AppVersion }}"
|
image: "gotenberg/gotenberg:{{ .Chart.AppVersion }}"
|
||||||
@@ -46,7 +46,7 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
securityContext:
|
securityContext:
|
||||||
{{- toYaml .Values.gotenberg.securityContext.container | nindent 12 }}
|
{{- include "gotenberg.containerSecurityContext" .Values.gotenberg.securityContext | nindent 12 }}
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
# Gotenberg (and the Chromium/LibreOffice it drives) needs writable
|
# Gotenberg (and the Chromium/LibreOffice it drives) needs writable
|
||||||
# /tmp and home directories; with a read-only root filesystem these
|
# /tmp and home directories; with a read-only root filesystem these
|
||||||
@@ -60,13 +60,13 @@ spec:
|
|||||||
protocol: TCP
|
protocol: TCP
|
||||||
name: gotenberg-port
|
name: gotenberg-port
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
{{- toYaml .Values.gotenberg.livenessProbe | nindent 12 }}
|
{{- include "gotenberg.livenessProbe" .Values.gotenberg.livenessProbe | nindent 12 }}
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
{{- toYaml .Values.gotenberg.readinessProbe | nindent 12 }}
|
{{- include "gotenberg.readinessProbe" .Values.gotenberg.readinessProbe | nindent 12 }}
|
||||||
startupProbe:
|
startupProbe:
|
||||||
{{- toYaml .Values.gotenberg.startupProbe | nindent 12 }}
|
{{- include "gotenberg.startupProbe" .Values.gotenberg.startupProbe | nindent 12 }}
|
||||||
resources:
|
resources:
|
||||||
{{- include "gotenberg.resources" .Values.gotenberg.resources | nindent 12 }}
|
{{- include "gotenberg.effectiveResources" .Values.gotenberg.resources | nindent 12 }}
|
||||||
volumes:
|
volumes:
|
||||||
- name: tmp
|
- name: tmp
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
|
|||||||
@@ -43,7 +43,8 @@
|
|||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"resources": {
|
"resources": {
|
||||||
"type": "object",
|
"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,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"limitFactor": {
|
"limitFactor": {
|
||||||
@@ -59,7 +60,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"resourceList": {
|
"resourceList": {
|
||||||
"type": "object",
|
"type": ["object", "null"],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"cpu": {
|
"cpu": {
|
||||||
@@ -118,22 +119,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"securityContext": {
|
"securityContext": {
|
||||||
"type": "object",
|
"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.",
|
||||||
|
"type": ["object", "null"],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"pod": {
|
"pod": {
|
||||||
"description": "Rendered verbatim as the pod's securityContext; kept open because the chart does not interpret it.",
|
"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"
|
"type": ["object", "null"]
|
||||||
},
|
},
|
||||||
"container": {
|
"container": {
|
||||||
"description": "Rendered verbatim as the container's securityContext; kept open because the chart does not interpret it.",
|
"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"
|
"type": ["object", "null"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"probe": {
|
"probe": {
|
||||||
"description": "Rendered verbatim as a container probe; kept open because the chart does not interpret it.",
|
"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.",
|
||||||
"type": "object"
|
"type": ["object", "null"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user