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.
This commit is contained in:
jklein committed 2026-08-29 01:00:15 +02:00
1 parent 56713d4377
commit 6ae380e8ec
3 files changed
+261 -3

No files matched your search

+1 -1
View File
@@ -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"
+241
View File
@@ -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 }}
+19 -2
View File
@@ -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 -}}