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
@@ -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())
|
||||
Reference in New Issue
Block a user