feat(schema): declare a host hardware capability, hardware.gpu: intel-quicksync #27

Merged
jakman85 merged 2 commits from feat/sd-hardware-capability into main 2026-10-02 02:17:23 +00:00
Owner

What this is

A ServiceDefinition could not declare that it needs the host's Intel QuickSync render device. The schema is additionalProperties: false, and apps/ is generated from catalog/ + the schema, so there was no authorable form for it at all and a hand-edit to apps/ would be reverted by the next render.

This adds the capability. It does not wire Plex — that is separate work on top of this.

New optional top-level SD property hardware, with one optional member gpu, an enum whose only current value is intel-quicksync. Schema 1.7.0 → 1.8.0.

hardware:
  gpu: intel-quicksync

Why an enum and not a device path

The SD declares what class of device it needs; the hostPath and the mount live only in catalog/render.py. Migrating to the Intel GPU device plugin (gpu.intel.com/i915 resource requests, no hostPath at all) is then a renderer change with zero catalog edits and no per-service device wiring to re-review.

Letting an SD name /dev/dri directly would look like a simplification and would make that migration a catalog-wide edit. Both the schema description and renderer-contract.md §hardware say so explicitly, so the next person does not "simplify" it. That property also happens to be this PR's insurance policy — see the open risk at the bottom.

What the renderer emits

Exactly two things, on the app container only (never a sidecar):

      containers:
      - name: toy
        image: index.docker.io/plexinc/pms-docker:1.41.0
        volumeMounts:
        - name: config
          mountPath: /data/config
        - name: dri
          mountPath: /dev/dri
      securityContext:          # <- the SD's OWN, passed through untouched
        runAsUser: 1000
        supplementalGroups:
        - 44
      volumes:
      - name: config
        persistentVolumeClaim:
          claimName: toy-config
      - name: dri
        hostPath:
          path: /dev/dri
          type: DirectoryOrCreate

(a real render of a throwaway SD carrying hardware, a Longhorn volume and its own podSecurityContext: {runAsUser: 1000, supplementalGroups: [44]} — note [44], not [44, something]: the field adds nothing to it)

The mount is deliberately not readOnly — VA-API opens the render node read-write.

No supplementalGroups, and that is the interesting part

/dev/dri/renderD128 is root:render 0660, so the obvious move is to push the host render GID into podSecurityContext.supplementalGroups. An earlier revision of this branch did exactly that. It was measured to be a no-op and has been removed — measured in the running container on prod-2, 2026-09-30:

PID 1   s6-svscan            Uid: 0     Gid: 0     Groups: 0
pid 246 Plex Media Server    Uid: 1000  Gid: 1000  Groups: 100 1000

/etc/services.d/plex/run ends in exec s6-setuidgid plex …, and the child's group list is exactly the /etc/group account-database list — root's gid 0, which PID 1 holds, has vanished. That is a setgroups() that replaces rather than appends, so anything Kubernetes supplies reaches s6-svscan and is discarded before the app ever starts.

What actually works is the image's own /etc/cont-init.d/45-plex-hw-transcode-and-connected-tuner: it finds every char device under /dev/dri, stat -c '%g's its GID, groupadds a matching group inside the container when none exists, and usermod -a -Gs the app user into it — every start, nothing hardcoded. cont-init.d completes before services.d, so the database is already correct at exec time. Mounting the device is sufficient; the group handling is the image's job and it does it correctly.

An inert supplementalGroups would have read as the mechanism to whoever debugged this next, so the absence is documented at the emission site and in the contract, and --selftest asserts it. Corollary: because the hook re-stats the device on every start, host GID drift (a rebuild renumbering the systemd dynamic render group) is self-healing, and no GID is pinned anywhere in this repo.

DirectoryOrCreate is load-bearing — please do not tighten it to Directory

A node that comes back without the render device (rebuild, BIOS change, driver regression, hardware swap) must degrade the workload to software transcoding, not fail the mount. apps-wave-0 is wait: true and apps-wave-1 dependsOn it, so a failed hostPath mount stalls the entire app tier for a feature that is not load-bearing. /dev is tmpfs, so the empty directory the kubelet creates leaves nothing durable behind. The reasoning is at the emission site.

Its cost is stated rather than papered over: a missing device is silent. No metric exists to alert on (media apps here declare no metricsEndpoint, so no ServiceMonitor exists), and an alert that cannot fire is worse than none — see the 2026-09-13 correction in renderer-contract.md §observability.

Also deliberately NOT emitted

  • no privileged: true — Kyverno restrict-host-namespaces is Enforce on app namespaces and the render node does not need it;
  • no nodeSelector — placement is a separate concern with its own justification;
  • no resource requests/limits — there is no app-container resource render path and inventing one here is scope creep.

Verification

  • python3 catalog/render.py --selftest → render.py selftest: PASS, rc 0. New cases in the existing check(...) style: the positive (volume + mount, and no securityContext / privileged / nodeSelector), the negative that matters (no hardware ⇒ no volume, no mount, no securityContext — mirroring the existing podSecurityContext negative), and a pass-through case (an SD's own podSecurityContext survives byte-for-byte).
  • The self-tests bite. Mutating the guard to if True: in a scratch copy → rc 1, failing on the negative. A test that cannot fail is not a test.
  • Re-render is a zero diff. --all catalog -o apps --wave-layout --no-image-automation then git diff --stat apps/ → empty, git status --porcelain apps/ → empty. The change is inert until a service opts in.
  • Schema valid; all 12 catalog/*.yaml validate with 0 errors. gpu: /dev/dri rejected (enum), an extra key rejected (additionalProperties: false), hardware: {} rejected (minProperties: 1).
  • Gate: rc 3 — UNDETERMINED, NOT green. Nothing failed. spec-immutability SKIPPED (sibling spec repo absent from a worktree — tolerated per CLAUDE.md §3), gitea-settings SKIPPED (SETTINGS_API_TOKEN unset), conformance SKIPPED (CLUSTER_AVAILABLE!=true, Phase-0 standing skip). decisions-log, lint, l0 (19 cases, 0 failures/skips/unwired), render-golden ("all 10 onboarded service golden(s) reproduce"), l1 (3 cases, 0 failures/skips/unwired) and approval-policy all pass. No stage skipped for a reason caused by this change. Full per-stage table in the handoff entry.

Known gaps (also in the handoff)

  • Whether containerd's device cgroup permits a non-privileged pod to open() a bind-mounted char device. Still unverified, and it is the single largest technical risk in this workstream. Settling it requires applying a pod, which is an operator action. The container's PID 1 running as uid 0 helps on the DAC side but does not bypass the cgroup gate. If it blocks, the fallback is the Intel device plugin — which the intent-shaped enum makes a renderer-only change with no catalog edit. That is the design justifying itself, and the reason to keep the enum even if the hostPath mechanism loses.
  • The hook dependency is pinned to an image that floats. All of the in-container measurements above were read from digest sha256:e0ab27395614… (plexmediaserver 1.43.4.10903), but the deployment runs plexinc/pms-docker:latest with imagePullPolicy: Always. A silent pull can change the behaviour this feature depends on, with no commit anywhere in the repo to show for it. Noted, not fixed here.
  • No gate stage runs render.py --selftest (git grep -- --selftest ci/ is empty). The tests were run by hand. Wiring them in touches ci/, a sensitive path, so it is deliberately not in this PR.
  • The prod node facts, the in-container measurements and the Kyverno posture are recon handed to me, not re-measured by me (CLAUDE.md §2b). I ran no kubectl and opened no container shell.

Touches only schemas/, catalog/, decisions/ — none sensitive per infra/gitea/approval-policy.json. conformance/** and clusters/** untouched.

🤖 Generated with Claude Code

## What this is A ServiceDefinition could not declare that it needs the host's Intel QuickSync render device. The schema is `additionalProperties: false`, and `apps/` is generated from `catalog/` + the schema, so there was no authorable form for it at all and a hand-edit to `apps/` would be reverted by the next render. This adds the **capability**. It does **not** wire Plex — that is separate work on top of this. New optional top-level SD property `hardware`, with one optional member `gpu`, an enum whose only current value is `intel-quicksync`. Schema **1.7.0 → 1.8.0**. ```yaml hardware: gpu: intel-quicksync ``` ## Why an enum and not a device path The SD declares **what class of device it needs**; the hostPath and the mount live only in `catalog/render.py`. Migrating to the Intel GPU device plugin (`gpu.intel.com/i915` resource requests, no hostPath at all) is then a renderer change with **zero catalog edits** and no per-service device wiring to re-review. Letting an SD name `/dev/dri` directly would look like a simplification and would make that migration a catalog-wide edit. Both the schema `description` and `renderer-contract.md` §`hardware` say so explicitly, so the next person does not "simplify" it. That property also happens to be this PR's insurance policy — see the open risk at the bottom. ## What the renderer emits Exactly two things, on the app container only (never a sidecar): ```yaml containers: - name: toy image: index.docker.io/plexinc/pms-docker:1.41.0 volumeMounts: - name: config mountPath: /data/config - name: dri mountPath: /dev/dri securityContext: # <- the SD's OWN, passed through untouched runAsUser: 1000 supplementalGroups: - 44 volumes: - name: config persistentVolumeClaim: claimName: toy-config - name: dri hostPath: path: /dev/dri type: DirectoryOrCreate ``` (a real render of a throwaway SD carrying `hardware`, a Longhorn volume and its own `podSecurityContext: {runAsUser: 1000, supplementalGroups: [44]}` — note `[44]`, **not** `[44, something]`: the field adds nothing to it) The mount is deliberately not `readOnly` — VA-API opens the render node read-write. ## No `supplementalGroups`, and that is the interesting part `/dev/dri/renderD128` is `root:render` `0660`, so the obvious move is to push the host `render` GID into `podSecurityContext.supplementalGroups`. An earlier revision of this branch did exactly that. **It was measured to be a no-op and has been removed** — measured in the running container on prod-2, 2026-09-30: ``` PID 1 s6-svscan Uid: 0 Gid: 0 Groups: 0 pid 246 Plex Media Server Uid: 1000 Gid: 1000 Groups: 100 1000 ``` `/etc/services.d/plex/run` ends in `exec s6-setuidgid plex …`, and the child's group list is exactly the `/etc/group` account-database list — root's gid `0`, which PID 1 holds, has **vanished**. That is a `setgroups()` that *replaces* rather than appends, so anything Kubernetes supplies reaches `s6-svscan` and is discarded before the app ever starts. What actually works is the image's own `/etc/cont-init.d/45-plex-hw-transcode-and-connected-tuner`: it `find`s every char device under `/dev/dri`, `stat -c '%g'`s its GID, `groupadd`s a matching group *inside the container* when none exists, and `usermod -a -G`s the app user into it — every start, nothing hardcoded. `cont-init.d` completes before `services.d`, so the database is already correct at exec time. **Mounting the device is sufficient; the group handling is the image's job and it does it correctly.** An inert `supplementalGroups` would have read as *the mechanism* to whoever debugged this next, so the absence is documented at the emission site and in the contract, and `--selftest` asserts it. Corollary: because the hook re-`stat`s the device on every start, host GID drift (a rebuild renumbering the *systemd dynamic* `render` group) is **self-healing**, and no GID is pinned anywhere in this repo. ## `DirectoryOrCreate` is load-bearing — please do not tighten it to `Directory` A node that comes back without the render device (rebuild, BIOS change, driver regression, hardware swap) must degrade the workload to **software transcoding**, not fail the mount. `apps-wave-0` is `wait: true` and `apps-wave-1` `dependsOn` it, so a failed hostPath mount stalls the **entire app tier** for a feature that is not load-bearing. `/dev` is tmpfs, so the empty directory the kubelet creates leaves nothing durable behind. The reasoning is at the emission site. Its cost is stated rather than papered over: **a missing device is silent.** No metric exists to alert on (media apps here declare no `metricsEndpoint`, so no ServiceMonitor exists), and an alert that cannot fire is worse than none — see the 2026-09-13 correction in `renderer-contract.md` §`observability`. ## Also deliberately NOT emitted - no `privileged: true` — Kyverno `restrict-host-namespaces` is Enforce on app namespaces and the render node does not need it; - no `nodeSelector` — placement is a separate concern with its own justification; - no resource requests/limits — there is no app-container resource render path and inventing one here is scope creep. ## Verification - `python3 catalog/render.py --selftest` → `render.py selftest: PASS`, **rc 0**. New cases in the existing `check(...)` style: the positive (volume + mount, and *no* `securityContext` / privileged / nodeSelector), the negative that matters (no `hardware` ⇒ no volume, no mount, no `securityContext` — mirroring the existing `podSecurityContext` negative), and a pass-through case (an SD's own `podSecurityContext` survives byte-for-byte). - **The self-tests bite.** Mutating the guard to `if True:` in a scratch copy → **rc 1**, failing on the negative. A test that cannot fail is not a test. - **Re-render is a zero diff.** `--all catalog -o apps --wave-layout --no-image-automation` then `git diff --stat apps/` → empty, `git status --porcelain apps/` → empty. The change is inert until a service opts in. - Schema valid; all **12** `catalog/*.yaml` validate with 0 errors. `gpu: /dev/dri` rejected (enum), an extra key rejected (`additionalProperties: false`), `hardware: {}` rejected (`minProperties: 1`). - **Gate: `rc 3` — UNDETERMINED, NOT green.** Nothing failed. `spec-immutability` SKIPPED (sibling spec repo absent from a worktree — tolerated per CLAUDE.md §3), `gitea-settings` SKIPPED (`SETTINGS_API_TOKEN` unset), `conformance` SKIPPED (`CLUSTER_AVAILABLE!=true`, Phase-0 standing skip). `decisions-log`, `lint`, `l0` (19 cases, 0 failures/skips/unwired), `render-golden` (*"all 10 onboarded service golden(s) reproduce"*), `l1` (3 cases, 0 failures/skips/unwired) and `approval-policy` all pass. No stage skipped for a reason caused by this change. Full per-stage table in the handoff entry. ## Known gaps (also in the handoff) - **Whether containerd's device cgroup permits a non-privileged pod to `open()` a bind-mounted char device. Still unverified, and it is the single largest technical risk in this workstream.** Settling it requires applying a pod, which is an operator action. The container's PID 1 running as uid 0 helps on the DAC side but does not bypass the cgroup gate. If it blocks, the fallback is the Intel device plugin — which the intent-shaped enum makes a **renderer-only change** with no catalog edit. That is the design justifying itself, and the reason to keep the enum even if the hostPath mechanism loses. - **The hook dependency is pinned to an image that floats.** All of the in-container measurements above were read from digest `sha256:e0ab27395614…` (`plexmediaserver 1.43.4.10903`), but the deployment runs `plexinc/pms-docker:latest` with `imagePullPolicy: Always`. A silent pull can change the behaviour this feature depends on, with no commit anywhere in the repo to show for it. Noted, not fixed here. - No gate stage runs `render.py --selftest` (`git grep -- --selftest ci/` is empty). The tests were run by hand. Wiring them in touches `ci/`, a sensitive path, so it is deliberately not in this PR. - The prod node facts, the in-container measurements and the Kyverno posture are recon handed to me, not re-measured by me (CLAUDE.md §2b). I ran no `kubectl` and opened no container shell. Touches only `schemas/`, `catalog/`, `decisions/` — none sensitive per `infra/gitea/approval-policy.json`. `conformance/**` and `clusters/**` untouched. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
gitea-admin added 1 commit 2026-09-30 15:50:50 +00:00
feat(schema): declare a host hardware capability, hardware.gpu: intel-quicksync
gate / gate (pull_request) Successful in 1m41s
gate / conformance (pull_request) Has been skipped
2279d14ec5
A ServiceDefinition could not say it needs the host's Intel QuickSync render
device. The schema is `additionalProperties: false`, so there was no authorable
form for it at all, and `apps/` is generated — a hand-edit would be reverted by
the next render.

Adds optional top-level `hardware`, with one optional member `gpu`, an enum
whose only current value is `intel-quicksync` (schema 1.7.0 -> 1.8.0).

The enum is the point. The SD declares WHAT CLASS of device it needs; the
hostPath, the mount and the GID live only in catalog/render.py. Migrating to
the Intel GPU device plugin (`gpu.intel.com/i915` resource requests, no
hostPath) is then a renderer change with zero catalog edits. Letting an SD name
`/dev/dri` directly would look simpler and make that migration a catalog-wide
edit, so both the schema description and the renderer contract say so
explicitly.

When `hardware.gpu == intel-quicksync`, r_deployment emits, on the app
container / pod spec only:

  - a hostPath volume `dri` -> /dev/dri, type DirectoryOrCreate;
  - a read-write volumeMount of it at /dev/dri (VA-API opens the render node
    read-write, so deliberately not readOnly);
  - INTEL_RENDER_GID MERGED into podSecurityContext.supplementalGroups, never
    clobbering what the SD already declares.

`DirectoryOrCreate` is load-bearing. A node that comes back without the render
device (rebuild, BIOS change, driver regression, hardware swap) must degrade
the workload to software transcoding, not fail the mount: apps-wave-0 is
`wait: true` and apps-wave-1 dependsOn it, so a failed hostPath mount stalls
the whole app tier for a feature that is not load-bearing. /dev is tmpfs, so
the empty directory the kubelet creates leaves nothing durable behind. Its cost
— a missing device is silent, and no metric exists to alert on — is recorded in
the contract rather than papered over with an alert that could not fire.

Deliberately NOT emitted: no `privileged: true` (Kyverno restrict-host-
namespaces is Enforce and it is not needed), no nodeSelector (placement is a
separate concern with its own justification), no resource requests (there is no
app-container resource render path).

INTEL_RENDER_GID = 992 is a single named constant carrying its provenance:
measured on all three prod nodes on 2026-09-30, and a *systemd dynamic*
allocation rather than a static GID, so a node rebuild can move it.

No catalog entry uses the field. Re-rendering `--all catalog -o apps` is a zero
diff, which is the proof the change is inert until opted into.

Three self-test cases in the existing check(...) style: the positive, the
negative (an SD without `hardware` gains no volume, no mount, no
securityContext — mirroring the podSecurityContext negative) and the merge case
([44] + 992 -> [44, 992], fsGroup preserved). Note that NO gate stage runs
`render.py --selftest`; they were run by hand and the gap is recorded in the
handoff.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
gitea-admin force-pushed feat/sd-hardware-capability from 2279d14ec5 to 1040d253be 2026-09-30 17:49:44 +00:00 Compare
gitea-admin force-pushed feat/sd-hardware-capability from 1040d253be to f8ad3a6f81 2026-09-30 17:52:54 +00:00 Compare
gitea-admin force-pushed feat/sd-hardware-capability from f8ad3a6f81 to d9baa2a482 2026-09-30 17:55:38 +00:00 Compare
gitea-admin force-pushed feat/sd-hardware-capability from d9baa2a482 to f716e9d167 2026-10-02 02:04:31 +00:00 Compare
jakman85 approved these changes 2026-10-02 02:17:19 +00:00
jakman85 merged commit 41a20dfd20 into main 2026-10-02 02:17: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#27