feat(plex): wire PLEX_CLAIM from a SOPS Secret (placeholder value) #1

Merged
jakman85 merged 1 commits from feat/plex-claim-sops-secret into main 2026-09-29 06:18:14 +00:00
Owner

Wires PLEX_CLAIM into the plex Deployment from a SOPS-encrypted Secret so the
server can be claimed to a plex.tv account. Plex will not create libraries over
the mounted 8.33T NFS bulk share while the server is unclaimed.

The committed value is the documented placeholder, not a credential. The
Secret exists so the valueFrom.secretKeyRef is always resolvable in the Flux
graph (the ServiceDefinition schema's secretKeyRef shape has no optional, so an
absent Secret is a hard CreateContainerConfigError). The live token is injected
separately by the operator.

Adds runbooks/plex-claim.md with the claim procedure.

Gate: ./ci/gate.sh green locally in the pinned toolbox — lint, l0,
render-golden and l1 all green in 74s. The only non-green stage is
spec-immutability, which cannot run outside CI's spec checkout and which
gate.yml tolerates explicitly.

This PR is also being used to measure the real end-to-end wall time of the
branch -> PR -> gate -> merge -> reconcile -> pod-roll loop, so the claim-token
step that follows can be budgeted against a measured number instead of an
assumed one.

Co-Authored-By: Claude Opus 5 (1M context) [email protected]

🤖 Generated with Claude Code

Wires `PLEX_CLAIM` into the plex Deployment from a SOPS-encrypted Secret so the server can be claimed to a plex.tv account. Plex will not create libraries over the mounted 8.33T NFS bulk share while the server is unclaimed. **The committed value is the documented placeholder, not a credential.** The Secret exists so the `valueFrom.secretKeyRef` is always resolvable in the Flux graph (the ServiceDefinition schema's secretKeyRef shape has no `optional`, so an absent Secret is a hard CreateContainerConfigError). The live token is injected separately by the operator. Adds `runbooks/plex-claim.md` with the claim procedure. Gate: `./ci/gate.sh` green locally in the pinned toolbox — lint, l0, render-golden and l1 all green in 74s. The only non-green stage is spec-immutability, which cannot run outside CI's spec checkout and which `gate.yml` tolerates explicitly. This PR is also being used to measure the real end-to-end wall time of the branch -> PR -> gate -> merge -> reconcile -> pod-roll loop, so the claim-token step that follows can be budgeted against a measured number instead of an assumed one. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
gitea-admin added 1 commit 2026-09-29 04:51:09 +00:00
feat(plex): wire PLEX_CLAIM from a SOPS Secret so the server can be claimed
gate / gate (pull_request) Successful in 1m58s
gate / conformance (pull_request) Has been skipped
4fd75ccdaa
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]>
jakman85 merged commit 0391139240 into main 2026-09-29 06:18:14 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Home-Network/home-network-k3s-impl#1