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