Files
home-network-k3s-impl/runbooks/plex-claim.md
T
jakman85andClaude Opus 5 4fd75ccdaa
gate / gate (pull_request) Successful in 1m58s
gate / conformance (pull_request) Has been skipped
feat(plex): wire PLEX_CLAIM from a SOPS Secret so the server can be claimed
Plex cannot create libraries over the already-mounted 8.33T NFS bulk share while
the server is UNCLAIMED. catalog/plex.yaml carried a long argument for leaving
PLEX_CLAIM unwired; the operator has decided to override it. This implements the
wiring in the way that respects the half of that argument which is still true.

WHAT WAS TRUE AND STILL IS: a https://plex.tv/claim token has a ~4-minute TTL,
far shorter than commit -> push -> Flux reconcile -> pod-roll on its own timers.
So the committed value is a documented PLACEHOLDER and the live token is injected
by the operator inside that window, with the reconcile FORCED rather than waited
for. runbooks/plex-claim.md is that timed procedure.

WHAT WAS NOT TRUE: the old comment claimed "a placeholder/absent claim Secret
would crashloop the pod". Half of that is wrong, and the difference is the whole
design. Established from plexinc/pms-docker's own first-run script (branch
`master`, root/etc/cont-init.d/40-plex-first-run, s6-overlay v2), cross-checked
byte-identical against the same file inside the running :latest image:

  * the claim exchange (line 106) is gated on PLEX_CLAIM being non-empty AND the
    PlexOnlineToken pref in /config being empty. Once the server is claimed the
    block is skipped entirely, so the env is a NO-OP on every later start
    whatever it holds ("If server is already logged in, this parameter is
    ignored" -- upstream README).
  * with an invalid/expired token on a still-unclaimed /config, plex.tv answers
    403 with an empty body; curl's status is never checked, the token sed yields
    empty, the success branch at line 120 does not fire, there is no else branch
    and no non-zero exit, and the script (no `set -e`) exits 0. The pod comes up
    UNCLAIMED AND SERVING. It does not crashloop.
  * the image never sets S6_BEHAVIOUR_IF_STAGE2_FAILS (default 0 = continue even
    if a cont-init script failed), so even a non-zero exit would not halt
    startup.

An ABSENT Secret is the real hard failure (secretKeyRef has no `optional`, so it
is CreateContainerConfigError), which is why the Secret is committed, encrypted
and listed in the wave kustomization rather than hand-created at claim time.

NO SCHEMA CHANGE. `optional` on secretKeyRef would only be needed if the pod had
to tolerate a missing Secret or could not start without a VALID token. Neither
holds: the Secret always exists in the Flux graph, and an invalid token is
non-fatal. schemas/servicedefinition.schema.json is untouched, and so are
catalog/render.py's env emission and the renderer contract.

Changes:
  * apps/wave-0/plex-claim-secret.sops.yaml -- new Opaque Secret plex-claim/plex,
    key PLEX_CLAIM, SOPS-encrypted to the committed age recipient. Its header
    carries NO dollar-brace expressions on purpose: kustomize preserves body
    comments into the stream envsubst reads (per
    conformance/tests/l0-static/flux-postbuild-substitution), so quoting the
    upstream shell conditions verbatim would have been a live substitution
    hazard on apps-wave-0, not prose.
  * catalog/plex.yaml -- the PLEX_CLAIM env via valueFrom.secretKeyRef, the
    "deliberately NOT set" block rewritten around the TTL + one-shot design, and
    the stale crashloop claim in the /config storage comment corrected.
  * apps/wave-0/plex.yaml -- re-rendered with the canonical command
    (`--all catalog -o apps --wave-layout --no-image-automation`). No L1 golden
    under conformance/tests/l1-rendering covers plex, so this is the only
    rendered surface that moves.
  * catalog/render.py -- plan_apps_waves places `<svc>-claim-secret.sops.yaml`
    in the app's OWN wave, like the bootstrap secret, plus a selftest assertion.
    Without this, the next `--emit-waves` would have silently dropped the Secret
    from apps/wave-0/kustomization.yaml and put the pod into
    CreateContainerConfigError. apps/wave-0/kustomization.yaml is the hand-pruned
    subset (vm-demo/keycloak are rendered but deliberately unlisted), so its one
    new line was applied without re-emitting the rest.
  * runbooks/plex-claim.md + its kustomization.yaml generator entry + README
    index -- the operator procedure that fits in 4 minutes, including the single
    forced-reconcile command and the mandatory pod roll (a changed Secret does
    NOT restart anything: PLEX_CLAIM is read only at container start and the pod
    template is byte-identical across this change).

Touched requirements: R-SEC-01 (secrets encrypted at rest), R-GITOPS-02
(ServiceDefinition onboarding contract).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-28 22:26:44 -05:00

10 KiB

plex-claim — claiming the Plex Media Server inside the 4-minute token window

Alert: none. This is an operator procedure, not an incident page — it is indexed here because it is the page you need at the moment you need it, with a stopwatch running. Files: catalog/plex.yaml (env PLEX_CLAIM), apps/wave-0/plex-claim-secret.sops.yaml (the SOPS Secret plex-claim, key PLEX_CLAIM), apps/wave-0/plex.yaml (the rendered golden).

What this means here

Plex will not let you create libraries over the mounted bulk media share while the server is unclaimed — the 8.33 T NFS tree at /media is there, readable, and unusable. Claiming binds the server to a plex.tv account. It is a one-shot operation: the token is exchanged once for a durable PlexOnlineToken written into Preferences.xml on the plex-config Longhorn PVC, and from then on the PLEX_CLAIM env is ignored on every start (upstream gate: [ ! -z "${PLEX_CLAIM}" ] && [ -z "${token}" ] in root/etc/cont-init.d/40-plex-first-run).

A https://plex.tv/claim token lives ~4 minutes. That is shorter than commit → push → Flux's 10-minute reconcile interval → pod roll if you let those run on their own timers, which is why this page exists: every step below is written to be run back-to-back, with the reconcile forced rather than waited for. Budget: token in hand at T+0, pod restarted by roughly T+2:00.

An expired or wrong token does not break anything. Verified against the image's own first-run script (and the copy inside plexinc/pms-docker:latest): a failed exchange gets a 403 with an empty body, curl's status is never checked, the token sed yields empty, the if does not fire, there is no else and no exit 1, and the script (no set -e) exits 0. The pod comes up unclaimed and serving. So a burnt token costs you one more attempt, not an outage — and the repo deliberately ships a placeholder in that Secret for exactly this reason (the env secretKeyRef has no optional, so the Secret must always exist even when its value is useless).

Running the commands in this runbook

kubectl does not work on the operator workstation. The toolchain lives only in the pinned dev container, and the host's k3s-oidc context hangs on a browser prompt once its token expires — that hang is not the cluster being down. Every command below runs as:

infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get nodes'

Always pass --context k3s-prod explicitly. dev.sh merges BOTH clusters into one kubeconfig and picks a current-context per invocation, and that default has already changed once (k3s-dev until dev was decommissioned on 2026-09-13, k3s-prod now). Do not run two dev.sh invocations at once.

Mutating kubectl and flux reconcile are blocked for agents here. This whole page is operator work — the reconcile and the pod roll below are deliberate operator actions, and the durable change is still a git change.

First commands

Before spending a token, find out whether the server is already claimed. This prints only the attribute NAME, never its value — do not widen it to print the token:

infra/devcontainer/dev.sh run bash -c \
  'kubectl --context k3s-prod -n plex exec deploy/plex -- sh -c \
     "grep -o PlexOnlineToken= \"/config/Library/Application Support/Plex Media Server/Preferences.xml\" | head -1"'

PlexOnlineToken= printed ⇒ already claimed; stop, there is nothing to do and no token to burn. Empty output ⇒ unclaimed, continue.

Is the workload healthy enough to claim at all, and is the wave reconciling?

infra/devcontainer/dev.sh run bash -c \
  'kubectl --context k3s-prod -n plex get deploy,pod,pvc'
infra/devcontainer/dev.sh run bash -c \
  'kubectl --context k3s-prod -n flux-system get kustomization apps-wave-0'

The 4-minute procedure

Have this page open and do not stop between steps.

  1. T+0 — get the token. Sign in to plex.tv as the account that should OWN this server, open https://plex.tv/claim, copy the claim-… string. The clock starts now.

  2. T+0:15 — put it in the Secret, in place, encrypted, without it ever touching a plaintext file:

    infra/devcontainer/dev.sh run sops apps/wave-0/plex-claim-secret.sops.yaml
    

    Replace the PLEX_CLAIM: value (the placeholder replace-me-with-a-fresh-plex-tv-claim-token, or a previous expired token) and save. sops re-encrypts on write to the committed age recipient.

  3. T+0:45 — commit and push. git push origin main is the deploy; there is no other apply step.

    git add apps/wave-0/plex-claim-secret.sops.yaml
    git commit -m 'chore(plex): fresh plex.tv claim token'
    git push origin main
    
  4. T+1:00 — force the reconcile immediately. This is the one command that saves the window — it pulls the new commit and re-applies wave 0 instead of waiting up to 10 minutes for the interval:

    infra/devcontainer/dev.sh run bash -c \
      'flux --context k3s-prod -n flux-system reconcile kustomization apps-wave-0 --with-source'
    

    --with-source is the load-bearing flag: it reconciles the flux-system GitRepository first, so the Kustomization applies your commit and not the previously-fetched revision.

  5. T+1:30 — roll the pod. This step is mandatory and easy to forget: a changed Secret does not restart anything. PLEX_CLAIM is injected by secretKeyRef and read only at container start, and the Deployment's pod template is byte-identical across this change, so Flux applies the new Secret and the running pod keeps the old value forever.

    infra/devcontainer/dev.sh run bash -c \
      'kubectl --context k3s-prod -n plex rollout restart deployment/plex'
    
  6. T+2:00 — confirm the exchange happened, in the new pod's init log:

    infra/devcontainer/dev.sh run bash -c \
      'kubectl --context k3s-prod -n plex logs deploy/plex --since=5m | grep -i -e claim -e token'
    
    • Attempting to obtain server token from claim token and Token obtained successfully ⇒ claimed. Re-run the First-commands PlexOnlineToken= check to confirm it persisted, then create the libraries over /media/movies, /media/tv, /media/photos.

    • Attempting to obtain server token from claim token with no success line ⇒ the token was expired or invalid. Nothing is broken; go back to step 1 with a new token.

    • Neither line ⇒ the exchange was skipped, which means /config already had a PlexOnlineToken (already claimed) or the env did not reach the container. Check the pod actually has the variable, by NAME only:

      infra/devcontainer/dev.sh run bash -c \
        'kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex \
           -o jsonpath="{.items[0].spec.containers[0].env[*].name}"'
      

Likely causes, most likely first

When the claim does not take, in order of how often it is the answer here:

  1. The token expired — you spent more than ~4 minutes, usually in step 3 or waiting on step 4. Symptom: the "Attempting…" line with no success line. Remedy: a new token, and force the reconcile rather than waiting for it.
  2. The pod was never restarted (step 5 skipped). Symptom: no claim log lines at all from a pod whose AGE predates your push. The Secret is correct in git and in the cluster and the running container still holds the placeholder.
  3. Flux had not applied the commit yet when you rolled the pod — step 5 run before step 4 finished, so the new pod read the OLD Secret. Check kubectl --context k3s-prod -n flux-system get kustomization apps-wave-0 -o jsonpath='{.status.lastAppliedRevision}' against your commit, then roll again.
  4. Already claimed — the exchange is gated on PlexOnlineToken being absent from /config, so a server claimed earlier ignores every later token silently. This is the benign case.
  5. plex.tv unreachable. The upstream curl has no --max-time/--connect-timeout, so a stalled connection can leave the container sitting in cont-init with nothing listening on 32400 — the one genuine hang risk in this path. Symptom: pod Running but never Ready, log stops after "Attempting…". Check egress (plex is egress: internet) rather than re-rolling repeatedly.
  6. Wrong plex.tv account. The claim succeeds and binds the server to whoever was signed in at step 1. Unclaiming is a plex.tv-side operation on that account — see "escalate or stop".

When to escalate or stop

  • Stop after two failed token attempts and fix the timing, not the token. The third rushed attempt is how people start editing live Secrets by hand, which leaves the cluster holding a value that is in no git commit — precisely the drift qbittorrent-bootstrap had to be recovered from.
  • Never kubectl edit/patch the plex-claim Secret in the cluster, and never kubectl create secret it. Everything durable goes through the SOPS file; a live-only Secret is reverted (or adopted with the wrong value) on the next reconcile.
  • Never sops -d this file into a terminal, a log, or a shared screen, and never widen the PlexOnlineToken= check into one that prints the value. The token is a plex.tv account credential.
  • If the server got claimed to the wrong account, stop and get the operator. Reversing it means removing the server from that account on plex.tv and clearing PlexOnlineToken from Preferences.xml on the PVC — a destructive edit of app state on the replicated tier, not a git change.
  • Do not delete the plex-config PVC to "start clean". It holds the library database, metadata and watch state (policy: daily-offcluster), and it is expensive to rebuild. The claim is a two-line pref, not a reason to lose the DB.
  • After a successful claim, leave the expired token in the SOPS file. It is inert (the exchange is skipped once claimed) and the Secret must keep existing, because the env secretKeyRef has no optional — deleting it would put the pod into CreateContainerConfigError.

Source of truth: runbooks/plex-claim.md. Index: /runbooks/ Upstream behaviour cited here is from plexinc/pms-docker branch master, root/etc/cont-init.d/40-plex-first-run (s6-overlay v2), cross-checked against the same file inside the running plexinc/pms-docker:latest image.