--- date: 2026-09-30 branch: feat/plex-quicksync title: Plex opts into `hardware.gpu: intel-quicksync` — the device mount is GitOps, the Plex-side toggle is a documented manual step, and hardware transcoding has NOT been observed working --- ## What changed Three things, and the third is the point of the branch. 1. **`catalog/plex.yaml`** — added the four lines the whole feature reduces to: ```yaml hardware: gpu: intel-quicksync ``` placed between `env` and `exposure` to match the schema's own property order, with ~48 lines of comment in the file's established style. The comment records: the field is **intent, not mechanism** (a later move to the Intel device plugin is a `render.py` change and this block does not move); that `/dev/dri/renderD128` is present on **all three** prod nodes with `i915` bound; **why a `supplementalGroups` entry would be inert** (the image's cont-init hook does the group work inside the container and `s6-setuidgid` replaces the process group list from `/etc/group`); why `DirectoryOrCreate` is the load-bearing safe failure mode (`apps-wave-0` is `wait: true` with `apps-wave-1` depending on it, so a failed mount would stall the whole app tier for a feature that is not load-bearing); and that the Plex-side toggle is a **separate manual operator step**, pointing at the new runbook. 2. **`apps/wave-0/plex.yaml`** — re-rendered. Six added lines, nothing else, in the whole `apps/` tree: ``` - name: dri mountPath: /dev/dri - name: dri hostPath: path: /dev/dri type: DirectoryOrCreate ``` 3. **`runbooks/plex-quicksync.md`** (new), registered in `runbooks/kustomization.yaml`'s `configMapGenerator` as `- plex-quicksync=plex-quicksync.md` and indexed in `runbooks/README.md` under "Operator procedures". **The file alone would have been a 404** — the pod serves a ConfigMap, not the git tree, and `conformance/tests/l0-static/alert-runbooks-resolvable` exists precisely because "the file is in git" and "the URL resolves" are different claims. The page covers: the GitOps/manual boundary as a table; the toggle (Settings → Transcoder → "Use hardware acceleration when available", preference key `HardwareAcceleratedCodecs`); the real neighbouring keys and the widely-cited one that **does not exist**; that the only proof is `Transcode (hw)` on the Plex dashboard **and why no alert is possible**; the device-cgroup failure to expect first with a concrete open() diagnostic and the Intel-device-plugin fallback; and the floating-`:latest` caveat with a digest check. **Stacked on PR #27 (`feat/sd-hardware-capability`), which is open and unmerged.** This branch was cut from it rather than from `main`, so its one commit (`d9baa2a`, the schema + renderer + contract) is in this PR's range. That is deliberate: merging #27 is not an agent's action (CLAUDE.md §1), and duplicating its changes here would have been worse than showing them. **This must merge after #27.** Not done, deliberately: no `--emit-waves` (it writes into `clusters/base/`, a sensitive path that would force a human reviewer, and it is a no-op unless the *set* of files under `apps/` changes — it does not here). No `podSecurityContext`. No `nodeSelector`. No alert rule. No `kubectl apply`. ### Two follow-ups left unclaimed, with the reasoning - **Transcode scratch is on the wrong medium, and it is invisible.** The image sets `TranscoderTempDirectory=/transcode`, which is **not a volume** — it is the container's writable overlay, i.e. the node's `/data`. That is NVMe on prod-2 but **SATA on prod-1 and prod-3**, so transcode scratch performance changes silently on reschedule, with nothing reporting it. Both candidate fixes need a **schema** change and so neither belongs in this PR: pin Plex to prod-2 (needs a `nodeSelector` field the schema does not have), or give `/transcode` a `medium: Memory` emptyDir and transcode in RAM (~31 GiB per node; needs an emptyDir field the schema does not have). **The RAM option is the better one** — it removes the node asymmetry outright instead of working around it — which is exactly why it deserves its own PR and its own review rather than being smuggled in behind a four-line catalog change. Documented in the runbook's "Known gaps". - **GuC/HuC firmware is not loaded** (`i915.enable_guc=0`). The cost is low-power H.264 **encode** quality only; decode and this feature are unaffected. Changing it needs a kernel parameter plus a **serialized rolling reboot** — an operator action under CLAUDE.md §5, on a cluster with a documented history of that going badly (a prod node hung on a warm reboot on 2026-09-03). Kept well clear of this PR. ### Rebase note (2026-10-01, Ruth Okonkwo) — resolution only, no redesign Rebased onto `main` @ `08deb20` (was cut from `0fbf544`), restacked on the rebased `feat/sd-hardware-capability` (PR #27) so the stack stays coherent — this branch still carries exactly PR #27's commit plus its own, with no duplicate of anything already in `main`. The branch ref was force-updated with `--force-with-lease`, so PR #32 is the same PR with a new head. **The `catalog/render.py` conflict disappeared** once PR #27 was rebased; it was never this branch's own conflict. **One conflict remained, in `runbooks/README.md`, purely textual.** PR #30 (`fix/jackett-oauth2-proxy-session`, merged as `08deb20`) appended an `oauth2-proxy-session` index entry under *Operator procedures* at the same point this branch appended its `plex-quicksync` entry. **Both entries were kept, in full.** Ordering follows this file's existing convention, which is grouped/narrative rather than alphabetical (the *Nodes* section runs down/io/memory/disk/cpu): `plex-quicksync` sits next to `plex-claim` as the other Plex page, leaving `oauth2-proxy-session` adjacent to `crafty-admin-credential`, which cross-references the same sidecar gate. The change to `runbooks/README.md` is an 8-line pure insertion, so PR #30's entry is untouched. `runbooks/kustomization.yaml` auto-merged and **both** `configMapGenerator` entries are present. Re-verified after the resolution, on the rebased stack: `--selftest` PASS (rc 0); a full re-render reproduces every committed golden byte-identically; and the **only** `apps/` diff against the new `main` is `apps/wave-0/plex.yaml`, confined to the expected six lines (the `dri` mount and its `DirectoryOrCreate` hostPath volume). No other service's golden moved — PR #28 added ~176 lines of oauth2-proxy sidecar rendering, and this confirms by measurement rather than assumption that Plex (`auth: native`, no sidecar) is unaffected by it. ## What I verified - **The render is exactly the intended change, and nothing else moved.** After `python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation` (exit 0, silent): `git diff --stat apps/` = `apps/wave-0/plex.yaml | 6 ++++++`, and `git status --porcelain apps/` = a single ` M apps/wave-0/plex.yaml`. Read line by line, the diff is the `dri` volumeMount and the `dri` hostPath volume, and nothing more. **Specifically checked:** the same invocation also rewrites `apps/wave-1/keycloak.yaml` and `apps/wave-0/vm-demo.yaml`, which are git-tracked but excluded from the golden comparison by `RENDER_GOLDEN_NOT_ONBOARDED` in `ci/render-golden.sh` — a spurious diff there would have landed **ungated**. Neither file shows a diff; whole-repo `git status --porcelain` lists only `apps/wave-0/plex.yaml` and `catalog/plex.yaml` plus my new files. - **The rendered output matches what the contract promises** — `schemas/renderer-contract.md` `### hardware` says exactly two things (a `/dev/dri` hostPath with `type: DirectoryOrCreate` and a read-write mount on the app container) and exactly two things appeared. No `securityContext`, no `nodeSelector`, no resources in the diff. - **The runbook is projected into the server, not merely committed.** `runbooks/kustomization.yaml` carries `- plex-quicksync=plex-quicksync.md`, whose shape matches the generator-entry regex the L0 check parses; the extensionless key is what makes `/runbooks/plex-quicksync` a plain static lookup. Also indexed in `runbooks/README.md`. - **The runbook clears the L0 quality floor** that `alert-runbooks-resolvable` applies to alert-linked pages, even though nothing links to this one (it is advisory for an unreferenced page): all four required sections (`## What this means here`, `## First commands`, `## Likely causes, most likely first`, `## When to escalate or stop`), well over the 1500-byte floor, `dev.sh` used throughout, and `--context k3s-prod` on every command. - **The gate.** See the run recorded in the PR description for the per-stage table and the real rc. - **No secret was printed.** Every `Preferences.xml` read in the runbook greps one named key, and the page states explicitly why: the file holds `PlexOnlineToken`. Nothing was `sops -d`'d. ## What I did NOT verify **Hardware transcoding has NOT been observed working.** Nothing in this branch is evidence that it does. A green gate here proves the manifest renders and the golden matches — it proves nothing about runtime behaviour, and `vars.CLUSTER_AVAILABLE` is unset so L2/L3 conformance did not run at all. - **The device-cgroup question is OPEN, and it is the largest remaining technical risk.** Whether containerd on this k3s build permits a **non-privileged** pod to `open()` a bind-mounted character device is **UNVERIFIED**. It can only be settled by deploying and trying. The container inits as uid 0, which settles *file* permissions, but uid 0 does **not** bypass the cgroup device gate — and the two failures look identical from the outside. If it is refused, the fallback is the Intel Device Plugin (`gpu.intel.com/i915`), which is a `catalog/render.py` change plus a re-render with **no ServiceDefinition edit**; `privileged: true` is not available (Kyverno `restrict-host-namespaces` is Enforce). The runbook has the diagnostic sequence. - **The Plex-side toggle was still unset at the time of writing.** `HardwareAcceleratedCodecs` was **absent** from `Preferences.xml` — the toggle has never been enabled on this server. Until an operator ticks it in the UI, this branch changes the pod spec and **nothing about transcoding**. That is the whole reason the runbook exists. - **No metric, so no alert, so no automated detection — permanently.** `catalog/plex.yaml` declares no `metricsEndpoint`, so Plex renders no ServiceMonitor; there is no transcode-mode signal for a rule to evaluate. A silent degradation to software transcoding (a node losing its iGPU, the `DirectoryOrCreate` path) will **not** be reported by anything. I deliberately did not add an alert: with no metric behind it, it could never fire, and this repo has been burned at scale by exactly that. Closing the gap honestly means an exporter first. - **I did not re-derive this session's node or in-container measurements.** The three-node `renderD128`/`i915`/GID-992 facts, the cont-init hook behaviour, the `s6-setuidgid` group replacement, the `PLEX_PREFERENCE*` absence, the six `setPref` keys and the `HardwareAcceleratedCodecs` string were established earlier in this session and are cited, not re-measured, by me. Per CLAUDE.md §2b, treat this file as a record of belief and re-measure against the cluster before relying on any of it. - **The image is a floating tag.** Everything known about the hook came from digest `sha256:e0ab27395614…` (`plexmediaserver 1.43.4.10903`), while the Deployment runs `plexinc/pms-docker:latest` with `imagePullPolicy: Always`. A silent pull can invalidate the "no `supplementalGroups` needed" conclusion **with no commit in this repo**. Not guarded against — the runbook tells the reader to re-check the hook after an image bump, which is documentation, not enforcement. - **I did not verify the live Gitea gate state** (branch protection, required contexts, runner online) for this PR. Per §2b that must be queried, not read out of `CLAUDE.md`'s table; I did not query it. - **`/runbooks/plex-quicksync` was not fetched.** The L0 check asserts every static precondition for an HTTP 200; it does not make the request, and I did not either.