feat(gpu): Intel GPU device plugin v0.37.0, in its own Flux Kustomization
gate / gate (pull_request) Successful in 1m41s
gate / conformance (pull_request) Has been skipped

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]>
This commit is contained in:
jakman85andClaude Opus 5 committed 2026-10-01 22:25:33 -05:00
1 parent cdf3e14e27
commit 4e1b169256
7 files changed
+786

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
---
# Flux Kustomization: Intel GPU device plugin — reconciles ./platform/intel-gpu-plugin.
# Makes the three prod nodes advertise `gpu.intel.com/i915`, which is the resource a later PR will
# have `hardware.gpu: intel-quicksync` request in place of today's non-working /dev/dri hostPath.
#
# WHY ITS OWN KUSTOMIZATION, AND NOT ONE LINE IN platform/kustomization.yaml
#
# The cheap option was a line in the shared `platform` tier plus a new platform/ directory, and
# neither path is sensitive in infra/gitea/approval-policy.json, so it would have needed no human
# reviewer on the policy's terms. It is still the wrong place, for the reason the repo already
# wrote down in platform/kustomization.yaml when Cilium was split out of that tier:
#
# * `platform` is `wait: true` over eleven-plus components, so its Ready condition cannot tell
# you "the GPU plugin is wedged" from "longhorn is slow".
# * The only emergency valve would be `flux suspend kustomization platform` — freezing storage,
# TLS, DNS, ingress, monitoring, Keycloak and KubeVirt in order to stop touching a device
# plugin.
# * The tier's 10m interval / 5m timeout are explicitly called out there as WRONG for a 3-node
# DaemonSet rollout. This is a 3-node DaemonSet rollout.
#
# And the consequence is worse here than for an average component, because of what sits downstream:
# `platform` gates platform-config, which gates apps-wave-0 (wait:true), which gates apps-wave-1.
# A plugin DaemonSet that never went healthy inside that tier would stall every app on "dependency
# not ready" — i.e. a component added to fix a SILENT NO-OP (software transcoding) would have
# acquired the ability to cause a REAL OUTAGE. That trade is the whole argument.
#
# The usual objection to this placement is the approval cost, and it does not apply: Gitea enforces
# `required_approvals: 1` with `block_admin_merge_override: true` on EVERY PR here regardless of
# path, and authors cannot self-approve. So this change needed the operator's approval either way;
# touching clusters/** costs nothing extra and buys a blast radius of exactly one component.
#
# dependsOn: [cilium], WHICH IS THE MINIMUM AND ALSO THE EARLIEST
#
# Not platform-config (what headlamp/crafty/homepage/oauth2-proxy use) — those are leaf UIs that
# genuinely need Keycloak + wildcard TLS + split-horizon DNS + Gateway. This plugin needs none of
# it. It never uses the network and never talks to the API server; it talks to the local kubelet
# over a unix socket. Its only real prerequisite is a working CNI, so its pod can get a sandbox at
# all, and `cilium` already depends on `flux-system` (the root Kustomization that applies the
# cluster-vars ConfigMap this one substitutes from), so that edge is transitively satisfied too.
#
# Depending on `cilium` rather than `platform` also gets the resource advertised EARLIEST — this
# reconciles in parallel with the `platform` tier instead of behind all eleven of its components.
# That matters on a cold start once the consumer PR lands: a Plex pod requesting a resource no node
# advertises is Unschedulable, and apps-wave-0 is wait:true. Reconciling early shrinks that window
# instead of widening it. It is not a GUARANTEE — see the ordering note below, which is a real
# constraint on the next PR and not a formality.
#
# Kyverno (in `platform`) is deliberately NOT a dependency: every policy that could match this
# workload is scoped to namespaces labelled app-namespace=true, and this namespace is labelled
# "false" (platform/intel-gpu-plugin/namespace.yaml). So there is no admission race to lose.
#
# wait: true — A HEALTH SIGNAL, NOT A GATE. This is the one place this component departs from the
# headlamp/crafty leaf template, and the departure is deliberate. Those run wait:false because a
# leaf UI can be legitimately not-Ready for a long time during a cold start while it waits on
# Keycloak/DNS/TLS, so its health is not a useful signal. This DaemonSet has no such dependency: it
# reads sysfs and opens a socket, so on healthy nodes it should be Ready in seconds, and
# not-Ready genuinely means BROKEN. wait:true turns `flux get kustomization intel-gpu-plugin` into
# a one-line answer to "did the plugin come up on all three nodes", which is exactly the question
# the operator must answer before the consumer PR may merge.
#
# NOTHING IN THE GRAPH dependsOn THIS, so wait:true gates nothing — the blast radius of an unhealthy
# plugin is this Kustomization's own Ready condition and nothing else. Anyone later adding
# `dependsOn: [intel-gpu-plugin]` is choosing to make plugin health gate their thing; for the app
# waves that would hand a device plugin the power to stall every app, which is the thing this file
# exists to prevent. Think before adding that edge.
#
# ORDERING — DO NOT REORDER THE STACK. The renderer change (catalog/render.py emitting
# `gpu.intel.com/i915: 1` for `hardware.gpu: intel-quicksync` instead of the hostPath) must NOT
# land until this plugin is merged AND confirmed advertising the resource on all three nodes. If
# the renderer requests a resource no node advertises, the Plex pod is Unschedulable; apps-wave-0 is
# wait:true and apps-wave-1 dependsOn it, so that stalls the whole app tier — turning today's silent
# no-op into a real outage. This PR's only job is to make the resource exist.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: intel-gpu-plugin
namespace: flux-system
spec:
# Static vendored manifests with nothing to re-render; kustomize-controller is source-triggered,
# so this is only the drift-correction floor.
interval: 10m
retryInterval: 2m
# 10m, not the 5m the `platform` tier uses: a 3-node DaemonSet rollout under `wait: true` can
# exceed 5m on a cold start (image pull on three nodes), and the Cilium split-out comment calls
# out that exact mismatch as one of its reasons for leaving the shared tier.
timeout: 10m
prune: true
# See the header: a health SIGNAL for a component nothing depends on, not a gate.
wait: true
dependsOn:
- name: cilium # the CNI — the only real prerequisite; transitively gives us flux-system too
sourceRef:
kind: GitRepository
name: flux-system
path: ./platform/intel-gpu-plugin
# Retained for parity with every other tier / future overlay knobs. This component references no
# ${VARS} today, and its manifests contain no `$` literals for envsubst to eat (unlike
# clusters/base/runbooks.yaml, which omits substitution for exactly that reason).
postBuild:
substituteFrom:
- kind: ConfigMap
name: cluster-vars
# No SOPS decryption patch: this component carries no Secret, so clusters/dev adds nothing for it
# (same as homepage/runbooks, unlike headlamp/crafty/oauth2-proxy/backup).