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:
co-authored by
Claude Opus 4.8
parent
a4e26f880a
commit
ec7e3e706f
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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(", ")}
|
||||
</p>
|
||||
)}
|
||||
{(renderGid != null || videoGid != null) ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
Detected host GIDs — render: <span className="font-mono">{renderGid ?? "?"}</span>, video:{" "}
|
||||
<span className="font-mono">{videoGid ?? "?"}</span>. These are added as numeric{" "}
|
||||
<code>group_add</code> entries.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">
|
||||
No iGPU detected — render/video groups will be added by name.
|
||||
</p>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={renderGroup} onChange={(e) => setRenderGroup(e.target.checked)} /> add render group
|
||||
<input type="checkbox" checked={renderGroup} onChange={(e) => setRenderGroup(e.target.checked)} /> add render group{renderGid != null ? ` (${renderGid})` : ""}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={videoGroup} onChange={(e) => setVideoGroup(e.target.checked)} /> add video group
|
||||
<input type="checkbox" checked={videoGroup} onChange={(e) => setVideoGroup(e.target.checked)} /> add video group{videoGid != null ? ` (${videoGid})` : ""}
|
||||
</label>
|
||||
{vendor === "intel" && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
|
||||
@@ -93,6 +93,8 @@ export interface GPUInfo {
|
||||
device_path?: string | null;
|
||||
driver: string;
|
||||
vram_mb?: number | null;
|
||||
render_gid?: number | null;
|
||||
video_gid?: number | null;
|
||||
}
|
||||
|
||||
export interface HostDevice {
|
||||
|
||||
Reference in New Issue
Block a user