Phase 2: Volume Wizard, GPU & device passthrough (0.2.0)
Backend: - gpu_service: detect NVIDIA (nvidia-smi) + AMD/Intel (/dev/dri, sysfs); inject helpers (nvidia deploy.reservations, /dev/dri + groups + LIBVA) - volume_service: list/orphaned/prune volumes; NFS/SMB/named/bind/tmpfs YAML generation (generate-yaml) - device_service: USB/TTY/DRI detection + sandboxed host path browser - compose_edit_service: server-side merge of volume/gpu/device fragments - routers: volumes (+host paths), editor (services/add-volume/set-gpu/ add-device/remove-device/set-privileged), system gpus+devices - compose: bind-mount /dev:ro for detection Frontend: - split-pane StackEditor with helper panel (service picker + tabs) - VolumeWizard (bind/named/nfs/smb/tmpfs) + HostPathBrowser - GPUSelector, DevicePanel; api clients for volumes/editor/system Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7775128c07
commit
b553c1b861
+4
-2
@@ -12,7 +12,7 @@ from sqlmodel import Session
|
||||
from config import settings
|
||||
from database import engine, init_db
|
||||
from docker_client import DockerError
|
||||
from routers import audit, auth, stacks, system, ws
|
||||
from routers import audit, auth, editor, stacks, system, volumes, ws
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("stackpilot")
|
||||
@@ -31,7 +31,7 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="StackPilot", version="0.1.1", lifespan=lifespan)
|
||||
app = FastAPI(title="StackPilot", version="0.2.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -53,6 +53,8 @@ async def docker_error_handler(_request: Request, exc: DockerError):
|
||||
app.include_router(auth.router)
|
||||
app.include_router(stacks.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(volumes.router)
|
||||
app.include_router(editor.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(ws.router)
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Editor helper endpoints — merge wizard fragments into compose YAML."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth import get_current_user
|
||||
from models.user import User
|
||||
from services import compose_edit_service as edit
|
||||
|
||||
router = APIRouter(prefix="/api/editor", tags=["editor"])
|
||||
|
||||
|
||||
class AddVolumeBody(BaseModel):
|
||||
yaml: str
|
||||
service: str
|
||||
spec: dict
|
||||
|
||||
|
||||
class SetGpuBody(BaseModel):
|
||||
yaml: str
|
||||
service: str
|
||||
config: dict
|
||||
|
||||
|
||||
class DeviceBody(BaseModel):
|
||||
yaml: str
|
||||
service: str
|
||||
host_path: str
|
||||
target: str | None = None
|
||||
|
||||
|
||||
class PrivilegedBody(BaseModel):
|
||||
yaml: str
|
||||
service: str
|
||||
value: bool
|
||||
|
||||
|
||||
def _run(fn, *args) -> dict:
|
||||
try:
|
||||
return {"yaml": fn(*args)}
|
||||
except edit.EditError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/services")
|
||||
def services(body: dict, _user: User = Depends(get_current_user)) -> dict:
|
||||
try:
|
||||
return {"services": edit.list_services(body.get("yaml", ""))}
|
||||
except edit.EditError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/add-volume")
|
||||
def add_volume(body: AddVolumeBody, _user: User = Depends(get_current_user)) -> dict:
|
||||
return _run(edit.add_volume, body.yaml, body.service, body.spec)
|
||||
|
||||
|
||||
@router.post("/set-gpu")
|
||||
def set_gpu(body: SetGpuBody, _user: User = Depends(get_current_user)) -> dict:
|
||||
return _run(edit.set_gpu, body.yaml, body.service, body.config)
|
||||
|
||||
|
||||
@router.post("/add-device")
|
||||
def add_device(body: DeviceBody, _user: User = Depends(get_current_user)) -> dict:
|
||||
return _run(edit.add_device, body.yaml, body.service, body.host_path, body.target)
|
||||
|
||||
|
||||
@router.post("/remove-device")
|
||||
def remove_device(body: DeviceBody, _user: User = Depends(get_current_user)) -> dict:
|
||||
return _run(edit.remove_device, body.yaml, body.service, body.host_path)
|
||||
|
||||
|
||||
@router.post("/set-privileged")
|
||||
def set_privileged(body: PrivilegedBody, _user: User = Depends(get_current_user)) -> dict:
|
||||
return _run(edit.set_privileged, body.yaml, body.service, body.value)
|
||||
@@ -10,6 +10,7 @@ from auth import get_current_user
|
||||
from config import settings
|
||||
from docker_client import DockerError, get_client, safe_call
|
||||
from models.user import User
|
||||
from services import device_service, gpu_service
|
||||
|
||||
router = APIRouter(prefix="/api/system", tags=["system"])
|
||||
|
||||
@@ -88,5 +89,17 @@ def system_info(_user: User = Depends(get_current_user)) -> dict:
|
||||
"uptime_seconds": _uptime(),
|
||||
"containers_running": containers_running,
|
||||
"containers_total": containers_total,
|
||||
"gpus": [], # populated in Phase 2
|
||||
"gpus": [g.to_dict() for g in gpu_service.detect_gpus()],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/gpus")
|
||||
def gpus(_user: User = Depends(get_current_user)) -> list[dict]:
|
||||
"""Re-detect GPUs live."""
|
||||
return [g.to_dict() for g in gpu_service.detect_gpus()]
|
||||
|
||||
|
||||
@router.get("/devices")
|
||||
def devices(_user: User = Depends(get_current_user)) -> dict:
|
||||
"""List host USB / serial / DRI devices for passthrough."""
|
||||
return device_service.detect_devices()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Volume management, NFS/SMB YAML generation, and host path browser."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlmodel import Session
|
||||
|
||||
from auth import get_current_user, require_admin
|
||||
from database import get_session
|
||||
from models.user import User
|
||||
from services import audit_service, device_service, volume_service
|
||||
|
||||
router = APIRouter(tags=["volumes"])
|
||||
|
||||
|
||||
def _ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Volumes
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@router.get("/api/volumes")
|
||||
def list_volumes(_user: User = Depends(get_current_user)) -> list[dict]:
|
||||
return volume_service.list_volumes()
|
||||
|
||||
|
||||
@router.get("/api/volumes/orphaned")
|
||||
def orphaned(_user: User = Depends(get_current_user)) -> list[dict]:
|
||||
return volume_service.orphaned_volumes()
|
||||
|
||||
|
||||
@router.delete("/api/volumes/{name}")
|
||||
def delete_volume(
|
||||
name: str,
|
||||
request: Request,
|
||||
force: bool = Query(False),
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
vols = {v["name"]: v for v in volume_service.list_volumes()}
|
||||
if name in vols and vols[name]["in_use"] and not force:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"error": "volume_in_use",
|
||||
"detail": f"Volume '{name}' is used by: {', '.join(vols[name]['used_by'])}",
|
||||
},
|
||||
)
|
||||
volume_service.remove_volume(name, force=force)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="volume.delete", target=name, ip=_ip(request)
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/volumes/prune")
|
||||
def prune(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
result = volume_service.prune_volumes()
|
||||
audit_service.record(
|
||||
session, user=user.username, action="volume.prune", target="*",
|
||||
detail=str(result.get("VolumesDeleted")), ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/volumes/generate-yaml")
|
||||
def generate_yaml(
|
||||
spec: dict,
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
try:
|
||||
return {"yaml": volume_service.generate_yaml(spec)}
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid volume spec: {exc}") from exc
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Host path browser
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@router.get("/api/host/paths")
|
||||
def host_paths(
|
||||
path: str = Query("/"),
|
||||
show_hidden: bool = Query(False),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
try:
|
||||
return device_service.browse(path, show_hidden)
|
||||
except device_service.BrowseError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Server-side merging of wizard fragments into a compose YAML string.
|
||||
|
||||
PyYAML round-trips lose comments; that is acceptable here because these helpers
|
||||
are invoked from the editor where the returned YAML replaces the buffer and the
|
||||
user reviews before saving.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
|
||||
from services import gpu_service, volume_service
|
||||
|
||||
|
||||
class EditError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _load(yaml_str: str) -> dict:
|
||||
try:
|
||||
data = yaml.safe_load(yaml_str) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise EditError(f"Invalid YAML: {exc}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise EditError("Top-level compose document must be a mapping")
|
||||
data.setdefault("services", {})
|
||||
if not isinstance(data["services"], dict):
|
||||
raise EditError("`services` must be a mapping")
|
||||
return data
|
||||
|
||||
|
||||
def _dump(data: dict) -> str:
|
||||
# Drop empty top-level keys we may have created.
|
||||
if not data.get("services"):
|
||||
data.pop("services", None)
|
||||
if "volumes" in data and not data["volumes"]:
|
||||
data.pop("volumes", None)
|
||||
return yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
|
||||
|
||||
|
||||
def _get_service(data: dict, service: str) -> dict:
|
||||
svc = data["services"].get(service)
|
||||
if svc is None:
|
||||
svc = {}
|
||||
data["services"][service] = svc
|
||||
if not isinstance(svc, dict):
|
||||
raise EditError(f"Service '{service}' is not a mapping")
|
||||
return svc
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Volumes
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def add_volume(yaml_str: str, service: str, spec: dict) -> str:
|
||||
data = _load(yaml_str)
|
||||
svc = _get_service(data, service)
|
||||
|
||||
# Top-level volume declaration (named/nfs/smb).
|
||||
definition = volume_service.build_volume_definition(spec)
|
||||
if definition:
|
||||
top = data.setdefault("volumes", {})
|
||||
if not isinstance(top, dict):
|
||||
raise EditError("`volumes` must be a mapping")
|
||||
top.update(definition)
|
||||
|
||||
mount = volume_service.build_service_mount(spec)
|
||||
vols = svc.setdefault("volumes", [])
|
||||
if not isinstance(vols, list):
|
||||
raise EditError(f"Service '{service}' volumes must be a list")
|
||||
if mount not in vols:
|
||||
vols.append(mount)
|
||||
return _dump(data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# GPU
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def set_gpu(yaml_str: str, service: str, config: dict) -> str:
|
||||
"""config = { mode: 'none'|'nvidia'|'dri', ... }"""
|
||||
data = _load(yaml_str)
|
||||
svc = _get_service(data, service)
|
||||
mode = config.get("mode", "none")
|
||||
|
||||
gpu_service.remove_gpu(svc)
|
||||
|
||||
if mode == "nvidia":
|
||||
gpu_service.inject_nvidia(
|
||||
svc,
|
||||
device_ids=config.get("device_ids") or None,
|
||||
count=config.get("count"),
|
||||
capabilities=config.get("capabilities") or ["gpu"],
|
||||
)
|
||||
elif mode == "dri":
|
||||
gpu_service.inject_dri(
|
||||
svc,
|
||||
vendor=config.get("vendor", "intel"),
|
||||
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),
|
||||
)
|
||||
return _dump(data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Devices / privileged
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def add_device(yaml_str: str, service: str, host_path: str, target: str | None = None) -> str:
|
||||
data = _load(yaml_str)
|
||||
svc = _get_service(data, service)
|
||||
entry = f"{host_path}:{target or host_path}"
|
||||
devices = svc.setdefault("devices", [])
|
||||
if not isinstance(devices, list):
|
||||
raise EditError(f"Service '{service}' devices must be a list")
|
||||
if entry not in devices:
|
||||
devices.append(entry)
|
||||
return _dump(data)
|
||||
|
||||
|
||||
def remove_device(yaml_str: str, service: str, host_path: str) -> str:
|
||||
data = _load(yaml_str)
|
||||
svc = _get_service(data, service)
|
||||
devices = svc.get("devices", [])
|
||||
if isinstance(devices, list):
|
||||
svc["devices"] = [
|
||||
d for d in devices if not str(d).startswith(host_path + ":") and d != host_path
|
||||
]
|
||||
if not svc["devices"]:
|
||||
svc.pop("devices", None)
|
||||
return _dump(data)
|
||||
|
||||
|
||||
def set_privileged(yaml_str: str, service: str, value: bool) -> str:
|
||||
data = _load(yaml_str)
|
||||
svc = _get_service(data, service)
|
||||
if value:
|
||||
svc["privileged"] = True
|
||||
else:
|
||||
svc.pop("privileged", None)
|
||||
return _dump(data)
|
||||
|
||||
|
||||
def list_services(yaml_str: str) -> list[str]:
|
||||
data = _load(yaml_str)
|
||||
return list(data["services"].keys())
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Host device detection (USB, serial/TTY, DRI) and host filesystem browser."""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Optional
|
||||
|
||||
from config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class HostDevice:
|
||||
path: str
|
||||
kind: str # "usb" | "tty" | "dri" | "other"
|
||||
name: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _read(path: str) -> str:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
return fh.read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _usb_devices() -> list[HostDevice]:
|
||||
devices: list[HostDevice] = []
|
||||
# /dev/bus/usb/<bus>/<dev>
|
||||
for path in sorted(glob.glob("/dev/bus/usb/*/*")):
|
||||
name = ""
|
||||
# Best-effort name lookup via sysfs is non-trivial to map; leave generic.
|
||||
devices.append(HostDevice(path=path, kind="usb", name=name or "USB device"))
|
||||
return devices
|
||||
|
||||
|
||||
def _usb_names_from_sysfs() -> list[HostDevice]:
|
||||
"""Richer USB list from sysfs with product/manufacturer strings."""
|
||||
out: list[HostDevice] = []
|
||||
for dev in sorted(glob.glob("/sys/bus/usb/devices/*")):
|
||||
busnum = _read(os.path.join(dev, "busnum"))
|
||||
devnum = _read(os.path.join(dev, "devnum"))
|
||||
if not busnum or not devnum:
|
||||
continue
|
||||
product = _read(os.path.join(dev, "product"))
|
||||
manufacturer = _read(os.path.join(dev, "manufacturer"))
|
||||
label = " ".join(p for p in (manufacturer, product) if p) or "USB device"
|
||||
path = f"/dev/bus/usb/{int(busnum):03d}/{int(devnum):03d}"
|
||||
out.append(HostDevice(path=path, kind="usb", name=label))
|
||||
return out
|
||||
|
||||
|
||||
def _tty_devices() -> list[HostDevice]:
|
||||
devices: list[HostDevice] = []
|
||||
patterns = ["/dev/ttyUSB*", "/dev/ttyACM*", "/dev/ttyAMA*", "/dev/serial/by-id/*"]
|
||||
for pattern in patterns:
|
||||
for path in sorted(glob.glob(pattern)):
|
||||
base = os.path.basename(path)
|
||||
driver = _read(f"/sys/class/tty/{base}/device/driver/module/name") or ""
|
||||
devices.append(
|
||||
HostDevice(path=path, kind="tty", name=driver or "Serial device")
|
||||
)
|
||||
return devices
|
||||
|
||||
|
||||
def _dri_devices() -> list[HostDevice]:
|
||||
return [
|
||||
HostDevice(path=p, kind="dri", name="GPU render node")
|
||||
for p in sorted(glob.glob("/dev/dri/*"))
|
||||
]
|
||||
|
||||
|
||||
def detect_devices() -> dict:
|
||||
usb = _usb_names_from_sysfs() or _usb_devices()
|
||||
return {
|
||||
"usb": [d.to_dict() for d in usb],
|
||||
"tty": [d.to_dict() for d in _tty_devices()],
|
||||
"dri": [d.to_dict() for d in _dri_devices()],
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Host filesystem browser (sandboxed)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _real_root(path: str) -> str:
|
||||
"""Map a logical host path into the container view (HOST_ROOT_PREFIX)."""
|
||||
prefix = settings.HOST_ROOT_PREFIX.rstrip("/")
|
||||
if prefix:
|
||||
return prefix + path
|
||||
return path
|
||||
|
||||
|
||||
def _is_allowed(path: str) -> bool:
|
||||
norm = os.path.normpath(path)
|
||||
for root in settings.ALLOWED_BROWSE_ROOTS:
|
||||
root = os.path.normpath(root)
|
||||
if root == "/" or norm == root or norm.startswith(root + os.sep):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class BrowseError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def browse(path: str = "/", show_hidden: bool = False) -> dict:
|
||||
path = os.path.normpath(path or "/")
|
||||
if not path.startswith("/"):
|
||||
raise BrowseError("Path must be absolute")
|
||||
if not _is_allowed(path):
|
||||
raise BrowseError("Path is outside the allowed browse roots")
|
||||
|
||||
real = _real_root(path)
|
||||
if not os.path.isdir(real):
|
||||
raise BrowseError(f"Not a directory: {path}")
|
||||
|
||||
entries = []
|
||||
try:
|
||||
names = os.listdir(real)
|
||||
except PermissionError as exc:
|
||||
raise BrowseError(f"Permission denied: {path}") from exc
|
||||
|
||||
for name in sorted(names):
|
||||
if not show_hidden and name.startswith("."):
|
||||
continue
|
||||
full_real = os.path.join(real, name)
|
||||
try:
|
||||
st = os.lstat(full_real)
|
||||
is_dir = os.path.isdir(full_real)
|
||||
entries.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": "dir" if is_dir else "file",
|
||||
"size": st.st_size if not is_dir else None,
|
||||
"permissions": oct(st.st_mode & 0o777),
|
||||
}
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Sort: dirs first, then files, both alphabetical.
|
||||
entries.sort(key=lambda e: (e["type"] != "dir", e["name"].lower()))
|
||||
parent = os.path.dirname(path) if path != "/" else None
|
||||
return {
|
||||
"path": path,
|
||||
"parent": parent,
|
||||
"roots": settings.ALLOWED_BROWSE_ROOTS,
|
||||
"entries": entries,
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"""GPU detection and Compose YAML injection helpers.
|
||||
|
||||
Detection sources:
|
||||
* NVIDIA — `nvidia-smi` query (when the toolkit is present)
|
||||
* AMD/Intel — scan /dev/dri/renderD* + /sys/class/drm/*/device/vendor
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Literal, Optional
|
||||
|
||||
Vendor = Literal["nvidia", "amd", "intel"]
|
||||
|
||||
# PCI vendor IDs found in /sys/class/drm/*/device/vendor
|
||||
_PCI_VENDORS = {
|
||||
"0x10de": "nvidia",
|
||||
"0x1002": "amd",
|
||||
"0x8086": "intel",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GPUInfo:
|
||||
vendor: Vendor
|
||||
index: int
|
||||
name: str
|
||||
uuid: Optional[str] = None
|
||||
device_path: Optional[str] = None # e.g. /dev/dri/renderD128
|
||||
driver: str = ""
|
||||
vram_mb: Optional[int] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _detect_nvidia() -> list[GPUInfo]:
|
||||
gpus: list[GPUInfo] = []
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[
|
||||
"nvidia-smi",
|
||||
"--query-gpu=index,name,uuid,memory.total,driver_version",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=8,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.SubprocessError):
|
||||
return gpus
|
||||
if out.returncode != 0:
|
||||
return gpus
|
||||
for line in out.stdout.strip().splitlines():
|
||||
parts = [p.strip() for p in line.split(",")]
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
idx, name, uuid, mem, driver = parts[:5]
|
||||
try:
|
||||
index = int(idx)
|
||||
except ValueError:
|
||||
index = 0
|
||||
try:
|
||||
vram = int(float(mem))
|
||||
except ValueError:
|
||||
vram = None
|
||||
gpus.append(
|
||||
GPUInfo(
|
||||
vendor="nvidia",
|
||||
index=index,
|
||||
name=name,
|
||||
uuid=uuid,
|
||||
driver=driver,
|
||||
vram_mb=vram,
|
||||
)
|
||||
)
|
||||
return gpus
|
||||
|
||||
|
||||
def _read(path: str) -> str:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return fh.read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _detect_dri() -> list[GPUInfo]:
|
||||
"""Detect AMD/Intel render nodes via /dev/dri + sysfs."""
|
||||
gpus: list[GPUInfo] = []
|
||||
render_nodes = sorted(glob.glob("/dev/dri/renderD*"))
|
||||
for i, node in enumerate(render_nodes):
|
||||
base = os.path.basename(node) # renderD128
|
||||
vendor_id = _read(f"/sys/class/drm/{base}/device/vendor").lower()
|
||||
vendor = _PCI_VENDORS.get(vendor_id)
|
||||
if vendor == "nvidia":
|
||||
# NVIDIA is reported via nvidia-smi; skip its DRI node here.
|
||||
continue
|
||||
if not vendor:
|
||||
continue
|
||||
# Try to read a human-ish name.
|
||||
device_id = _read(f"/sys/class/drm/{base}/device/device")
|
||||
name = {
|
||||
"amd": "AMD GPU",
|
||||
"intel": "Intel iGPU",
|
||||
}.get(vendor, "GPU")
|
||||
if device_id:
|
||||
name = f"{name} ({device_id})"
|
||||
gpus.append(
|
||||
GPUInfo(
|
||||
vendor=vendor, # type: ignore[arg-type]
|
||||
index=i,
|
||||
name=name,
|
||||
device_path=node,
|
||||
driver=vendor,
|
||||
)
|
||||
)
|
||||
return gpus
|
||||
|
||||
|
||||
def detect_gpus() -> list[GPUInfo]:
|
||||
return _detect_nvidia() + _detect_dri()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Injection helpers — mutate a single service dict in place
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def inject_nvidia(
|
||||
service: dict,
|
||||
*,
|
||||
device_ids: Optional[list[str]] = None,
|
||||
count: Optional[int] = None,
|
||||
capabilities: Optional[list[str]] = None,
|
||||
) -> dict:
|
||||
"""Add an NVIDIA device reservation under deploy.resources."""
|
||||
caps = capabilities or ["gpu"]
|
||||
reservation: dict = {"driver": "nvidia", "capabilities": caps}
|
||||
if device_ids:
|
||||
reservation["device_ids"] = device_ids
|
||||
elif count is not None:
|
||||
reservation["count"] = count
|
||||
else:
|
||||
reservation["count"] = "all"
|
||||
|
||||
deploy = service.setdefault("deploy", {})
|
||||
resources = deploy.setdefault("resources", {})
|
||||
reservations = resources.setdefault("reservations", {})
|
||||
devices = reservations.setdefault("devices", [])
|
||||
# Replace any existing nvidia reservation.
|
||||
devices[:] = [d for d in devices if d.get("driver") != "nvidia"]
|
||||
devices.append(reservation)
|
||||
return service
|
||||
|
||||
|
||||
def inject_dri(
|
||||
service: dict,
|
||||
*,
|
||||
vendor: Vendor,
|
||||
add_render_group: bool = True,
|
||||
add_video_group: bool = False,
|
||||
set_libva: bool = False,
|
||||
) -> dict:
|
||||
"""Pass through /dev/dri and (optionally) add render/video groups.
|
||||
|
||||
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")
|
||||
|
||||
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."""
|
||||
deploy = service.get("deploy", {})
|
||||
resources = deploy.get("resources", {})
|
||||
reservations = resources.get("reservations", {})
|
||||
if "devices" in reservations:
|
||||
reservations["devices"] = [
|
||||
d for d in reservations["devices"] if d.get("driver") != "nvidia"
|
||||
]
|
||||
if not reservations["devices"]:
|
||||
reservations.pop("devices", None)
|
||||
if not reservations:
|
||||
resources.pop("reservations", None)
|
||||
if not resources:
|
||||
deploy.pop("resources", None)
|
||||
if not deploy:
|
||||
service.pop("deploy", None)
|
||||
# DRI passthrough
|
||||
if "devices" in service:
|
||||
service["devices"] = [
|
||||
d for d in service["devices"] if d != "/dev/dri:/dev/dri"
|
||||
]
|
||||
if not service["devices"]:
|
||||
service.pop("devices", None)
|
||||
if "group_add" in service:
|
||||
service["group_add"] = [
|
||||
g for g in service["group_add"] if g not in ("render", "video")
|
||||
]
|
||||
if not service["group_add"]:
|
||||
service.pop("group_add", None)
|
||||
return service
|
||||
|
||||
|
||||
def _ensure_env(service: dict, key: str, value: str) -> None:
|
||||
env = service.get("environment")
|
||||
if env is None:
|
||||
service["environment"] = [f"{key}={value}"]
|
||||
return
|
||||
if isinstance(env, dict):
|
||||
env[key] = value
|
||||
return
|
||||
# list form
|
||||
prefix = f"{key}="
|
||||
env = [e for e in env if not (isinstance(e, str) and e.startswith(prefix))]
|
||||
env.append(f"{key}={value}")
|
||||
service["environment"] = env
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Docker volume management + Compose volume YAML generation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from docker_client import get_client, safe_call
|
||||
|
||||
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Listing / pruning
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def list_volumes() -> list[dict]:
|
||||
client = get_client()
|
||||
volumes = safe_call(client.volumes.list)
|
||||
# Map volume name -> set of containers using it.
|
||||
containers = safe_call(client.containers.list, all=True)
|
||||
usage: dict[str, list[str]] = {}
|
||||
for c in containers:
|
||||
for mount in c.attrs.get("Mounts", []) or []:
|
||||
if mount.get("Type") == "volume" and mount.get("Name"):
|
||||
usage.setdefault(mount["Name"], []).append(c.name)
|
||||
|
||||
result = []
|
||||
for v in volumes:
|
||||
attrs = v.attrs
|
||||
name = v.name
|
||||
result.append(
|
||||
{
|
||||
"name": name,
|
||||
"driver": attrs.get("Driver"),
|
||||
"mountpoint": attrs.get("Mountpoint"),
|
||||
"created_at": attrs.get("CreatedAt"),
|
||||
"labels": attrs.get("Labels") or {},
|
||||
"scope": attrs.get("Scope"),
|
||||
"stack": (attrs.get("Labels") or {}).get(COMPOSE_PROJECT_LABEL),
|
||||
"used_by": usage.get(name, []),
|
||||
"in_use": bool(usage.get(name)),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def orphaned_volumes() -> list[dict]:
|
||||
return [v for v in list_volumes() if not v["in_use"]]
|
||||
|
||||
|
||||
def remove_volume(name: str, force: bool = False) -> None:
|
||||
client = get_client()
|
||||
volume = safe_call(client.volumes.get, name)
|
||||
safe_call(volume.remove, force=force)
|
||||
|
||||
|
||||
def prune_volumes() -> dict:
|
||||
client = get_client()
|
||||
return safe_call(client.volumes.prune)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# YAML generation
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
VolumeType = Literal["bind", "named", "nfs", "smb", "tmpfs"]
|
||||
|
||||
|
||||
def _nfs_options(server: str, opts: dict) -> str:
|
||||
"""Build the `o:` option string for an NFS driver_opts block."""
|
||||
parts = [f"addr={server}"]
|
||||
# rw / ro
|
||||
if opts.get("rw", True):
|
||||
parts.append("rw")
|
||||
else:
|
||||
parts.append("ro")
|
||||
for flag in ("soft", "hard", "nolock", "noatime", "noacl", "nocto", "bg"):
|
||||
if opts.get(flag):
|
||||
parts.append(flag)
|
||||
version = opts.get("nfsvers") or opts.get("version")
|
||||
if version:
|
||||
parts.append(f"nfsvers={version}")
|
||||
if opts.get("timeo") is not None:
|
||||
parts.append(f"timeo={opts['timeo']}")
|
||||
if opts.get("retrans") is not None:
|
||||
parts.append(f"retrans={opts['retrans']}")
|
||||
extra = (opts.get("extra") or "").strip()
|
||||
if extra:
|
||||
parts.append(extra.lstrip(","))
|
||||
return ",".join(parts)
|
||||
|
||||
|
||||
def _smb_options(opts: dict) -> str:
|
||||
parts = []
|
||||
if opts.get("username"):
|
||||
parts.append(f"username={opts['username']}")
|
||||
if opts.get("password") is not None:
|
||||
parts.append(f"password={opts['password']}")
|
||||
if opts.get("uid") is not None:
|
||||
parts.append(f"uid={opts['uid']}")
|
||||
if opts.get("gid") is not None:
|
||||
parts.append(f"gid={opts['gid']}")
|
||||
version = opts.get("vers")
|
||||
if version:
|
||||
parts.append(f"vers={version}")
|
||||
if opts.get("noperm"):
|
||||
parts.append("noperm")
|
||||
if opts.get("file_mode"):
|
||||
parts.append(f"file_mode={opts['file_mode']}")
|
||||
if opts.get("dir_mode"):
|
||||
parts.append(f"dir_mode={opts['dir_mode']}")
|
||||
extra = (opts.get("extra") or "").strip()
|
||||
if extra:
|
||||
parts.append(extra.lstrip(","))
|
||||
return ",".join(parts)
|
||||
|
||||
|
||||
def build_volume_definition(spec: dict) -> dict:
|
||||
"""Return the top-level `volumes:` declaration for a named/nfs/smb volume.
|
||||
|
||||
Returns {} for bind/tmpfs (those live entirely on the service).
|
||||
"""
|
||||
vtype: VolumeType = spec["type"]
|
||||
name = spec.get("volume_name") or spec.get("name")
|
||||
|
||||
if vtype == "named":
|
||||
decl: dict = {"driver": spec.get("driver", "local")}
|
||||
if spec.get("driver_opts"):
|
||||
decl["driver_opts"] = spec["driver_opts"]
|
||||
if not decl.get("driver_opts") and decl["driver"] == "local":
|
||||
# A plain named volume can be declared as null.
|
||||
return {name: None}
|
||||
return {name: decl}
|
||||
|
||||
if vtype == "nfs":
|
||||
o = _nfs_options(spec["nfs_server"], spec.get("options", {}))
|
||||
device = spec["nfs_path"]
|
||||
if not device.startswith(":"):
|
||||
device = ":" + device
|
||||
return {
|
||||
name: {
|
||||
"driver": "local",
|
||||
"driver_opts": {"type": "nfs", "o": o, "device": device},
|
||||
}
|
||||
}
|
||||
|
||||
if vtype == "smb":
|
||||
o = _smb_options(spec.get("options", {}))
|
||||
device = spec["smb_share"] # //server/share
|
||||
return {
|
||||
name: {
|
||||
"driver": "local",
|
||||
"driver_opts": {"type": "cifs", "o": o, "device": device},
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def build_service_mount(spec: dict) -> str | dict:
|
||||
"""Return the entry to add to a service's `volumes:` list."""
|
||||
vtype: VolumeType = spec["type"]
|
||||
if vtype == "bind":
|
||||
mode = "ro" if not spec.get("options", {}).get("rw", True) else "rw"
|
||||
return f"{spec['host_path']}:{spec['container_path']}:{mode}"
|
||||
if vtype == "tmpfs":
|
||||
return {
|
||||
"type": "tmpfs",
|
||||
"target": spec["container_path"],
|
||||
"tmpfs": {
|
||||
k: v
|
||||
for k, v in {
|
||||
"size": spec.get("options", {}).get("size"),
|
||||
"mode": spec.get("options", {}).get("mode"),
|
||||
}.items()
|
||||
if v is not None
|
||||
},
|
||||
}
|
||||
# named / nfs / smb
|
||||
name = spec.get("volume_name") or spec.get("name")
|
||||
return f"{name}:{spec['container_path']}"
|
||||
|
||||
|
||||
def generate_yaml(spec: dict) -> str:
|
||||
"""Produce a ready-to-insert YAML fragment for the given volume spec.
|
||||
|
||||
Includes the top-level `volumes:` block (if any) and a `services:` example
|
||||
showing the mount, mirroring the wizard preview.
|
||||
"""
|
||||
doc: dict = {}
|
||||
definition = build_volume_definition(spec)
|
||||
if definition:
|
||||
doc["volumes"] = definition
|
||||
service_name = spec.get("service", "service")
|
||||
doc["services"] = {service_name: {"volumes": [build_service_mount(spec)]}}
|
||||
return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False)
|
||||
Reference in New Issue
Block a user