From 6ae380e8ec46cf8f212f55b67a457d81f60f36cc Mon Sep 17 00:00:00 2001 From: jklein Date: Sat, 29 Aug 2026 00:58:00 +0200 Subject: [PATCH] teamspeak-server: NOTES.txt and the exponential-quantity fix (5.1.0+up6.0.0.beta12.1) Teil von #157 und Teil von #43. #157: multiplyQuantity turned its input into a string with printf "%v". The null-safe fallback helpers from #99 route values through fromYaml, which parses every number as float64, and Go switches float64 to exponential notation from about 1e6 upwards. A request of 1048576 therefore reached the suffix check as "1.048576e+06" and aborted the render. The second printf "%v", the one that prints a non-integer product, never reaches that check at all: with a fractional limitFactor it wrote an invalid quantity into the manifest without a word. Both sites now go through the canonical teamspeak-server.formatNumber helper, identical to the other charts. Note for this chart: values.schema.json types resources.limitFactor as "number", not "integer", so the silent path is reachable with ordinary values rather than only with --set-json. #43: adds templates/NOTES.txt. teamspeak-server is a NodePort service with no Ingress and no secrets at all, so the notes cover what is actually worth looking up. Measured against the pinned image 6.0.0-beta12.1 rather than taken from upstream docs: the first start prints a ServerAdmin privilege key and ServerQuery credentials exactly once, and only the privilege key is recoverable afterwards from the log files on the data volume; TSSERVER_ DEFAULT_PORT is read only when the virtual server is created, so changing server.voicePort on an existing release moves the Service, the nodePort and the containerPort while the server keeps answering on the old port, with all three probes staying green because they only ever TCP-connect to the file transfer port; TSSERVER_FILE_TRANSFER_PORT, by contrast, applies on every start; the bundled license runs 2026-07-01 to 2026-10-01 with one virtual server and 32 slots; and no query port listens at all, which leaves the declared web-query container port 10080 bound by nothing. Minor bump: additive, appVersion unchanged. --- teamspeak-server/Chart.yaml | 2 +- teamspeak-server/templates/NOTES.txt | 241 ++++++++++++++++++++++++ teamspeak-server/templates/_helpers.tpl | 21 ++- 3 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 teamspeak-server/templates/NOTES.txt diff --git a/teamspeak-server/Chart.yaml b/teamspeak-server/Chart.yaml index 22b699f..0f31405 100644 --- a/teamspeak-server/Chart.yaml +++ b/teamspeak-server/Chart.yaml @@ -5,5 +5,5 @@ icon: https://avatars.githubusercontent.com/u/136759148 type: application -version: 5.0.0+up6.0.0.beta12.1 +version: 5.1.0+up6.0.0.beta12.1 appVersion: "6.0.0-beta12.1" diff --git a/teamspeak-server/templates/NOTES.txt b/teamspeak-server/templates/NOTES.txt new file mode 100644 index 0000000..4566f25 --- /dev/null +++ b/teamspeak-server/templates/NOTES.txt @@ -0,0 +1,241 @@ +{{- $replicas := include "teamspeak-server.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.replicas) -}} +{{- $res := fromYaml (include "teamspeak-server.effectiveResources" .Values.resources) -}} +{{- $eph := index ($res.limits | default dict) "ephemeral-storage" | default "the computed ephemeral-storage limit" -}} +{{- $startup := fromYaml (include "teamspeak-server.startupProbe" .Values.startupProbe) -}} +{{- $budget := "" -}} +{{- if and (kindIs "float64" $startup.periodSeconds) (kindIs "float64" $startup.failureThreshold) -}} +{{- $budget = printf "%.0f seconds" (mulf $startup.periodSeconds $startup.failureThreshold) -}} +{{- end -}} +{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}} +{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}} +{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}} +{{- $pvc := printf "%s-teamspeak-volume-claim" .Release.Name -}} +{{- $voice := .Values.server.voicePort -}} +{{- $ft := .Values.server.fileTransferPort -}} +{{- $badPorts := list -}} +{{- if or (lt (int $voice) 30000) (gt (int $voice) 32767) }}{{- $badPorts = append $badPorts (printf "server.voicePort (%v)" $voice) }}{{- end }} +{{- if or (lt (int $ft) 30000) (gt (int $ft) 32767) }}{{- $badPorts = append $badPorts (printf "server.fileTransferPort (%v)" $ft) }}{{- end }} +TeamSpeak {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}. + +ACCESS — TWO NODE PORTS, NO INGRESS + TeamSpeak is not HTTP, so this chart renders no Ingress and no certificate. + The Service {{ $svc }} is of type NodePort, so both + ports are open on EVERY node of the cluster and reachable by anyone who can + reach a node. + + voice node IP : {{ $voice }}/UDP (server.voicePort) + file transfer node IP : {{ $ft }}/TCP (server.fileTransferPort) + + kubectl get nodes -o wide # the IPs to hand to your users + + Each of the two numbers is used THREE times: as the container port, as the + Service port and as the nodePort. That is deliberate — TeamSpeak's file + transfer announces its own port to the client, so the port inside the + container has to be the same one the client reaches from outside. It also + means the voice port cannot be 9987, the port a client assumes when it is + given a bare host name. Tell your users the full address: + + NODE-IP:{{ $voice }} +{{- if $badPorts }} + + OUT OF THE NODE-PORT RANGE: {{ join ", " $badPorts }} + A default cluster only allocates node ports from 30000-32767. The values + schema accepts 1-65535, so this rendered fine — the API server is what + rejects it, when you apply the Service, with "provided port is not in the + valid range". Nothing about that error mentions this chart. Either pick a + port inside the range or widen --service-node-port-range on the API + server. +{{- end }} + + Inside the cluster the same ports apply; there is no separate service port: + + {{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $voice }} + +THE FIRST START PRINTS TWO SECRETS AND NEVER PRINTS THEM AGAIN + Against an empty data volume the server creates its database, and with it a + virtual server, a ServerQuery admin account and a ServerAdmin privilege key. + All of it goes to stdout ONCE, at that first start only, and into no Secret: + + kubectl -n {{ .Release.Namespace }} logs {{ $pod }} + + Two different things are printed, and they are not equally recoverable: + + token=... the ServerAdmin privilege key. This is what + you paste into a connected client to become + server admin. Also written to the log FILES + on the data volume, so it survives a lost + stdout: + + kubectl -n {{ .Release.Namespace }} exec {{ $pod }} -- \ + sh -c 'grep -h "token=" /var/tsserver/logs/*.log' + + serveradmin / apikey the ServerQuery credentials. These go to + stdout ONLY — they are not in the log files + on the volume. Once the pod's stdout is + gone, they are gone. + + Losing the privilege key without having claimed admin rights means starting + over: the identity that holds admin is stored in the database, so the way + back is deleting the volume and letting a new server be created. + + A second start against an existing volume prints neither. That includes + every ordinary restart — a rollout, a node drain, an app-version bump. If a + fresh install seems to be missing its token, check whether the PVC below + already existed. + +server.voicePort ONLY TAKES EFFECT ON A BRAND-NEW VOLUME + This is the trap of this chart, and it is silent in both directions. + + The chart passes server.voicePort to the container as TSSERVER_DEFAULT_PORT. + The server reads that variable only when it CREATES the virtual server; from + then on the port lives in the virtual server's row in + /var/tsserver/tsserver.sqlitedb and the variable is ignored. Measured on + {{ .Chart.AppVersion }}: a restart against an existing volume with a changed + TSSERVER_DEFAULT_PORT kept listening on the old port. + + So changing server.voicePort on a release that already has data moves the + Service port, the nodePort and the containerPort to the new number, while + the server inside the pod keeps answering on the old one. Voice becomes + unreachable, and — see below — every probe stays green. + + There is no comfortable way out of that. The port is a property of the + virtual server in the database, and the only interface that can edit it is + ServerQuery, which this chart does not expose (see below). Treat + server.voicePort as fixed for the life of the data volume: pick it before + the first install, and if it really has to change, plan for a new volume and + a new server. + + server.fileTransferPort behaves the opposite way: TSSERVER_FILE_TRANSFER_PORT + is applied on every start, so that one really does follow the value. +{{- if eq $replicas "0" }} + +SCALED TO ZERO + {{ if .Values.scaleDown }}scaleDown: true{{ else }}replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so nothing + answers on the ports above until you set it back and upgrade again. The data + PVC and the Service stay in place, so no data is lost and the node ports + stay reserved. +{{- end }} +{{- if gt (atoi $replicas) 1 }} + +MORE THAN ONE REPLICA WILL NOT WORK + replicas is {{ $replicas }}. Every replica mounts the same ReadWriteOnce claim + {{ $pvc }} — this chart writes a plain PVC, not a + StatefulSet volumeClaimTemplate, so there is no per-pod volume. Unless the + storage class hands that claim to several nodes at once, the extra pods + never start; where they do, two servers open the same SQLite database and + corrupt it. The Service would also spray a client's UDP packets across both + pods, which no TeamSpeak session survives. This chart is built for one + replica. +{{- end }} + +THE BUNDLED LICENSE EXPIRES ON 2026-10-01 + This image ships without a license file and falls back to the built-in one. + Measured by running {{ .Chart.AppVersion }}: + + type : No License + starting date : Wed Jul 1 00:00:00 2026 + ending date : Thu Oct 1 00:00:00 2026 + max virtualservers: 1 + max slots : 32 + + Two consequences. The 32-slot ceiling is the real capacity of this release, + whatever the resources say. And after the ending date the server refuses to + start — a working deployment turns into CrashLoopBackOff with no change on + your side. The fix is an app-version bump to a newer beta, which ships a + license with a later date; that is tracked separately and is not something a + values change can solve. Confirm the dates for the version you actually run: + + kubectl -n {{ .Release.Namespace }} logs {{ $pod }} | grep -A6 "Licensing Information" + +NO SECRETS, AND NO QUERY INTERFACE TO USE THEM ON + This chart creates no OnePasswordItem and no Kubernetes Secret — there is no + secrets.* value to set, and nothing to prepare in a vault before installing. + The server's own credentials are the ones printed at first start. + + Which matters, because those ServerQuery credentials have nowhere to go. On + a default install nothing listens on a query port: measured on + {{ .Chart.AppVersion }}, the only listening sockets in the container are + UDP {{ $voice }} (voice) and TCP {{ $ft }} (file transfer). The pod + declares a third container port, web-query 10080, and nothing binds it; it + is also absent from the Service. The server additionally restricts query + access to 127.0.0.1 and ::1 by default. Administration therefore happens + through a connected TeamSpeak client with the privilege key, not over + ServerQuery. + +WHAT A READY POD DOES NOT PROVE + All three probes are bare TCP connects to the file-transfer port. Green + means one thing: the file-transfer listener accepts a connection.{{ if $budget }} The startup + probe covers {{ $budget }} and keeps liveness and readiness off until then.{{ end }} + + It says nothing about voice, which is the actual service — UDP cannot be + probed this way, so no probe in this chart ever touches it. A release can be + fully Ready while the voice port is wrong (see server.voicePort above), while + the virtual server is stopped, or while every slot is taken. It says nothing + about the license either, though an expired one crashes the process and does + surface as a restart loop. + + The real check is a client connecting to NODE-IP:{{ $voice }}. Short of + that, the server's own view: + + kubectl -n {{ .Release.Namespace }} logs {{ $sfs }} | grep "listening on" + + which prints the ports the server actually bound — compare them with the two + at the top of these notes. They should match; when they do not, the cause is + almost always the port trap above. + +THE DATA VOLUME IS THE SERVER, AND IT BELONGS TO THIS RELEASE + Everything is in one directory, /var/tsserver, on the claim + {{ $pvc }} ({{ .Values.storage.size }}{{ if .Values.storage.storageClass }} from storageClass {{ .Values.storage.storageClass }}{{ end }}): + + tsserver.sqlitedb the virtual server, its port, all permissions, + identities, channels and ServerQuery accounts + files/ everything uploaded through file transfer + logs/ the server's own logs, incl. the privilege key + query_ip_*list.txt the query allow/deny lists + + Note the claim name: {{ .Release.Name }}-teamspeak-volume-claim, without + the chart name. That is a historical name this chart keeps deliberately — + renaming it would orphan the data. + + It is an ordinary PVC of this release, not a volumeClaimTemplate, and it + carries no resource-policy annotation. "helm uninstall {{ .Release.Name }}" + deletes it, and with a Delete reclaim policy the whole server goes with it — + not just chat history, but the admin identity and the privilege key. Back it + up before uninstalling; an annotation added to the live PVC afterwards does + not help, because Helm reads that annotation from the release manifest. + + Growing storage.size only works if the storage class allows volume + expansion. Shrinking is refused by Kubernetes and makes the next upgrade + fail on the PVC instead of touching the data. + +THE ROOT FILESYSTEM IS READ-ONLY AND THERE IS NO SCRATCH VOLUME + The container runs non-root as uid/gid 9987 with readOnlyRootFilesystem and + all capabilities dropped, and /var/tsserver is the only writable path — the + chart mounts no emptyDir. Verified against {{ .Chart.AppVersion }}: the + server needs nothing else, so this is the supported configuration and not a + deviation to "fix". An image that later wants a writable /tmp would fail at + start, not at build time. + + fsGroup 9987 is what makes the volume writable. On a storage class that does + not honour fsGroup the pod starts and the server then cannot open its + database — that failure is in the pod log, not in the events. + {{- if .Values.storage.storageClass }} This release uses "{{ .Values.storage.storageClass }}".{{ end }} + Whatever the container writes outside the volume is nothing, so the + ephemeral-storage limit ({{ $eph }}) is headroom rather than a + working budget. +{{- if .Values.env }} + +env IS APPENDED, NOT MERGED — AND CAN SHADOW THE CHART'S PORTS + The {{ len .Values.env }} entr{{ if eq (len .Values.env) 1 }}y{{ else }}ies{{ end }} here land{{ if eq (len .Values.env) 1 }}s{{ end }} AFTER the three variables the chart + always sets: TSSERVER_LICENSE_ACCEPTED, TSSERVER_DEFAULT_PORT and + TSSERVER_FILE_TRANSFER_PORT. Kubernetes keeps the last value for a repeated + name, so redefining either port variable here wins inside the container while + the Service, the nodePort and the containerPort still follow server.*. That + splits the two apart with nothing reporting it — set the ports under server, + never through env. + + Values are rendered through tpl, so they may contain template expressions. + Anything sensitive belongs in valueFrom.secretKeyRef: a plain "value" is + stored in the pod spec and readable by everyone who can read pods in + {{ .Release.Namespace }}. +{{- end }} diff --git a/teamspeak-server/templates/_helpers.tpl b/teamspeak-server/templates/_helpers.tpl index 12351f5..931cbd1 100644 --- a/teamspeak-server/templates/_helpers.tpl +++ b/teamspeak-server/templates/_helpers.tpl @@ -67,7 +67,7 @@ Handles plain numbers ("2", "0.5", 1073741824), decimal ("1.5Gi"), binary results are printed without a decimal point. */}} {{- define "teamspeak-server.multiplyQuantity" -}} -{{- $quantity := printf "%v" .quantity -}} +{{- $quantity := include "teamspeak-server.formatNumber" .quantity -}} {{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}} {{- if not $number -}} {{- fail (printf "cannot parse resource quantity %q" $quantity) -}} @@ -80,7 +80,24 @@ results are printed without a decimal point. {{- if eq $product (floor $product) -}} {{- printf "%d%s" (int64 $product) $suffix -}} {{- else -}} -{{- printf "%v%s" $product $suffix -}} +{{- printf "%s%s" (include "teamspeak-server.formatNumber" $product) $suffix -}} +{{- end -}} +{{- end -}} + +{{/* +Render a number as a plain decimal string, never in exponential notation. + +Why this exists (issue #157): the null-safe fallback helpers pass values +through fromYaml, which parses every number as float64. Go's "%v" switches +float64 to exponential notation from about 1e6 upwards, so a bare byte count +like 1048576 became "1.048576e+06" - not a valid Kubernetes quantity, and the +render aborted. Strings ("512Mi") are passed through untouched. +*/}} +{{- define "teamspeak-server.formatNumber" -}} +{{- if kindIs "float64" . -}} +{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}} +{{- else -}} +{{- printf "%v" . -}} {{- end -}} {{- end -}}