Minecraft-router chart: naming scheme, hardening, probes, values interface

Stage 2 of the Fleet migration for minecraft-router: bring the chart up to the
repo style guidelines. Chart 0.1.1+up1.25.1 -> 1.0.0+up1.25.1 (major: resource
renames, mappings becomes required, probes and security hardening change the
runtime behavior; appVersion unchanged).

- Resources renamed to {{ .Release.Name }}-{{ .Chart.Name }}-<kind>, templates
  to <chart>.<kind>.yaml; the deployment label now matches its own selector.
- mappings is required and must not be empty (the old default rendered
  "<nil>": "<nil>:25565"); every entry needs domain and internalUrl.
- Ports come from values: server.port (router listen port, also passed as
  --port), server.backendPort (default backend port, overridable per mapping),
  server.apiPort (--api-binding, used by the probes). Defaults stay 25565 /
  25565 / 8080, server.outwardPort keeps its 25565 default and comment.
- _helpers.tpl added (resources/limitFactor and replicas/scaleDown helpers),
  resources come from values, no CPU limit.
- replicas value plus the chart-global scaleDown flag.
- Additive env block rendered through tpl (value / secretKeyRef /
  configMapKeyRef).
- Values-configurable securityContext.pod / .container with hardened defaults:
  non-root uid/gid 10000, readOnlyRootFilesystem, no privilege escalation, all
  capabilities dropped.
- Liveness, readiness and startup probes on the mc-router API: GET /routes with
  an "Accept: application/json" header (the API router matches on that header
  and answers 404 without it).
- icon added to Chart.yaml.
- CI fixture supplies mappings; README chart table entry updated.
This commit is contained in:
jklein committed 2026-08-23 15:14:49 +02:00
1 parent 0e3ba0a92f
commit c20ad389f2
11 files changed
+358 -101

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo
| `gotenberg` | Gotenberg | Deployment + Service only (no ingress) | | `gotenberg` | Gotenberg | Deployment + Service only (no ingress) |
| `homeassistant` | Home Assistant | StatefulSet, has `NOTES.txt` and Helm tests | | `homeassistant` | Home Assistant | StatefulSet, has `NOTES.txt` and Helm tests |
| `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service | | `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service |
| `minecraft-router` | itzg/mc-router | Deployment + routing ConfigMap + NodePort Service (TCP only, no ingress); pre-existing chart, still on its legacy conventions | | `minecraft-router` | itzg/mc-router | Deployment + routing ConfigMap + NodePort Service (TCP only, no ingress); `mappings` is a required value |
| `outline` | Outline | StatefulSet, separate DB/Redis secrets | | `outline` | Outline | StatefulSet, separate DB/Redis secrets |
| `paperless-ngx` | Paperless-ngx | Largest chart: consume CronJob, SMB-connector PV/PVC, pre/post-consumption scripts ConfigMap | | `paperless-ngx` | Paperless-ngx | Largest chart: consume CronJob, SMB-connector PV/PVC, pre/post-consumption scripts ConfigMap |
| `patchmon` | PatchMon | Two Deployments (frontend + backend), OIDC secret | | `patchmon` | PatchMon | Two Deployments (frontend + backend), OIDC secret |
+4 -3
View File
@@ -5,9 +5,10 @@
# default node-port range (30000-32767). The real cluster runs an extended # default node-port range (30000-32767). The real cluster runs an extended
# --service-node-port-range, the k3d CI cluster does not, so the API server # --service-node-port-range, the k3d CI cluster does not, so the API server
# would reject the Service. This is a CI-only override; the chart default # would reject the Service. This is a CI-only override; the chart default
# stays 25565 (see minecraft-router/values.yaml). # stays 25565 (see minecraft-router/values.yaml). server.port (the port the
# 2. mappings: the chart default renders "<nil>": "<nil>:25565"; the install # router itself listens on) keeps its default and is unaffected.
# works either way, but the CI install uses a realistic routing table. # 2. mappings: required value without a default - a router without routes is
# not operable, so the CI install has to supply a routing table.
server: server:
outwardPort: 30565 outwardPort: 30565
mappings: mappings:
+5 -17
View File
@@ -1,24 +1,12 @@
apiVersion: v2 apiVersion: v2
name: minecraft-router name: minecraft-router
description: A Helm chart for deploying a itzg/mc-router description: A Helm chart for deploying a itzg/mc-router
icon: https://avatars.githubusercontent.com/u/988985
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application type: application
# This is the chart version. This version number should be incremented each time you make changes version: 1.0.0+up1.25.1
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.1+up1.25.1
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.25.1" appVersion: "1.25.1"
maintainers:
- name: jklein
+116
View File
@@ -0,0 +1,116 @@
{{/*
Render the body of a container "resources:" block from a values dict of the form
resources:
limitFactor: 3 # optional, default 3
requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... }
limits: { ... } # optional, every entry optional
Rules (variant B, see issue #19):
- requests are rendered as given (camelCase "ephemeralStorage" is normalized
to the Kubernetes key "ephemeral-storage").
- an explicitly set limit always wins.
- a missing memory / ephemeral-storage limit is computed as
limitFactor x the corresponding request.
- a missing cpu limit is NOT set at all (the kubelet then treats CPU as
unlimited; CPU is compressible, so this cannot kill the workload).
Usage (the values path to the resources dict varies per chart):
resources:
{{- include "minecraft-router.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
*/}}
{{- define "minecraft-router.resources" -}}
{{- $resources := . | default dict -}}
{{- $factor := 3.0 -}}
{{- if not (kindIs "invalid" $resources.limitFactor) -}}
{{- $factor = float64 $resources.limitFactor -}}
{{- end -}}
{{- $requests := dict -}}
{{- range $key, $value := ($resources.requests | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $requests (include "minecraft-router.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- $limits := dict -}}
{{- range $key, $value := ($resources.limits | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $limits (include "minecraft-router.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- range $key, $request := $requests -}}
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
{{- $_ := set $limits $key (include "minecraft-router.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}}
{{- end -}}
{{- end -}}
{{- $out := dict -}}
{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}}
{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}}
{{- toYaml $out -}}
{{- end -}}
{{/*
Normalize a resource name from values to its Kubernetes key.
*/}}
{{- define "minecraft-router.resourceKey" -}}
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
{{- end -}}
{{/*
Multiply a Kubernetes quantity by a factor, keeping its suffix.
Input: dict "quantity" <string|number> "factor" <float64>.
Handles plain numbers ("2", "0.5", 1073741824), decimal ("1.5Gi"), binary
("512Mi", "1Gi", "10Ki") and milli/SI suffixes ("100m", "500M"). Integer
results are printed without a decimal point.
*/}}
{{- define "minecraft-router.multiplyQuantity" -}}
{{- $quantity := printf "%v" .quantity -}}
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
{{- if not $number -}}
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
{{- end -}}
{{- $suffix := trimPrefix $number $quantity -}}
{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}}
{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}}
{{- end -}}
{{- $product := mulf (float64 $number) .factor -}}
{{- if eq $product (floor $product) -}}
{{- printf "%d%s" (int64 $product) $suffix -}}
{{- else -}}
{{- printf "%v%s" $product $suffix -}}
{{- end -}}
{{- end -}}
{{/*
Render the replica count for a Deployment/StatefulSet.
Input: dict
"scaleDown" the chart-global .Values.scaleDown flag (may be nil)
"replicas" the workload's replicas value (may be nil)
Rules (issue #25):
- scaleDown: true takes precedence and always yields 0.
- otherwise an explicitly set replicas value is used as given, including an
explicit 0 — Helm's "default" would swallow 0, hence the explicit
kindIs "invalid" nil check.
- a missing replicas value defaults to 1.
Usage (the values path to the workload's replicas varies per chart):
replicas: {{ include "minecraft-router.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.<app>.replicas) }}
NOTE: like the resources helper above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix. This file in
template-chart is the reference implementation.
*/}}
{{- define "minecraft-router.replicas" -}}
{{- if .scaleDown -}}
0
{{- else if not (kindIs "invalid" .replicas) -}}
{{- .replicas -}}
{{- else -}}
1
{{- end -}}
{{- end -}}
@@ -1,15 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-config
data:
custom-routing-config.json: |
{
"mappings": {
{{- $length := len .Values.mappings }}
{{- $indexLength := sub $length 1 }}
{{- range $index, $value := .Values.mappings }}
"{{ $value.domain }}": "{{ $value.internalUrl }}:25565"{{ if lt $index $indexLength }},{{ end }}
{{- end }}
}
}
@@ -1,48 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-deployment
labels:
app: {{ .Release.Name }}
spec:
replicas: 1
selector:
matchLabels:
app: {{ .Release.Name }}-deployment
template:
metadata:
name: {{ .Release.Name }}-deployment
labels:
app: {{ .Release.Name }}-deployment
spec:
automountServiceAccountToken: false
containers:
- image: "itzg/mc-router:{{ .Chart.AppVersion }}"
name: mc-router
args: ["--api-binding", ":8080"]
ports:
- name: proxy-port
containerPort: 25565
- name: api-port
containerPort: 8080
env:
- name: ROUTES_CONFIG
value: /data/custom-routing-config.json
resources:
requests:
memory: 50Mi
cpu: "100m"
ephemeral-storage: "50Mi"
limits:
memory: 100Mi
cpu: "250m"
ephemeral-storage: "500Mi"
volumeMounts:
- name: config-mount
mountPath: /data/custom-routing-config.json
subPath: custom-routing-config.json
volumes:
- name: config-mount
configMap:
name: {{ .Release.Name }}-config
@@ -1,14 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-service
spec:
selector:
app: {{ .Release.Name }}-deployment
ports:
- nodePort: {{ .Values.server.outwardPort }}
port: 25565
targetPort: 25565
name: proxy-port
type: NodePort
@@ -0,0 +1,22 @@
{{- $message := "minecraft-router: .Values.mappings is required and must not be empty - set at least one mapping with a domain and an internalUrl (see values.yaml)" -}}
{{- $mappings := required $message .Values.mappings -}}
{{- if and (not (kindIs "invalid" $mappings)) (or (not (kindIs "slice" $mappings)) (empty $mappings)) }}{{- fail $message -}}{{- end -}}
{{- $routes := dict -}}
{{/* "helm lint" reports a missing required value but keeps rendering, so the
loop has to tolerate the nil it hands back; "helm template" and "helm
install" abort on the required above. */}}
{{- range $index, $mapping := ($mappings | default list) -}}
{{- $domain := required (printf "minecraft-router: .Values.mappings[%d].domain is required" $index) $mapping.domain -}}
{{- $internalUrl := required (printf "minecraft-router: .Values.mappings[%d].internalUrl is required" $index) $mapping.internalUrl -}}
{{- $port := $mapping.port | default $.Values.server.backendPort -}}
{{- $_ := set $routes $domain (printf "%v:%v" $internalUrl $port) -}}
{{- end -}}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-configmap
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-configmap
data:
custom-routing-config.json: |
{{ dict "mappings" $routes | toPrettyJson | indent 4 }}
@@ -0,0 +1,79 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
replicas: {{ include "minecraft-router.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.server.replicas) }}
selector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
template:
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
automountServiceAccountToken: false
securityContext:
{{- toYaml .Values.server.securityContext.pod | nindent 8 }}
containers:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
image: "itzg/mc-router:{{ .Chart.AppVersion }}"
imagePullPolicy: IfNotPresent
args:
- "--port"
- "{{ .Values.server.port }}"
- "--api-binding"
- ":{{ .Values.server.apiPort }}"
env:
- name: ROUTES_CONFIG
value: /data/custom-routing-config.json
{{- range $value := .Values.server.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 }}
ports:
- name: proxy-port
protocol: TCP
containerPort: {{ .Values.server.port }}
- name: api-port
protocol: TCP
containerPort: {{ .Values.server.apiPort }}
livenessProbe:
{{- toYaml .Values.server.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.server.readinessProbe | nindent 12 }}
startupProbe:
{{- toYaml .Values.server.startupProbe | nindent 12 }}
resources:
{{- include "minecraft-router.resources" .Values.server.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.server.securityContext.container | nindent 12 }}
volumeMounts:
- name: config-mount
mountPath: /data/custom-routing-config.json
subPath: custom-routing-config.json
readOnly: true
volumes:
- name: config-mount
configMap:
name: {{ .Release.Name }}-{{ .Chart.Name }}-configmap
restartPolicy: Always
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-service
spec:
type: NodePort
selector:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
ports:
- name: proxy-port
protocol: TCP
port: {{ .Values.server.port }}
targetPort: proxy-port
nodePort: {{ .Values.server.outwardPort }}
+115 -3
View File
@@ -1,9 +1,121 @@
# Scale the whole chart down without uninstalling it: scaleDown: true renders
# every Deployment/StatefulSet with replicas: 0 (and suspends CronJobs) and
# takes precedence over any per-workload replicas value. PVCs, Secrets and
# Services stay in place; scaleDown: false brings the workloads back up.
scaleDown: false
# Routing table of the router: which client-facing hostname is proxied to which
# backend Minecraft server. Required and must not be empty - a router without
# routes is not operable, and there is no sensible default.
# Every entry needs "domain" and "internalUrl"; the optional "port" overrides
# server.backendPort for that single backend:
# mappings:
# - domain: mc.example.com
# internalUrl: survival-service.minecraft.svc.cluster.local
# - domain: creative.example.com
# internalUrl: creative-service.minecraft.svc.cluster.local
# port: 25566
mappings: null
server: server:
# Replica count for this workload; 0 is allowed and scales it down.
# The chart-global scaleDown flag takes precedence.
replicas: 1
# nodePort of the router Service. 25565 is the Minecraft default port and is # nodePort of the router Service. 25565 is the Minecraft default port and is
# deliberately outside the Kubernetes default node-port range (30000-32767) - # deliberately outside the Kubernetes default node-port range (30000-32767) -
# the cluster runs an extended --service-node-port-range. Do NOT "fix" this # the cluster runs an extended --service-node-port-range. Do NOT "fix" this
# into the default range: the Minecraft clients connect to this exact port. # into the default range: the Minecraft clients connect to this exact port.
outwardPort: 25565 outwardPort: 25565
mappings: # Port the router listens on for Minecraft client connections (mc-router
- domain: null # --port); used as containerPort and as the Service's cluster-internal port.
internalUrl: null port: 25565
# Default port of the backend Minecraft servers, used for every mapping that
# does not carry its own "port".
backendPort: 25565
# Port of the mc-router HTTP API (--api-binding). Not exposed by the Service;
# only the probes talk to it.
apiPort: 8080
resources:
# Limits are optional: memory and ephemeral-storage limits default to
# limitFactor x the request, an explicitly set limit always wins, and a
# CPU limit is only set when configured explicitly under "limits".
limitFactor: 3
requests:
cpu: 100m
memory: 50Mi
ephemeral-storage: 50Mi
# The probes query the mc-router API. GET /routes answers with the live
# routing table, but only with the "Accept: application/json" header - the
# API router matches on it and answers 404 without it. The API server is
# started after the routes config has been read and the Minecraft listener is
# up, so a 200 here means the router is actually serving clients.
livenessProbe:
httpGet:
path: /routes
port: api-port
httpHeaders:
- name: Accept
value: application/json
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /routes
port: api-port
httpHeaders:
- name: Accept
value: application/json
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness/readiness. mc-router is a single static Go
# binary that reads one config file and serves the API in well under a
# second; budget = periodSeconds * failureThreshold = 30s.
startupProbe:
httpGet:
path: /routes
port: api-port
httpHeaders:
- name: Accept
value: application/json
periodSeconds: 2
timeoutSeconds: 5
failureThreshold: 15
securityContext:
pod:
runAsNonRoot: true
# The itzg/mc-router image is built FROM scratch and declares no user, so
# it would run as root by default. The statically linked binary needs no
# particular uid/gid, so the chart pins an unprivileged one.
runAsUser: 10000
runAsGroup: 10000
# Keeps the mounted routing-config ConfigMap readable for that gid.
fsGroup: 10000
fsGroupChangePolicy: Always
container:
allowPrivilegeEscalation: false
# No deviation needed: the scratch image writes nothing, and the only
# mount is the read-only routing config.
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
add: []
# Extra environment variables for the router container, appended to the ones
# the chart always sets (ROUTES_CONFIG). Values are rendered through tpl and
# support the value, secretKeyRef and configMapKeyRef forms:
# - name: SOME_ENV
# value: "{{ .Release.Name }}"
# - name: FROM_SECRET
# valueFrom:
# secretKeyRef:
# name: "some-secret"
# key: "someKey"
# - name: FROM_CONFIGMAP
# valueFrom:
# configMapKeyRef:
# name: "some-configmap"
# key: "someKey"
env: []