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:
menzelj
2026-06-07 16:35:22 +00:00
co-authored by Claude Opus 4.8
parent 7775128c07
commit b553c1b861
20 changed files with 1841 additions and 26 deletions
+4 -2
View File
@@ -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)
+76
View File
@@ -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)
+14 -1
View File
@@ -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()
+97
View File
@@ -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
+149
View File
@@ -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())
+154
View File
@@ -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,
}
+231
View File
@@ -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
+198
View File
@@ -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)