From ec7e3e706fda4269d19fdb9f45d043d39d25e568 Mon Sep 17 00:00:00 2001 From: menzelj Date: Sun, 7 Jun 2026 22:51:18 +0000 Subject: [PATCH] =?UTF-8?q?Phase=2010:=20iGPU=20passthrough=20=E2=80=94=20?= =?UTF-8?q?render/video=20group=20GID=20detection=20(0.10.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 11 ++- backend/agent_app.py | 2 +- backend/main.py | 2 +- backend/services/compose_edit_service.py | 9 +- backend/services/gpu_service.py | 91 +++++++++++++++++++-- frontend/package.json | 2 +- frontend/src/components/gpu/GPUSelector.tsx | 23 +++++- frontend/src/types/index.ts | 2 + 8 files changed, 126 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 4039245..76bff98 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. > **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) + Phase 3 (Quality of > Life) + Phase 4 (Operations) + Phase 5 (Multi-host) + Phase 6 (Backup > destinations) + Phase 7 (Scheduled backups) + Phase 8 (Remote-stack backups) -> + Phase 9 (Networks) complete. +> + Phase 9 (Networks) + Phase 10 (iGPU passthrough) complete. ## What works today (Phase 1) @@ -122,6 +122,15 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. - **Stack delete**: local stacks can now be deleted from the UI (stack detail and the stack card), with a confirm dialog and an optional "keep files on disk". +### Phase 10 — iGPU passthrough + +- **Render/video group detection**: for a passed-through Intel/AMD iGPU, StackPilot + reads the host group ownership of the `/dev/dri` nodes (render node → `render` + GID, paired `card` node → `video` GID) and injects them as **numeric** + `group_add` entries (e.g. `group_add: ["991", "44"]`). Group names rarely + resolve inside images, so the numeric GID is what actually grants access. The + GPU selector shows the detected GIDs; removal cleans them (and `LIBVA_DRIVER_NAME`). + ## Deploying an agent on another host ```bash diff --git a/backend/agent_app.py b/backend/agent_app.py index 6c04087..29535b5 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -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" # --------------------------------------------------------------------------- # diff --git a/backend/main.py b/backend/main.py index 9de3fc2..9442901 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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, diff --git a/backend/services/compose_edit_service.py b/backend/services/compose_edit_service.py index 259c163..b4340da 100644 --- a/backend/services/compose_edit_service.py +++ b/backend/services/compose_edit_service.py @@ -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) diff --git a/backend/services/gpu_service.py b/backend/services/gpu_service.py index c91ad47..060fe3d 100644 --- a/backend/services/gpu_service.py +++ b/backend/services/gpu_service.py @@ -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: diff --git a/frontend/package.json b/frontend/package.json index c146a39..567ba91 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.9.0", + "version": "0.10.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/gpu/GPUSelector.tsx b/frontend/src/components/gpu/GPUSelector.tsx index 3e05969..3fcf24a 100644 --- a/frontend/src/components/gpu/GPUSelector.tsx +++ b/frontend/src/components/gpu/GPUSelector.tsx @@ -23,11 +23,15 @@ export function GPUSelector({ // dri const [vendor, setVendor] = useState<"intel" | "amd">("intel"); const [renderGroup, setRenderGroup] = useState(true); - const [videoGroup, setVideoGroup] = useState(false); + const [videoGroup, setVideoGroup] = useState(true); const [libva, setLibva] = useState(false); const nvidia = (gpus ?? []).filter((g) => g.vendor === "nvidia"); const dri = (gpus ?? []).filter((g) => g.vendor !== "nvidia"); + // GIDs come from the matching detected GPU (host /dev/dri ownership). + const driGpu = dri.find((g) => g.vendor === vendor) ?? dri[0]; + const renderGid = driGpu?.render_gid ?? null; + const videoGid = driGpu?.video_gid ?? null; const toggleCap = (c: string) => setCaps((prev) => (prev.includes(c) ? prev.filter((x) => x !== c) : [...prev, c])); @@ -46,6 +50,8 @@ export function GPUSelector({ add_render_group: renderGroup, add_video_group: videoGroup, set_libva: libva, + render_gid: renderGid, + video_gid: videoGid, }); }; @@ -134,11 +140,22 @@ export function GPUSelector({ {dri.map((g) => `${g.name} → ${g.device_path}`).join(", ")}

)} + {(renderGid != null || videoGid != null) ? ( +

+ Detected host GIDs — render: {renderGid ?? "?"}, video:{" "} + {videoGid ?? "?"}. These are added as numeric{" "} + group_add entries. +

+ ) : ( +

+ No iGPU detected — render/video groups will be added by name. +

+ )} {vendor === "intel" && (