From c20ad389f2ad37db1668e86621ed2854233f697c Mon Sep 17 00:00:00 2001 From: jklein Date: Sun, 23 Aug 2026 13:24:28 +0200 Subject: [PATCH] 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 }}-, templates to ..yaml; the deployment label now matches its own selector. - mappings is required and must not be empty (the old default rendered "": ":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. --- README.md | 2 +- ci/minecraft-router/test-values.yaml | 7 +- minecraft-router/Chart.yaml | 22 +--- minecraft-router/templates/_helpers.tpl | 116 +++++++++++++++++ .../templates/mc-router-config.yaml | 15 --- .../templates/mc-router-deployment.yaml | 48 ------- .../templates/mc-router-service.yaml | 14 --- .../templates/minecraft-router.configmap.yaml | 22 ++++ .../minecraft-router.deployment.yaml | 79 ++++++++++++ .../templates/minecraft-router.service.yaml | 16 +++ minecraft-router/values.yaml | 118 +++++++++++++++++- 11 files changed, 358 insertions(+), 101 deletions(-) create mode 100644 minecraft-router/templates/_helpers.tpl delete mode 100644 minecraft-router/templates/mc-router-config.yaml delete mode 100644 minecraft-router/templates/mc-router-deployment.yaml delete mode 100644 minecraft-router/templates/mc-router-service.yaml create mode 100644 minecraft-router/templates/minecraft-router.configmap.yaml create mode 100644 minecraft-router/templates/minecraft-router.deployment.yaml create mode 100644 minecraft-router/templates/minecraft-router.service.yaml diff --git a/README.md b/README.md index 34fb3cf..13bf0f8 100644 --- a/README.md +++ b/README.md @@ -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) | | `homeassistant` | Home Assistant | StatefulSet, has `NOTES.txt` and Helm tests | | `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 | | `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 | diff --git a/ci/minecraft-router/test-values.yaml b/ci/minecraft-router/test-values.yaml index a200557..2cd8062 100644 --- a/ci/minecraft-router/test-values.yaml +++ b/ci/minecraft-router/test-values.yaml @@ -5,9 +5,10 @@ # 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 # would reject the Service. This is a CI-only override; the chart default -# stays 25565 (see minecraft-router/values.yaml). -# 2. mappings: the chart default renders "": ":25565"; the install -# works either way, but the CI install uses a realistic routing table. +# stays 25565 (see minecraft-router/values.yaml). server.port (the port the +# router itself listens on) keeps its default and is unaffected. +# 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: outwardPort: 30565 mappings: diff --git a/minecraft-router/Chart.yaml b/minecraft-router/Chart.yaml index d8aaaef..9aa11d1 100644 --- a/minecraft-router/Chart.yaml +++ b/minecraft-router/Chart.yaml @@ -1,24 +1,12 @@ apiVersion: v2 name: minecraft-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 -# This is the chart version. This version number should be incremented each time you make changes -# 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. +version: 1.0.0+up1.25.1 appVersion: "1.25.1" + +maintainers: + - name: jklein diff --git a/minecraft-router/templates/_helpers.tpl b/minecraft-router/templates/_helpers.tpl new file mode 100644 index 0000000..91d67cd --- /dev/null +++ b/minecraft-router/templates/_helpers.tpl @@ -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..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" "factor" . +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..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 -}} diff --git a/minecraft-router/templates/mc-router-config.yaml b/minecraft-router/templates/mc-router-config.yaml deleted file mode 100644 index aca901e..0000000 --- a/minecraft-router/templates/mc-router-config.yaml +++ /dev/null @@ -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 }} - } - } diff --git a/minecraft-router/templates/mc-router-deployment.yaml b/minecraft-router/templates/mc-router-deployment.yaml deleted file mode 100644 index 6056383..0000000 --- a/minecraft-router/templates/mc-router-deployment.yaml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/minecraft-router/templates/mc-router-service.yaml b/minecraft-router/templates/mc-router-service.yaml deleted file mode 100644 index c301527..0000000 --- a/minecraft-router/templates/mc-router-service.yaml +++ /dev/null @@ -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 - \ No newline at end of file diff --git a/minecraft-router/templates/minecraft-router.configmap.yaml b/minecraft-router/templates/minecraft-router.configmap.yaml new file mode 100644 index 0000000..9d74f56 --- /dev/null +++ b/minecraft-router/templates/minecraft-router.configmap.yaml @@ -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 }} diff --git a/minecraft-router/templates/minecraft-router.deployment.yaml b/minecraft-router/templates/minecraft-router.deployment.yaml new file mode 100644 index 0000000..ed6d7a5 --- /dev/null +++ b/minecraft-router/templates/minecraft-router.deployment.yaml @@ -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 diff --git a/minecraft-router/templates/minecraft-router.service.yaml b/minecraft-router/templates/minecraft-router.service.yaml new file mode 100644 index 0000000..f1ceee4 --- /dev/null +++ b/minecraft-router/templates/minecraft-router.service.yaml @@ -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 }} diff --git a/minecraft-router/values.yaml b/minecraft-router/values.yaml index 5ce1fd5..0f451aa 100644 --- a/minecraft-router/values.yaml +++ b/minecraft-router/values.yaml @@ -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: + # 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 # deliberately outside the Kubernetes default node-port range (30000-32767) - # 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. outwardPort: 25565 -mappings: - - domain: null - internalUrl: null \ No newline at end of file + # Port the router listens on for Minecraft client connections (mc-router + # --port); used as containerPort and as the Service's cluster-internal port. + 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: []