Makes the three prod nodes advertise `gpu.intel.com/i915`. This is the measured
fix for the Plex hardware-transcoding blocker, and it only makes the resource
EXIST — no consumer is switched over here.
WHY. The `/dev/dri` hostPath merged on main @ cdf3e14 makes the device node
visible and not openable. Measured in the running pod on k3s-prod-2:
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 — root on the
HOST opens the same device rc=0, the node is unified cgroup2fs, and the
container's runc config.json carries one deny-all rule with `linux.devices:
None`. A hostPath is a filesystem operation: it makes the device node appear
and adds no cgroup allow rule, because Kubernetes cannot infer one from a
volume. A device plugin's Allocate response is what makes kubelet populate
`linux.devices` AND the matching cgroup allow rule — verified in the v0.37.0
source, which emits `DeviceSpec{HostPath, ContainerPath, Permissions: "rw"}`
per DRM node. It is the only non-privileged way to get there; `privileged:
true` is blocked by Kyverno restrict-host-namespaces under Enforce in app
namespaces, and `plex` is labelled app-namespace=true.
PLACEMENT: its own Kustomization (dependsOn cilium, wait:true), not a line in
platform/kustomization.yaml. The shared tier was cheaper and needed no human
reviewer on approval-policy.json's terms, but it is wait:true over eleven-plus
components and the repo already argued this when Cilium was split out: its
Ready condition cannot tell "the plugin is wedged" from "longhorn is slow", the
only emergency valve is freezing the whole tier, and the tier's 10m/5m
interval/timeout are called out there as wrong for a 3-node DaemonSet rollout —
which is exactly what this is. Worse here than average: platform gates
platform-config gates apps-wave-0 (wait:true) gates apps-wave-1, so a plugin
that never went healthy inside it would stall every app — a component added to
fix a silent no-op would have gained the power to cause a real outage.
Touching clusters/** costs nothing extra: required_approvals:1 with
block_admin_merge_override:true applies to every PR here regardless of path.
wait:true is a health SIGNAL, not a gate — nothing in the rendered graph
dependsOn this, so it gates nothing, while making `flux get kustomization
intel-gpu-plugin` answer "did it come up on all three nodes".
NON-CHOICES, each argued in the README: standalone DaemonSet over the operator
(no CRD, controller-manager, webhook or second namespace to keep healthy);
`--shared-dev-num` left at the upstream default of 1 (a higher count is a lie
the scheduler believes — no GPU-side partitioning backs it, so it converts a
loud Unschedulable into a quiet "transcodes are slow"); `--health-management`
left OFF, the one flag that would open a device and reintroduce this EPERM; no
NFD overlay (all three nodes identical); no supplementalGroups (the pms-docker
cont-init hook already adds `plex` to each device's group, measured live as
Groups: 44 100 992 1000).
The DaemonSet is vendored as `kubectl kustomize` output of upstream v0.37.0's
gpu_plugin/base, rather than referenced as a remote kustomize base, for the
reason external-snapshotter records: a remote base is git-fetched on every
reconcile and those fetches have flaked a whole tier. It is generated rather
than byte-copied because upstream mixes YAML sequence-indentation styles within
the one document and ci/yamllint.yaml sets `indent-sequences: consistent`, so a
verbatim copy is a hard lint ERROR — measured, not guessed: the first gate run
of this branch returned rc 1 BLOCKED on exactly that. The transform is proven
formatting-only (`yq -P 'sort_keys(..)'` of both diffs clean). Image pinned to
docker.io/intel/intel-gpu-plugin:0.37.0 (digest verified present:
sha256:7001200071db10587fc31184eedda4f5cf291efba737adf85a8c80539f7fa5e1).
ORDERING — DO NOT REORDER. catalog/render.py, catalog/plex.yaml and
apps/wave-0/plex.yaml are untouched. The renderer change that emits
`gpu.intel.com/i915: 1` instead of the hostPath must NOT land until this plugin
is merged AND confirmed advertising the resource on all three nodes: a request
for a resource no node advertises makes the Plex pod Unschedulable, and
apps-wave-0 is wait:true with apps-wave-1 depending on it.
VERIFIED: manifests render offline (component, clusters/base, both overlays);
no node currently advertises gpu.intel.com/* (queried — "NONE"); the plugin
only lists/stats devices and never opens one (no os.Open/OpenFile/ioctl/syscall
anywhere in its 908-line source); all four app-scoped Kyverno policies are
gated on app-namespace=true and the new namespace is labelled "false".
NOT VERIFIED: anything runtime. The plugin has never executed, the resource has
never been advertised, and the open() test has not been re-run. Gate returned
rc 3 (UNDETERMINED) with exactly the three expected skips — not green.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
14 KiB
date, branch, title
| date | branch | title |
|---|---|---|
| 2026-10-01 | feat/intel-gpu-device-plugin | Intel GPU device plugin, in its own Kustomization — the resource exists; nothing has run yet |
What changed
Deployed the Intel Device Plugin for Kubernetes (GPU), standalone DaemonSet, pinned at
v0.37.0, so the prod nodes advertise gpu.intel.com/i915. This is the measured fix for the
Plex hardware-transcoding blocker: the /dev/dri hostPath merged on main @ cdf3e14 makes the
device node visible and not openable (EPERM from the device cgroup at both uid 0 and uid
1000; the container's runc config.json carries one deny-all rule and linux.devices: None). A
hostPath is a filesystem operation and adds no cgroup allow rule. A device plugin's Allocate
response is what makes kubelet populate linux.devices and the matching cgroup allow rule, and
it is the only non-privileged way to get there — privileged: true is blocked by Kyverno
restrict-host-namespaces under Enforce in app namespaces, and plex is labelled
app-namespace=true.
New files:
platform/intel-gpu-plugin/intel-gpu-plugin.yaml— the DaemonSet, vendored askubectl kustomizeoutput of upstreamdeployments/gpu_plugin/baseatv0.37.0, under a provenance header carrying the exact re-vendor command and both sha256s (generated body80ebd070…, raw upstream sourcec8773918…). Generated rather than byte-copied because upstream mixes YAML sequence-indentation styles within the one document andci/yamllint.yamlsetsindent-sequences: consistent— a verbatim copy is a hard lint error that blocks the gate. That is not a guess: the first gate run of this branch returned rc 1 BLOCKED on exactly that error, and regenerating through kustomize is the fix (and is also the re-vendor idiomplatform/external-snapshotterdocuments). Vendored at all, rather than referenced as a remote kustomize base, for the other reasonexternal-snapshotterrecords: a remote base isgit fetched on every reconcile and those fetches have flaked a whole tier.platform/intel-gpu-plugin/namespace.yaml— a PLATFORM namespace, explicitlyhomelab.keithjackson.dev/app-namespace: "false"(followingvpn-egress/kyverno).platform/intel-gpu-plugin/kustomization.yaml— namespace scoping plus the image pin.platform/intel-gpu-plugin/README.md— the measurement, the placement argument, the non-choices, and the post-merge verification sequence.clusters/base/intel-gpu-plugin.yaml— its own Flux Kustomization:dependsOn: [cilium],wait: true,interval: 10m,timeout: 10m,prune: true.- One wired line (plus its rationale comment) in
clusters/base/kustomization.yaml.
Placement: its own Kustomization, not a line in the shared platform tier. The shared tier was
cheaper and needed no human reviewer on approval-policy.json's terms, but platform is
wait: true over eleven-plus components and the repo already argued this case when Cilium was
split out: its Ready condition cannot distinguish "the plugin is wedged" from "longhorn is slow",
the only emergency valve is freezing the entire tier, and the tier's 10m/5m interval/timeout are
called out there as wrong for a 3-node DaemonSet rollout — which is exactly what this is. The
consequence is worse than average here, because platform gates platform-config gates
apps-wave-0 (wait: true) gates apps-wave-1: a plugin that never went healthy inside that tier
would stall every app, so a component added to fix a silent no-op would have gained the ability
to cause a real outage. The approval cost of touching clusters/** is nil, because
required_approvals: 1 with block_admin_merge_override: true applies to every PR here regardless
of path and authors cannot self-approve.
dependsOn: [cilium] is the minimum and the earliest: the plugin never uses the network and
never talks to the API server (it talks to the local kubelet over a unix socket), so a working CNI
is its only real prerequisite, and cilium already depends on flux-system, which applies the
cluster-vars ConfigMap this Kustomization substitutes from. Depending on cilium rather than
platform also means it reconciles in parallel with the platform tier rather than behind all
eleven of its components, which shrinks the cold-start window in which a node has not yet
advertised the resource.
wait: true is a deliberate departure from the headlamp/crafty leaf template. Those use
wait: false because a leaf UI can be legitimately not-Ready for a long time during a cold start,
making its health a useless signal. This DaemonSet reads sysfs and opens a socket, so not-Ready
genuinely means broken — and since nothing in the graph dependsOn it (verified against the
rendered graph), wait: true gates nothing while turning
flux get kustomization intel-gpu-plugin into a one-line answer to the question that must be
answered before the consumer PR may merge.
Deliberate non-choices, each argued in the README: standalone DaemonSet rather than the operator
(no CRD, no controller-manager, no cert-manager webhook, no second namespace to keep healthy);
--shared-dev-num left at the upstream default of 1 (a higher count is a lie the scheduler
believes — there is no GPU-side partitioning behind it, so it converts a loud "Unschedulable" into
a quiet "transcodes are slow"); --health-management left OFF, because it is the one flag that
would open a device and would reintroduce the exact EPERM this fixes; no NFD overlay (all three
nodes are identical); no supplementalGroups anywhere, because the pms-docker cont-init hook
already adds the plex user to each device's group and that works identically on a
plugin-injected device.
Scope held deliberately: catalog/render.py, catalog/plex.yaml and apps/wave-0/plex.yaml
are untouched. Switching Plex from the hostPath to a gpu.intel.com/i915: 1 request is a strictly
later PR, and it must not land until this plugin is merged AND confirmed advertising the resource
on all three nodes — a request for a resource no node advertises makes the Plex pod
Unschedulable, and apps-wave-0 is wait: true with apps-wave-1 depending on it, so that would
stall the whole app tier and turn today's silent no-op into a real outage.
What I verified
Mechanism, read from the v0.37.0 source rather than assumed:
- The plugin only lists devices; it never opens one. The scan path uses
os.ReadDir,os.ReadFile,os.Statandos.Readlinkover/sys/class/drmand/dev/dri.grepforos.Open|OpenFile|ioctl|syscall|unix\.acrosscmd/gpu_plugin/gpu_plugin.go(908 lines) returns nothing. Listing andstat()are what demonstrably work today, so the plugin needs no cgroup grant and no extra privilege — it does not need more than the upstream manifest gives it. - The fix mechanism is real:
createDeviceSpecsFromDrmFilesemitspluginapi.DeviceSpec{HostPath, ContainerPath, Permissions: "rw"}per DRM node, which is the thing kubelet turns intolinux.devicesplus a cgroup allow rule. - The resource name is
gpu.intel.com/i915by derivation, not by hope:scan()callsdevTree.AddDevice(devProps.driver(), ...), so the suffix is the kernel driver name, and these nodes havei915bound. --shared-dev-numdefault is1(flag.IntVar(..., 1, ...));--health-managementdefault isfalseandsetupLevelZeroServicereturns early unless it is set.- Missing
/dev/dri/by-pathis non-fatal (newDevicePluginwarns, setsbypathFound = false).
Image pin, verified against the registry:
docker.io/intel/intel-gpu-plugin:0.37.0exists; manifest digestsha256:7001200071db10587fc31184eedda4f5cf291efba737adf85a8c80539f7fa5e1(single amd64 manifest, not an index), fetched fromregistry-1.docker.ioon 2026-10-01.v0.37.0is the latest upstream release (2026-09-16) and is not a prerelease.- The Docker Hub tag list carries
0.35.0 / 0.36.0 / 0.37.0— image tags have novprefix while GitHub release tags do.
Policy scope, read from the policies rather than assumed:
- All four app-scoped Kyverno policies (
restrict-host-namespaces,require-egress-class-label,require-auth-mode-label,require-default-deny-baseline) are gated onnamespaceSelector/selectormatchLabels: homelab.keithjackson.dev/app-namespace: "true". The new namespace is labelled"false", so none of them match it. - Measured on prod:
plexisapp-namespace=true, confirmingprivileged: trueis genuinely unavailable there;kyvernoandvpn-egressare the two namespaces carrying the label as"false"; the other platform namespaces leave it unset. Nointel*namespace exists, so no collision.
Cluster before-state (read-only, kubectl in the pinned toolbox with the prod kubeconfig
bind-mounted read-only — never copied into the worktree):
- Three nodes
k3s-prod-{1,2,3}, eachv1.36.4+k3s1, Debian 13 trixie,containerd://2.3.4-k3s1.36. - Every extended resource currently advertised is
devices.kubevirt.io/{kvm,sev,tun,vhost-net}on all three nodes. No node advertisesgpu.intel.com/*— queried explicitly, returned "NONE". That is the before-state this change is meant to move. - Those
devices.kubevirt.io/*resources are themselves advertised by a device plugin, which is measured evidence that the device-plugin API and the default kubelet socket path (/var/lib/kubelet/device-plugins) work on these nodes.
Offline rendering (the main proof available to a PR):
kubectl kustomize platform/intel-gpu-pluginrenders two objects:Namespace/intel-gpu-plugincarryingapp-namespace=false, andDaemonSet/intel-gpu-pluginin namespaceintel-gpu-plugin, with the image resolved todocker.io/intel/intel-gpu-plugin:0.37.0.kubectl kustomize clusters/baserenders, and the new Flux Kustomization appears asintel-gpu-plugin ./platform/intel-gpu-plugin wait=true dependsOn=cilium. Both overlays (clusters/prod,clusters/dev) also render and carry it.- Across the whole rendered graph, no Kustomization lists
intel-gpu-pluginindependsOn— sowait: truegates nothing. - The vendored DaemonSet is a formatting-only transform of upstream, proven rather than
asserted:
yq -P 'sort_keys(..)'of the generated file and of the raw upstream filediffclean, so passing it through kustomize changed no content. The committed body's sha256 matches the one recorded in its own provenance header (80ebd070…), so the file has not drifted from what was generated.
Gate (pinned toolbox, from this worktree, with the ENGINE shim bind-mounting the main checkout's
gitdir and DEV_KUBECONFIG=/dev/null): see the PR description for the per-stage table. rc 3
(UNDETERMINED), with exactly the three expected skips — spec-immutability (sibling spec repo is
not beside a worktree), gitea-settings (no CI token), conformance (no cluster in CI). Every
other stage passed. rc 3 is not green and is not reported as such.
What I did NOT verify
Nothing about this component's runtime behaviour. It has never run.
- The plugin has never executed. No pod has been scheduled, no image has been pulled, no scan
has happened. It cannot run until the operator merges this and Flux reconciles —
kubectl applyandflux reconcileare operator actions (CLAUDE.md §5), so this is expected, not an oversight. - The resource has never been advertised.
gpu.intel.com/i915does not exist on any node and I have not seen it exist. The claim that it will bei915rather thanxeis derived from source plus the established fact thati915is bound — it is a derivation, not an observation. - The DaemonSet has never been admitted. That the Kyverno policies do not match this namespace
is read off the policy manifests; no admission request has actually been evaluated. Nor has the
kubectl kustomizeoutput ever been server-side dry-run against the API — rendering is not admission. - The
open()test has NOT been re-run. TheEPERMis exactly as it was. Nothing in this PR touches Plex, so Plex is still software-transcoding and will continue to until the later renderer PR lands. The decisive evidence is anopen()as uid 1000 throughs6-setuidgid plex; passing as uid 0 is weaker and must not be accepted as proof. - A green gate proves the manifests render, nothing more. And this gate was not green: it was
rc 3. Note also that the Giteagatejob runs./ci/gate.sh || [ "$?" -eq 3 ](.gitea/workflows/gate.yml:138), so a green tick on this PR cannot distinguish rc 0 from rc 3, andvars.CLUSTER_AVAILABLEis unset so no L2/L3 conformance runs in CI. seLinuxOptions: type: container_device_plugin_tis carried verbatim from upstream. These nodes are Debian 13 and I did not measure whether SELinux is enabled or whether that type exists there. Expected to be inert (runc skips labelling when SELinux is disabled), but unverified — if the pods fail to start with an SELinux error, that line is the first suspect.- Renovate has not been run against this pin. The inline-marker idiom is copied from
platform/kubevirt/platform/external-snapshotter, but whether Renovate normalises thev-prefix mismatch (releasev0.37.0vs image0.37.0) for this dep is unknown. If it proposesnewTag: v0.38.0, the fix is anextractVersionpackageRule inrenovate.json. - Resource limits unexamined under load. The upstream 115Mi/100m limits are taken as-is; I have
no measurement of this plugin's footprint on these nodes. Worth a glance given
keycloak-operatoris OOMing at 91% of its limit elsewhere in this cluster. clusters/devrenders it, and there is no dev cluster (the operator confirmed thek3s-dev-*nodes are gone). That is inert rather than wrong — the unified graph (R-ENV-02) has both overlays render the same base — but it is not a thing I could test.- Branch protection / required-checks state not re-measured in this session. CLAUDE.md §2b says re-measure before relying on it; I did not, so treat the approval/gating claims in §2 as unconfirmed-by-me.