satisfactory: strict values.schema.json and null-safe hardening

No ingress gate: this chart has no Ingress at all (game and messaging
ports are exposed via NodePort), and ingress.domain appears neither in
its templates nor in its values.

1. values.schema.json (#68): additionalProperties: false on every
   chart-owned object level. Like teamspeak-server, this chart keeps env,
   probes and securityContext at the TOP level, and the schema mirrors
   that shape.

2. Null-safety for hardening, probes and resources (#99). What a rebuilt
   default must not lose here:
   - liveness and startup run the image's own healthcheck script via
     exec. It queries the server's HTTPS API on SERVERGAMEPORT, so it
     survives a port override, which a tcpSocket default could not.
   - readiness keeps the named "api" port.
   - the resources default keeps limitFactor 2 rather than the repo-wide
     3, which is what holds the memory limit at the published 8Gi.
   - the pod context keeps fsGroupChangePolicy OnRootMismatch, which is
     what avoids a recursive chown of multi-GB game files on every start.

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

This chart is excluded from the CI install-test, so the local proof is
the only one. Rendering is byte-identical to 4.0.0 with default values
and with the production bundle values, and the rendered manifests were
additionally validated by the cluster's API server via a server-side
dry-run.
This commit is contained in:
jklein committed 2026-08-23 21:28:52 +02:00
1 parent 81549d6aab
commit 290c69cc7d
4 files changed
+359 -7

No files matched your search

+170
View File
@@ -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": 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 "satisfactory.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 "satisfactory.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.securityContext" can itself be erased.
*/}}
{{- define "satisfactory.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.
Like teamspeak-server, this chart keeps these blocks at the TOP level of
values.yaml, so the paths in the error messages have no chart-name prefix.
Probe specifics that a rebuilt default must not lose:
- liveness and startup run the image's own healthcheck script via exec. It
queries the server's HTTPS API on SERVERGAMEPORT, so it keeps working when
the port is overridden - which a tcpSocket default could not do.
- readiness uses the named "api" port.
- the startup budget is deliberately huge (30s x 40 = 20 minutes): the
server downloads and updates the game files via Steam on first start.
*/}}
{{- define "satisfactory.podSecurityContext" -}}
{{- $given := (fromYaml (include "satisfactory.subBlock" (dict "block" . "key" "pod" "path" "securityContext"))).value -}}
{{- $defaults := dict
"runAsNonRoot" true
"runAsUser" 1000
"runAsGroup" 1000
"fsGroup" 1000
"fsGroupChangePolicy" "OnRootMismatch" -}}
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" $given "path" "securityContext.pod") -}}
{{- end -}}
{{- define "satisfactory.containerSecurityContext" -}}
{{- $given := (fromYaml (include "satisfactory.subBlock" (dict "block" . "key" "container" "path" "securityContext"))).value -}}
{{- $defaults := dict
"allowPrivilegeEscalation" false
"readOnlyRootFilesystem" true
"runAsNonRoot" true
"capabilities" (dict "drop" (list "ALL") "add" (list)) -}}
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" $given "path" "securityContext.container") -}}
{{- end -}}
{{- define "satisfactory.healthcheckExec" -}}
{{- dict "command" (list "/bin/sh" "-c" "/home/steam/healthcheck.sh") | toYaml -}}
{{- end -}}
{{- define "satisfactory.livenessProbe" -}}
{{- $defaults := dict
"exec" (fromYaml (include "satisfactory.healthcheckExec" .))
"failureThreshold" 4
"successThreshold" 1
"periodSeconds" 30
"timeoutSeconds" 10 -}}
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" . "path" "livenessProbe") -}}
{{- end -}}
{{- define "satisfactory.readinessProbe" -}}
{{- $defaults := dict
"tcpSocket" (dict "port" "api")
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" . "path" "readinessProbe") -}}
{{- end -}}
{{- define "satisfactory.startupProbe" -}}
{{- $defaults := dict
"exec" (fromYaml (include "satisfactory.healthcheckExec" .))
"periodSeconds" 30
"timeoutSeconds" 10
"failureThreshold" 40 -}}
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" . "path" "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 limitFactor default stays 2 here (not the repo-wide 3): the game server
holds a large world in memory and the factor keeps the memory limit at the
previously published 8Gi.
*/}}
{{- define "satisfactory.effectiveResources" -}}
{{- $defaults := dict
"limitFactor" 2
"requests" (dict "memory" "4Gi" "cpu" "1" "ephemeral-storage" "5Gi") -}}
{{- $merged := fromYaml (include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" . "path" "server.resources")) -}}
{{- include "satisfactory.resources" $merged -}}
{{- end -}}
+6 -6
View File
@@ -17,7 +17,7 @@ spec:
spec:
automountServiceAccountToken: false
securityContext:
{{- toYaml .Values.securityContext.pod | nindent 8 }}
{{- include "satisfactory.podSecurityContext" .Values.securityContext | nindent 8 }}
containers:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
image: "ghcr.io/wolveix/satisfactory-server:{{ .Chart.AppVersion }}"
@@ -73,13 +73,13 @@ spec:
containerPort: {{ .Values.server.messagingPort }}
protocol: TCP
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
{{- include "satisfactory.livenessProbe" .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
{{- include "satisfactory.readinessProbe" .Values.readinessProbe | nindent 12 }}
startupProbe:
{{- toYaml .Values.startupProbe | nindent 12 }}
{{- include "satisfactory.startupProbe" .Values.startupProbe | nindent 12 }}
resources:
{{- include "satisfactory.resources" .Values.server.resources | nindent 12 }}
{{- include "satisfactory.effectiveResources" .Values.server.resources | nindent 12 }}
volumeMounts:
- name: satisfactory-volume
mountPath: /config
@@ -104,7 +104,7 @@ spec:
- name: tmp
mountPath: /tmp
securityContext:
{{- toYaml .Values.securityContext.container | nindent 12 }}
{{- include "satisfactory.containerSecurityContext" .Values.securityContext | nindent 12 }}
volumes:
- name: satisfactory-volume
persistentVolumeClaim: