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 -}}