Files
helm-charts/minecraft-server/templates/minecraft-server.sfs.yaml
T
jklein fc8f57e176 minecraft-server: null-safe hardening, probes and resources
The last open gap of #99. minecraft-server was the one chart of 21 where an
erased security context removed the hardening in silence: it never got the
defaultedDict helper, the StatefulSet rendered .Values.securityContext.pod
straight through toYaml, and the schema typed securityContext.pod as a strict
"object" without required. Measured against ci/minecraft-server/test-values.yaml
on 3.0.1:

  --set securityContext.pod=null --set securityContext.container=null
  -> helm exit 0, no schema error, "securityContext: null" for BOTH the pod and
     the container - no runAsNonRoot, no readOnlyRootFilesystem, no dropped
     capabilities, in a chart with a PVC.

Bitter place for it: the README lists this chart as the pilot for #68. It got
its schema early and was therefore never caught by the later null-safety wave.

Both halves are added, because they cover different cases. For a key WITH a
chart default Helm's coalescing deletes the null before validation ever sees
it, and only the defaultedDict fallback catches that; for a key WITHOUT one
(server.resources.limits) the null survives into validation, where the strict
"object" aborted the render. Now widened to ["object","null"].

The fallback table reproduces THIS chart's defaults, deviations included:
uid/gid pinned to 1000 (the image's minecraft user - starting as it directly
takes the entrypoint's non-root branch, which skips the recursive chown of
/data and the write to /etc/nsswitch.conf that a read-only root filesystem
would reject), fsGroupChangePolicy OnRootMismatch (a recursive chown of a 32Gi
world on every start costs minutes), the explicit empty capabilities.add list,
and all three probes as exec probes running the image's mc-health script - this
image has no HTTP endpoint, so a probe default rebuilt as httpGet would leave
the workload with no working probe at all. The startup budget stays 15s x 80.

server.resources goes through the same helper while keeping its memory request
deliberately unset, so it is still derived from the JVM heap.

Also, the same class one level down: init.sh chowned the migrated serverpack to
a hardcoded 1000:1000 while the uid it has to match was overridable. Both reads
now go through the same helper, so they cannot disagree. With the chart defaults
the rendered script is unchanged, so checksum/config does not move and no
existing pod rolls.

minecraft.resources.storage and .memory stay deliberately not null-safe: an
erased value yields an invalid PVC or an unparseable quantity, which is the loud
class #99 excludes.

Evidence, all against ci/minecraft-server/test-values.yaml unless noted:

  - the reported call now renders the defaults, byte-identical to the
    unmodified rendering. Fallback, not rejection.
  - rendering with securityContext, all three probes and server.resources set
    to null is byte-identical to rendering the chart defaults - which is what
    keeps the table and values.yaml in sync instead of leaving it to discipline.
  - no regression: rendering before and after the change is byte-identical, on
    the CI values, on the chart defaults, and with curseforgeServerpack enabled
    (the init-container path the CI values do not exercise). Confirmed
    structurally too, not only by raw diff.
  - partial nulling, 14 keys swept one at a time. On 3.0.1 twelve of them
    changed the rendering silently, including capabilities (the container kept
    every default capability instead of dropping ALL) and requests (cpu and
    ephemeral-storage disappeared entirely). On 4.0.0 all fourteen render
    identical to the defaults.
  - explicit false and 0 still win over the defaults; a scalar in a mapping slot
    is still rejected, now naming the values path.

Major bump: erasing one of these blocks used to change the rendered workload
and now does not, which is a runtime behaviour change. appVersion is untouched.

Refs #99
2026-08-24 16:53:25 +02:00

239 lines
11 KiB
YAML

{{- include "minecraft-server.validateModpackSources" . }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
spec:
replicas: {{ include "minecraft-server.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.server.replicas) }}
selector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
template:
metadata:
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
annotations:
# Rolls the pod whenever the rendered ConfigMap changes. init.sh is
# mounted with subPath (kubelet never refreshes those in a running
# container) and the init container only reads it at pod start, so
# without this a changed download URL or working directory would stay
# inert while the release still reported success.
checksum/config: {{ include (print $.Template.BasePath "/minecraft-server.configmap.yaml") . | sha256sum }}
# Deliberately a SEPARATE annotation, and deliberately NOT a hash of the
# secret's content: the Kubernetes Secret is materialized by the
# 1Password operator, so Helm never sees what is in it. This hash covers
# the rendered OnePasswordItem, i.e. the op:// item path - repointing
# secrets.minecraftServerRef at a different item now takes effect
# instead of silently leaving the old credentials in the running pod.
# A change of the item's CONTENT inside the vault is not covered; see
# the comment on secrets in values.yaml for why that gap cannot be
# closed here and what it means operationally.
checksum/secret-ref: {{ include (print $.Template.BasePath "/minecraft-server.secret.yaml") . | sha256sum }}
spec:
automountServiceAccountToken: false
securityContext:
{{- include "minecraft-server.podSecurityContext" .Values.securityContext | nindent 8 }}
{{- if .Values.curseforgeServerpack.enabled }}
initContainers:
- name: download-serverpack
image: {{ .Values.server.initImage }}
command: [ 'sh', '/init/init.sh' ]
securityContext:
{{- include "minecraft-server.containerSecurityContext" .Values.securityContext | nindent 12 }}
volumeMounts:
- name: "mc-volume"
mountPath: /modpacks
subPath: mc-modpacks/
- name: "mc-volume"
mountPath: /data
subPath: mc-data/
- name: init-script
mountPath: /init/init.sh
subPath: init.sh
- name: tmp
mountPath: /tmp
{{- end }}
containers:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
image: "itzg/minecraft-server:{{ .Chart.AppVersion }}"
imagePullPolicy: IfNotPresent
securityContext:
{{- include "minecraft-server.containerSecurityContext" .Values.securityContext | nindent 12 }}
ports:
- name: mc-port
containerPort: {{ .Values.server.port }}
protocol: TCP
{{- if .Values.server.rcon.enabled }}
- name: minecraft-rcon
containerPort: {{ .Values.server.rcon.port }}
protocol: TCP
{{- end }}
- name: metrics
containerPort: 19565
protocol: TCP
env:
- name: EULA
value: "true"
- name: DUMP_SERVER_PROPERTIES
value: "true"
- name: MOTD
value: "{{ .Values.server.motd }}"
- name: DIFFICULTY
value: {{ .Values.minecraft.difficulty }}
- name: SERVER_PORT
value: "{{ .Values.server.port }}"
# ENABLE_WHITELIST is set in both positions on purpose - it is the
# only variable that can switch the whitelist OFF again. The image
# enables the whitelist when WHITELIST/WHITELIST_FILE is non-empty
# OR ENABLE_WHITELIST is true, and disables it (white-list=false)
# when ENABLE_WHITELIST is false, rewriting an existing
# server.properties on the volume. Because a non-empty WHITELIST
# wins over ENABLE_WHITELIST=false, players must stay unset while
# disabled - hence the guard below rather than emitting both.
- name: ENABLE_WHITELIST
value: "{{ .Values.minecraft.whitelist.enabled }}"
# enforce-whitelist is mapped from ENFORCE_WHITELIST in the image's
# property-definitions.json. The image only ever sets it to true (in
# its enable branch) and never resets it, so without this line a
# server that once had the whitelist on keeps enforce-whitelist=true
# in its persisted server.properties after switching off. It is
# inert while white-list=false, but leaving stale state behind is
# exactly what this chart version fixes, so it follows the switch.
- name: ENFORCE_WHITELIST
value: "{{ .Values.minecraft.whitelist.enabled }}"
{{- if .Values.minecraft.whitelist.enabled }}
- name: EXISTING_WHITELIST_FILE
value: {{ .Values.minecraft.whitelist.overridePolicy }}
{{- with .Values.minecraft.whitelist.players }}
- name: WHITELIST
value: "{{ . }}"
{{- end }}
{{- end }}
{{- with .Values.minecraft.management.opPlayers }}
- name: OPS
value: "{{ . }}"
{{- end }}
- name: EXISTING_OPS_FILE
value: "SYNCHRONIZE"
- name: MAX_PLAYERS
value: "{{ .Values.minecraft.management.maxPlayers }}"
- name: FORCE_GAMEMODE
value: "{{ .Values.minecraft.management.forceGameMode }}"
- name: SNOOPER_ENABLED
value: "false"
- name: MODE
value: "{{ .Values.minecraft.management.gameMode }}"
- name: LEVEL
value: "{{ .Values.minecraft.management.worldSaveName }}"
- name: SERVER_NAME
value: "{{ .Values.minecraft.management.serverName }}"
- name: VERSION
value: "{{ .Values.minecraft.version }}"
- name: ENABLE_RCON
value: "{{ .Values.server.rcon.enabled }}"
{{- if .Values.server.rcon.enabled }}
- name: RCON_PORT
value: "{{ .Values.server.rcon.port }}"
- name: RCON_PASSWORD
valueFrom:
secretKeyRef:
key: rcon-password
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
{{- end }}
- name: ENABLE_AUTOPAUSE
value: "false"
- name: MEMORY
value: "{{ .Values.minecraft.resources.memory }}G"
{{- if .Values.curseforge.enabled }}
- name: TYPE
value: "AUTO_CURSEFORGE"
- name: CF_API_KEY
valueFrom:
secretKeyRef:
key: curseforge-api-key
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
- name: CF_SLUG
value: "{{ .Values.curseforge.modpack.slug }}"
- name: CF_FILE_ID
value: "{{ .Values.curseforge.modpack.versionId }}"
- name: CF_EXCLUDE_MODS
value: "{{ .Values.curseforge.modpack.exclude }}"
- name: CF_OVERRIDES_EXCLUSIONS
value: "{{ .Values.curseforge.modpack.excludeOverride }}"
- name: CF_FORCE_INCLUDE_MODS
value: "{{ .Values.curseforge.modpack.include }}"
- name: CF_FORCE_SYNCHRONIZE
value: "{{ .Values.curseforge.forceReload }}"
{{- end }}
{{- if .Values.curseforgeServerpack.enabled }}
- name: TYPE
value: "CURSEFORGE"
- name: CF_SERVER_MOD
value: "/modpacks/serverpack.zip"
- name: CF_BASE_DIR
value: "{{ .Values.curseforgeServerpack.workingDirectory }}"
{{- end }}
{{- if .Values.ftb.enabled }}
- name: TYPE
value: FTBA
- name: FTB_MODPACK_ID
value: "{{ .Values.ftb.modpackId }}"
- name: FTB_MODPACK_VERSION_ID
value: "{{ .Values.ftb.versionId }}"
{{- end }}
{{- range $value := .Values.env }}
{{- if $value.value }}
- name: {{ $value.name }}
value: {{ tpl $value.value $ }}
{{- else if $value.valueFrom }}
{{- if $value.valueFrom.secretKeyRef }}
- name: {{ $value.name }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $ }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $ }}
{{- else if $value.valueFrom.configMapKeyRef }}
- name: {{ $value.name }}
valueFrom:
configMapKeyRef:
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ }}
{{- end }}
{{- end }}
{{- end }}
livenessProbe:
{{- include "minecraft-server.livenessProbe" .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- include "minecraft-server.readinessProbe" .Values.readinessProbe | nindent 12 }}
startupProbe:
{{- include "minecraft-server.startupProbe" .Values.startupProbe | nindent 12 }}
resources:
{{- include "minecraft-server.serverResources" . | nindent 12 }}
volumeMounts:
- name: "mc-volume"
mountPath: /data
subPath: mc-data/
- name: "mc-volume"
mountPath: /modpacks
subPath: mc-modpacks/
# Writable path required by readOnlyRootFilesystem: the image writes
# its server-icon scratch file and mc-server-runner's console named
# pipe to /tmp. Everything else goes to the PVC at /data.
- name: tmp
mountPath: /tmp
volumes:
- name: "mc-volume"
persistentVolumeClaim:
# Deliberate exception from the naming scheme: renaming the PVC
# would make Kubernetes provision a new, empty volume and orphan
# the existing world (see README, naming exceptions).
claimName: {{ .Release.Name }}-mc-volume-claim
- name: init-script
configMap:
name: {{ .Release.Name }}-{{ .Chart.Name }}-configmap
- name: tmp
emptyDir: {}