Phase 10: iGPU passthrough — render/video group GID detection (0.10.0)

- gpu_service: detect host render/video group GIDs from /dev/dri node ownership
  (render node → render GID, paired card node → video GID); added to GPUInfo +
  exposed via /api/system/gpus. inject_dri now emits numeric group_add entries
  (e.g. ["991","44"]) when GIDs are known, falling back to names otherwise;
  remove_gpu strips those GIDs + LIBVA_DRIVER_NAME; dri_group_gids() for cleanup.
- editor set-gpu passes render_gid/video_gid through; GPUSelector shows detected
  GIDs, defaults video group on, and sends them.

Verified: py_compile, unit check (inject→["991","44"] then clean removal),
frontend tsc build, image imports. Live iGPU verify is on the user's hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-07 22:51:18 +00:00
co-authored by Claude Opus 4.8
parent a4e26f880a
commit ec7e3e706f
8 changed files with 126 additions and 16 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ from services import backup_service, compose_service
logger = logging.getLogger("stackpilot.agent")
AGENT_VERSION = "0.9.0"
AGENT_VERSION = "0.10.0"
# --------------------------------------------------------------------------- #
+1 -1
View File
@@ -54,7 +54,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel()
app = FastAPI(title="StackPilot", version="0.9.0", lifespan=lifespan)
app = FastAPI(title="StackPilot", version="0.10.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
+8 -1
View File
@@ -84,7 +84,12 @@ def set_gpu(yaml_str: str, service: str, config: dict) -> str:
svc = _get_service(data, service)
mode = config.get("mode", "none")
gpu_service.remove_gpu(svc)
# Detect host iGPU group GIDs so we can both inject and clean them up.
try:
dri_gids = gpu_service.dri_group_gids()
except Exception: # noqa: BLE001 - detection is best-effort
dri_gids = set()
gpu_service.remove_gpu(svc, dri_gids)
if mode == "nvidia":
gpu_service.inject_nvidia(
@@ -100,6 +105,8 @@ def set_gpu(yaml_str: str, service: str, config: dict) -> str:
add_render_group=config.get("add_render_group", True),
add_video_group=config.get("add_video_group", False),
set_libva=config.get("set_libva", False),
render_gid=config.get("render_gid"),
video_gid=config.get("video_gid"),
)
return _dump(data)
+83 -8
View File
@@ -31,6 +31,10 @@ class GPUInfo:
device_path: Optional[str] = None # e.g. /dev/dri/renderD128
driver: str = ""
vram_mb: Optional[int] = None
# Host group GIDs that own the DRI nodes — needed so a container can access
# a passed-through iGPU (group_add). render = renderD* node, video = card* node.
render_gid: Optional[int] = None
video_gid: Optional[int] = None
def to_dict(self) -> dict:
return asdict(self)
@@ -87,6 +91,29 @@ def _read(path: str) -> str:
return ""
def _stat_gid(path: Optional[str]) -> Optional[int]:
"""Group owner (GID) of a device node — the host's GID via the /dev bind."""
if not path:
return None
try:
return os.stat(path).st_gid
except OSError:
return None
def _paired_card(render_base: str) -> Optional[str]:
"""Find the /dev/dri/cardN node that belongs to the same PCI device."""
try:
target = os.path.realpath(f"/sys/class/drm/{render_base}/device")
except OSError:
return None
for card in sorted(glob.glob("/dev/dri/card*")):
cb = os.path.basename(card)
if os.path.realpath(f"/sys/class/drm/{cb}/device") == target:
return card
return None
def _detect_dri() -> list[GPUInfo]:
"""Detect AMD/Intel render nodes via /dev/dri + sysfs."""
gpus: list[GPUInfo] = []
@@ -108,6 +135,7 @@ def _detect_dri() -> list[GPUInfo]:
}.get(vendor, "GPU")
if device_id:
name = f"{name} ({device_id})"
card = _paired_card(base)
gpus.append(
GPUInfo(
vendor=vendor, # type: ignore[arg-type]
@@ -115,6 +143,8 @@ def _detect_dri() -> list[GPUInfo]:
name=name,
device_path=node,
driver=vendor,
render_gid=_stat_gid(node),
video_gid=_stat_gid(card),
)
)
return gpus
@@ -124,6 +154,17 @@ def detect_gpus() -> list[GPUInfo]:
return _detect_nvidia() + _detect_dri()
def dri_group_gids() -> set[int]:
"""All render/video GIDs across detected DRI GPUs (for cleanup on remove)."""
gids: set[int] = set()
for g in _detect_dri():
if g.render_gid is not None:
gids.add(g.render_gid)
if g.video_gid is not None:
gids.add(g.video_gid)
return gids
# --------------------------------------------------------------------------- #
# Injection helpers — mutate a single service dict in place
# --------------------------------------------------------------------------- #
@@ -163,28 +204,45 @@ def inject_dri(
add_render_group: bool = True,
add_video_group: bool = False,
set_libva: bool = False,
render_gid: Optional[int] = None,
video_gid: Optional[int] = None,
) -> dict:
"""Pass through /dev/dri and (optionally) add render/video groups.
For Intel QSV, set LIBVA_DRIVER_NAME=iHD.
When the host group GIDs are known they are used as numeric ``group_add``
entries (e.g. ``"993"``) — group *names* rarely resolve inside images, so the
numeric GID is what actually grants access to a passed-through iGPU. Falls
back to the group name when the GID is unknown. For Intel QSV, set
LIBVA_DRIVER_NAME=iHD.
"""
devices = service.setdefault("devices", [])
if "/dev/dri:/dev/dri" not in devices:
devices.append("/dev/dri:/dev/dri")
groups = service.setdefault("group_add", [])
if add_render_group and "render" not in groups:
groups.append("render")
if add_video_group and "video" not in groups:
groups.append("video")
def _add(name: str, gid: Optional[int]) -> None:
value = str(gid) if gid is not None else name
existing = {str(g) for g in groups}
if value not in existing and name not in existing:
groups.append(value)
if add_render_group:
_add("render", render_gid)
if add_video_group:
_add("video", video_gid)
if set_libva and vendor == "intel":
_ensure_env(service, "LIBVA_DRIVER_NAME", "iHD")
return service
def remove_gpu(service: dict) -> dict:
"""Strip GPU-related config from a service."""
def remove_gpu(service: dict, dri_gids: Optional[set[int]] = None) -> dict:
"""Strip GPU-related config from a service.
``dri_gids`` are host render/video GIDs to also drop from ``group_add`` so
switching a service away from iGPU passthrough cleans up numeric groups too.
"""
deploy = service.get("deploy", {})
resources = deploy.get("resources", {})
reservations = resources.get("reservations", {})
@@ -208,14 +266,31 @@ def remove_gpu(service: dict) -> dict:
if not service["devices"]:
service.pop("devices", None)
if "group_add" in service:
strip = {"render", "video"} | {str(g) for g in (dri_gids or set())}
service["group_add"] = [
g for g in service["group_add"] if g not in ("render", "video")
g for g in service["group_add"] if str(g) not in strip
]
if not service["group_add"]:
service.pop("group_add", None)
_remove_env(service, "LIBVA_DRIVER_NAME")
return service
def _remove_env(service: dict, key: str) -> None:
env = service.get("environment")
if isinstance(env, dict):
env.pop(key, None)
if not env:
service.pop("environment", None)
elif isinstance(env, list):
prefix = f"{key}="
service["environment"] = [
e for e in env if not (isinstance(e, str) and (e == key or e.startswith(prefix)))
]
if not service["environment"]:
service.pop("environment", None)
def _ensure_env(service: dict, key: str, value: str) -> None:
env = service.get("environment")
if env is None: