feat(gpu): satisfy hardware.gpu: intel-quicksync with gpu.intel.com/i915 instead of a /dev/dri hostPath (the hostPath was measured not to work: device-cgroup EPERM) #37

Merged
jakman85 merged 2 commits from feat/quicksync-via-device-plugin into main 2026-10-02 05:02:23 +00:00
Owner

Final step of the hardware.gpu: intel-quicksync workstream: drop the /dev/dri hostPath, emit a gpu.intel.com/i915 resource request instead. The device plugin landed in #36 and is live; this is the renderer change that consumes it.

Zero ServiceDefinition field changes. catalog/plex.yaml's hardware: block is byte-identical — git diff -U0 on that file contains no non-comment lines. That was the design bet when the field was made an enum of capability classes rather than a device path (contract §hardware, invariant 8), and this PR collects on it.

Why: the hostPath mechanism was measured not to work

In the live Plex pod on k3s-prod-2, 2026-10-01. The device node was visible in the container (crw-rw---- root video2 226,128 renderD128) and could not be opened:

attempt result
uid 0, O_RDWR EPERM "Operation not permitted"
uid 1000, O_RDWR EPERM "Operation not permitted"

EPERM from the device cgroup, not EACCES from file permissions — proven three ways: root on the host opens the same device rc=0; the node is unified cgroup2fs; and the container's runc config.json read linux.resources.devices: [{allow: False, access: 'rwm'}] with linux.devices: None — one deny-all rule, no allow entry for major 226. A hostPath is a filesystem operation: it binds the device node in but adds no cgroup allow rule, because Kubernetes cannot infer one from a volume.

uid 0 failing matters: root does not bypass the cgroup device gate, so "run as root" was never a fix, and privileged: true is unavailable (Kyverno restrict-host-namespaces is Enforce on app namespaces). The device plugin is the fix — v0.37.0's createDeviceSpecsFromDrmFiles emits DeviceSpec{…, Permissions: "rw"}, which the kubelet turns into the injection and the matching cgroup allow.

What changed

  • catalog/render.py — DRI_VOLUME_NAME/DRI_DEVICE_DIR retired for QUICKSYNC_RESOURCE. The intel-quicksync branch now emits exactly resources.limits: {gpu.intel.com/i915: 1} on the app container only (never a sidecar), and no volume, no mount, no securityContext, no nodeSelector. limits not requests is mandatory: Kubernetes mirrors requests from limits, and a requests-only extended resource is invalid. This is the only app-container resources render path and is deliberately narrow — no general CPU/memory feature was invented along the way.
  • Self-tests — resources dict asserted exactly; hostPath + /dev/dri mount asserted absent; the no-hardware negative extended to resources; the podSecurityContext pass-through and the no-synthesised-securityContext assertion kept; new: the limit must never reach the oauth2-proxy sidecar. Mutation-tested, 8/8 mutants caught — requests instead of limits, emission dropped, hostPath re-added, emitted unconditionally, supplementalGroups synthesised, resource renamed …/xe, quantity as the string "1", limit leaked onto the sidecar.
  • schemas/renderer-contract.md — mechanism half of §hardware and invariant 8 rewritten; the EPERM measurement recorded as the reason for the design so nobody simplifies it back; DirectoryOrCreate kept but labelled historical.
  • catalog/plex.yaml — comment correction only (the old text restated the dead mechanism). Measured facts that remain true are kept, including the no-supplementalGroups explanation.
  • runbooks/plex-quicksync.md — diagnostics now check .status.allocatable["gpu.intel.com/i915"] and the plugin DaemonSet; the decisive open test is specified as uid 1000 via s6-setuidgid plex; new top-of-page "STOP HERE IF PLEX IS Pending" plus a "Plex will not schedule" procedure. Manual-toggle section unchanged.
  • apps/wave-0/plex.yaml — re-rendered: six hostPath lines out, a three-line resources block in.
+        resources:
+          limits:
+            gpu.intel.com/i915: 1
         volumeMounts:
         - name: config
           mountPath: /config
         - name: media
           mountPath: /media
           readOnly: true
-        - name: dri
-          mountPath: /dev/dri
@@
-      - name: dri
-        hostPath:
-          path: /dev/dri
-          type: DirectoryOrCreate

git status --porcelain apps/ ⇒ M apps/wave-0/plex.yaml and nothing else. apps/wave-1/keycloak.yaml and apps/wave-0/vm-demo.yaml verified untouched (they are excluded from the golden comparison by RENDER_GOLDEN_NOT_ONBOARDED, so a spurious diff there would land ungated). clusters/ untouched; --emit-waves not run.

The one real regression — stated rather than sold

device/resource unavailable ⇒
Before DirectoryOrCreate mounted an empty dir; Plex ran and silently software-transcoded. Invisible, harmless to everything else.
After Plex is Unschedulable. apps-wave-0 is wait: true and apps-wave-1 dependsOn it, so this stalls the app tier.

Unavoidable: an extended resource is a hard scheduling constraint, and it is also the only thing that produces the cgroup allow rule. There is no graceful-degradation variant. Plex's schedulability is now coupled to the intel-gpu-plugin DaemonSet; that is the price of hardware transcoding working at all.

Escape hatch — one field, no code change: delete the hardware: block from catalog/plex.yaml and re-render. Plex returns to software transcoding and the tier unblocks. It is in the contract, in the runbook ("Plex will not schedule → remedy B", with commands), and in the handoff.

Gate

Pinned toolbox, from a worktree (DEV_KUBECONFIG=/dev/null + an ENGINE shim bind-mounting the main checkout's .git read-only, per CLAUDE.md §3; prod.kubeconfig was not copied). Run on 9babe57:

rc 3 — UNDETERMINED. NOT green, and I am not calling it green. Three skips, the three expected ones; nothing failed.

stage result
spec-immutability SKIPPED — spec repo not present (R-TEST-06 unverified)
decisions-log green
lint green
l0 green
render-golden green
l1 green
approval-policy green (event='none')
gitea-settings SKIPPED — SETTINGS_API_TOKEN unset
conformance SKIPPED — CLUSTER_AVAILABLE != true

diff base: origin/main @ 8214642d87de … HEAD @ 9babe5768f88; touched requirements resolved (R-PLAT-02 among them), so the R-TEST-03 coverage gate actually applied. Note .gitea/workflows/gate.yml:138 is ./ci/gate.sh || [ "$?" -eq 3 ], so CI's tick cannot distinguish rc 0 from rc 3 — read the stage output.

Still NOT verified

Hardware transcoding is still not observed working. A green gate proves the manifest renders.

  • The open() test cannot be re-run until this merges and Flux reconciles. The case for the change rests on the plugin's DeviceSpec{… Permissions: "rw"} read from v0.37.0 source, not observed on this cluster.
  • The Plex-side HardwareAcceleratedCodecs toggle is still unset, and no env var/ConfigMap/postBuild can set it.
  • I measured nothing on the cluster: i915=1 on all three nodes and the DaemonSet's 3/3/3 come from the brief (2026-10-01, DaemonSet 2m15s old). Per CLAUDE.md §2b, re-measure.
  • The new failure mode is reasoned from wait: true + dependsOn, not drilled. The escape hatch is likewise reasoned, not rehearsed.
  • No L2/L3 conformance here or in CI.

Post-merge sequence (operator)

  1. Merge; confirm apps-wave-0's .status.lastAppliedRevision is the merge commit.
  2. Confirm Plex scheduled — get pod -o wide. Pending ⇒ runbook "Plex will not schedule".
  3. Confirm it consumed the resource — …containers[0].resources.limits.
  4. Decisive test, as uid 1000. rc=0 is the pass condition: exec deploy/plex -- s6-setuidgid plex sh -c 'exec 3<>/dev/dri/renderD128 && echo RW-OPEN-OK || echo RW-OPEN-DENIED'. Passing as uid 0 is weaker evidence and must not be accepted as proof — Plex runs as 1000, and uid 0 was one of the two EPERMs that killed the hostPath.
  5. Only then the Plex UI toggle: Settings → Transcoder → "Use hardware acceleration when available" (under Show Advanced) → Save. Force a transcode; Dashboard → Activity showing Transcode (hw) is the proof.

🤖 Generated with Claude Code

Final step of the `hardware.gpu: intel-quicksync` workstream: **drop the `/dev/dri` hostPath, emit a `gpu.intel.com/i915` resource request instead.** The device plugin landed in #36 and is live; this is the renderer change that consumes it. **Zero ServiceDefinition field changes.** `catalog/plex.yaml`'s `hardware:` block is byte-identical — `git diff -U0` on that file contains no non-comment lines. That was the design bet when the field was made an enum of capability classes rather than a device path (contract §`hardware`, invariant 8), and this PR collects on it. ## Why: the hostPath mechanism was measured not to work In the live Plex pod on k3s-prod-2, 2026-10-01. The device node was **visible** in the container (`crw-rw---- root video2 226,128 renderD128`) and **could not be opened**: | attempt | result | |---|---| | `uid 0`, `O_RDWR` | **`EPERM`** "Operation not permitted" | | `uid 1000`, `O_RDWR` | **`EPERM`** "Operation not permitted" | `EPERM` from the **device cgroup**, not `EACCES` from file permissions — proven three ways: root on the **host** opens the same device `rc=0`; the node is unified `cgroup2fs`; and the container's runc `config.json` read `linux.resources.devices: [{allow: False, access: 'rwm'}]` with `linux.devices: None` — one deny-all rule, no allow entry for major 226. A hostPath is a *filesystem* operation: it binds the device node in but adds **no cgroup allow rule**, because Kubernetes cannot infer one from a volume. `uid 0` failing matters: root does not bypass the cgroup device gate, so "run as root" was never a fix, and `privileged: true` is unavailable (Kyverno `restrict-host-namespaces` is Enforce on app namespaces). The device plugin *is* the fix — v0.37.0's `createDeviceSpecsFromDrmFiles` emits `DeviceSpec{…, Permissions: "rw"}`, which the kubelet turns into the injection **and** the matching cgroup allow. ## What changed - **`catalog/render.py`** — `DRI_VOLUME_NAME`/`DRI_DEVICE_DIR` retired for `QUICKSYNC_RESOURCE`. The `intel-quicksync` branch now emits exactly `resources.limits: {gpu.intel.com/i915: 1}` on the **app container only** (never a sidecar), and no volume, no mount, no `securityContext`, no `nodeSelector`. `limits` not `requests` is mandatory: Kubernetes mirrors requests from limits, and a requests-only extended resource is invalid. This is the only app-container `resources` render path and is deliberately narrow — no general CPU/memory feature was invented along the way. - **Self-tests** — resources dict asserted *exactly*; hostPath + `/dev/dri` mount asserted **absent**; the no-`hardware` negative extended to `resources`; the `podSecurityContext` pass-through and the no-synthesised-`securityContext` assertion kept; **new**: the limit must never reach the oauth2-proxy sidecar. **Mutation-tested, 8/8 mutants caught** — `requests` instead of `limits`, emission dropped, hostPath re-added, emitted unconditionally, `supplementalGroups` synthesised, resource renamed `…/xe`, quantity as the string `"1"`, limit leaked onto the sidecar. - **`schemas/renderer-contract.md`** — mechanism half of §`hardware` and invariant 8 rewritten; the `EPERM` measurement recorded as the *reason* for the design so nobody simplifies it back; `DirectoryOrCreate` kept but labelled historical. - **`catalog/plex.yaml`** — comment correction only (the old text restated the dead mechanism). Measured facts that remain true are kept, including the no-`supplementalGroups` explanation. - **`runbooks/plex-quicksync.md`** — diagnostics now check `.status.allocatable["gpu.intel.com/i915"]` and the plugin DaemonSet; the decisive open test is specified **as uid 1000 via `s6-setuidgid plex`**; new top-of-page "STOP HERE IF PLEX IS `Pending`" plus a "Plex will not schedule" procedure. Manual-toggle section unchanged. - **`apps/wave-0/plex.yaml`** — re-rendered: six hostPath lines out, a three-line `resources` block in. ```diff + resources: + limits: + gpu.intel.com/i915: 1 volumeMounts: - name: config mountPath: /config - name: media mountPath: /media readOnly: true - - name: dri - mountPath: /dev/dri @@ - - name: dri - hostPath: - path: /dev/dri - type: DirectoryOrCreate ``` `git status --porcelain apps/` ⇒ `M apps/wave-0/plex.yaml` and nothing else. `apps/wave-1/keycloak.yaml` and `apps/wave-0/vm-demo.yaml` verified untouched (they are excluded from the golden comparison by `RENDER_GOLDEN_NOT_ONBOARDED`, so a spurious diff there would land ungated). `clusters/` untouched; `--emit-waves` not run. ## The one real regression — stated rather than sold | | device/resource unavailable ⇒ | |---|---| | **Before** | `DirectoryOrCreate` mounted an empty dir; Plex ran and **silently software-transcoded**. Invisible, harmless to everything else. | | **After** | Plex is **Unschedulable**. `apps-wave-0` is `wait: true` and `apps-wave-1` `dependsOn` it, so this **stalls the app tier**. | Unavoidable: an extended resource is a hard scheduling constraint, and it is also the only thing that produces the cgroup allow rule. There is no graceful-degradation variant. Plex's schedulability is now coupled to the `intel-gpu-plugin` DaemonSet; that is the price of hardware transcoding working at all. **Escape hatch — one field, no code change:** delete the `hardware:` block from `catalog/plex.yaml` and re-render. Plex returns to software transcoding and the tier unblocks. It is in the contract, in the runbook ("Plex will not schedule → remedy B", with commands), and in the handoff. ## Gate Pinned toolbox, from a worktree (`DEV_KUBECONFIG=/dev/null` + an `ENGINE` shim bind-mounting the main checkout's `.git` read-only, per CLAUDE.md §3; `prod.kubeconfig` was not copied). Run on `9babe57`: **`rc 3` — UNDETERMINED. NOT green, and I am not calling it green.** Three skips, the three expected ones; **nothing failed**. | stage | result | |---|---| | `spec-immutability` | **SKIPPED** — spec repo not present (R-TEST-06 unverified) | | `decisions-log` | green | | `lint` | green | | `l0` | green | | `render-golden` | green | | `l1` | green | | `approval-policy` | green (`event='none'`) | | `gitea-settings` | **SKIPPED** — `SETTINGS_API_TOKEN` unset | | `conformance` | **SKIPPED** — `CLUSTER_AVAILABLE != true` | `diff base: origin/main @ 8214642d87de … HEAD @ 9babe5768f88`; touched requirements resolved (`R-PLAT-02` among them), so the R-TEST-03 coverage gate actually applied. Note `.gitea/workflows/gate.yml:138` is `./ci/gate.sh || [ "$?" -eq 3 ]`, so **CI's tick cannot distinguish rc 0 from rc 3** — read the stage output. ## Still NOT verified **Hardware transcoding is still not observed working.** A green gate proves the manifest renders. - The `open()` test **cannot be re-run until this merges and Flux reconciles**. The case for the change rests on the plugin's `DeviceSpec{… Permissions: "rw"}` read from v0.37.0 *source*, not observed on this cluster. - The Plex-side `HardwareAcceleratedCodecs` toggle is **still unset**, and no env var/ConfigMap/`postBuild` can set it. - I measured nothing on the cluster: `i915=1` on all three nodes and the DaemonSet's 3/3/3 come from the brief (2026-10-01, DaemonSet 2m15s old). Per CLAUDE.md §2b, re-measure. - The new failure mode is reasoned from `wait: true` + `dependsOn`, not drilled. The escape hatch is likewise reasoned, not rehearsed. - No L2/L3 conformance here or in CI. ## Post-merge sequence (operator) 1. Merge; confirm `apps-wave-0`'s `.status.lastAppliedRevision` is the merge commit. 2. Confirm Plex **scheduled** — `get pod -o wide`. `Pending` ⇒ runbook "Plex will not schedule". 3. Confirm it consumed the resource — `…containers[0].resources.limits`. 4. **Decisive test, as uid 1000. `rc=0` is the pass condition:** `exec deploy/plex -- s6-setuidgid plex sh -c 'exec 3<>/dev/dri/renderD128 && echo RW-OPEN-OK || echo RW-OPEN-DENIED'`. **Passing as `uid 0` is weaker evidence and must not be accepted as proof** — Plex runs as 1000, and uid 0 was one of the two `EPERM`s that killed the hostPath. 5. **Only then** the Plex UI toggle: Settings → Transcoder → "Use hardware acceleration when available" (under *Show Advanced*) → Save. Force a transcode; Dashboard → Activity showing `Transcode (hw)` is the proof. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
gitea-admin added 2 commits 2026-10-02 04:27:42 +00:00
The hostPath mechanism was MEASURED NOT TO WORK. In the live Plex pod on
k3s-prod-2, 2026-10-01: the device node was VISIBLE in the container
(`crw-rw---- root video2 226,128 renderD128`) and could not be opened --
`uid 0 O_RDWR -> EPERM` and `uid 1000 O_RDWR -> EPERM`. EPERM from the DEVICE
CGROUP, not EACCES from file permissions, proven three ways: root on the HOST
opens the same device rc=0; the node is unified cgroup2fs; and the container's
runc config.json read `linux.resources.devices: [{allow: False, access: 'rwm'}]`
with `linux.devices: None` -- one deny-all rule, no allow entry for major 226.
A hostPath is a filesystem operation: it binds the device node in but adds no
cgroup allow rule, because Kubernetes cannot infer one from a volume.

The Intel GPU device plugin produces that allow rule
(`createDeviceSpecsFromDrmFiles` emits `DeviceSpec{..., Permissions: "rw"}`).
It shipped as #36 and is live: all three prod nodes advertise
`gpu.intel.com/i915: 1`, DaemonSet 3/3/3 (measured 2026-10-01).

ZERO ServiceDefinition field changes, which was the entire point of making the
field an enum of capability classes rather than a device path. `catalog/plex.yaml`'s
`hardware:` block comes through byte-identical; only its comment changes, because
it restated the old mechanism. Contract invariant 8 holds, now demonstrated rather
than claimed.

- catalog/render.py: `DRI_VOLUME_NAME`/`DRI_DEVICE_DIR` retired for
  `QUICKSYNC_RESOURCE`; the hardware branch now sets
  `resources.limits["gpu.intel.com/i915"] = 1` on the APP container only (never a
  sidecar) and emits no volume, no mount, no securityContext, no nodeSelector.
  `limits` not `requests` is mandatory: Kubernetes mirrors requests from limits,
  and a requests-only extended resource is invalid. This is the only app-container
  `resources` render path and is deliberately narrow -- no general CPU/memory
  feature was invented.
- selftests: resources dict asserted EXACTLY; hostPath/mount asserted ABSENT; the
  no-`hardware` negative extended to `resources`; the podSecurityContext
  pass-through and the no-synthesised-securityContext assertions kept; one new
  assertion that the limit never reaches the oauth2-proxy sidecar. Mutation-tested:
  8/8 mutants caught (requests-instead-of-limits, emission dropped, hostPath
  re-added, emitted unconditionally, supplementalGroups synthesised, wrong resource
  name, quantity as a string, limit leaked onto the sidecar).
- apps/wave-0/plex.yaml: six hostPath lines removed, a three-line resources block
  added. Only golden touched; keycloak/vm-demo unchanged.

THE REGRESSION, STATED RATHER THAN SOLD. Before: device missing ->
`DirectoryOrCreate` mounted an empty dir -> Plex ran and SILENTLY
software-transcoded (invisible, harmless to everything else). After: resource
unavailable (plugin broken, removed, or not yet reconciled) -> Plex is
UNSCHEDULABLE, and `apps-wave-0` is `wait: true` with `apps-wave-1` dependsOn it,
so that STALLS THE APP TIER. Unavoidable: an extended resource is a hard
scheduling constraint, and it is also the only thing that produces the cgroup
allow rule. There is no graceful-degradation variant. Plex's schedulability is now
coupled to the intel-gpu-plugin DaemonSet. Escape hatch, one field and no code
change: delete the `hardware:` block from catalog/plex.yaml and re-render.
Documented in the contract, the runbook (its own top-of-page section and a
"Plex will not schedule" procedure) and the handoff.

HARDWARE TRANSCODING IS STILL NOT OBSERVED WORKING. The decisive open() test
cannot be re-run until this merges and Flux reconciles, and the Plex-side
`HardwareAcceleratedCodecs` toggle is still unset. A green gate proves the
manifest renders.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
docs(decisions): handoff for the quicksync mechanism swap, including the regression it trades into
gate / gate (pull_request) Successful in 1m42s
gate / conformance (pull_request) Has been skipped
9babe5768f
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
jakman85 approved these changes 2026-10-02 05:02:19 +00:00
jakman85 merged commit bdfa72b1c7 into main 2026-10-02 05:02:23 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

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