Manage Docker secrets and configs per stack from a new Secrets tab on Stack/ RemoteStackDetail. Content is stored as files inside the stack dir (.secrets/<name>, .configs/<name>; dir 0700 / file 0600) and referenced from the compose file with relative `file:` paths, so the daemon reads them without any HOST_ROOT_PREFIX dependency. Content is write-only — the API only ever returns metadata (name, kind, size). - secret_service: write/delete/list (metadata only)/exists/rel_path/attach/detach; name validation rejects traversal/hidden/separators, content capped at 1 MiB. - compose_edit_service: add/remove secret and config (top-level defs pruned when no service still references them). - routers/secrets.py (admin-only, audit secret.*) + agent endpoints + multi-host proxy (audit agent.secret.*). - Frontend SecretsPanel (create/list/delete + per-row attach/detach to a service; config rows take a mount target), agentId-aware for remote stacks. Verified: name-sandbox + perms + metadata-only listing unit-tested; compose add/remove round-trips to clean YAML; py_compile + backend/agent/frontend image builds + route smoke-test (local/agent/proxy). Live exec check (/run/secrets/<name> on a deployed stack) and swarm path are hardware-verify debt (swarm dropped: A). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
298 lines
9.8 KiB
Python
298 lines
9.8 KiB
Python
"""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)
|
|
for key in ("volumes", "secrets", "configs"):
|
|
if key in data and not data[key]:
|
|
data.pop(key, 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")
|
|
|
|
# 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(
|
|
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),
|
|
render_gid=config.get("render_gid"),
|
|
video_gid=config.get("video_gid"),
|
|
)
|
|
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 set_resources(
|
|
yaml_str: str,
|
|
service: str,
|
|
*,
|
|
cpus: float | None = None,
|
|
memory: str | None = None,
|
|
cpus_reserve: float | None = None,
|
|
memory_reserve: str | None = None,
|
|
) -> str:
|
|
"""Set deploy.resources limits/reservations for a service.
|
|
|
|
Pass None / empty to clear an individual value.
|
|
"""
|
|
data = _load(yaml_str)
|
|
svc = _get_service(data, service)
|
|
deploy = svc.setdefault("deploy", {})
|
|
resources = deploy.setdefault("resources", {})
|
|
|
|
limits = resources.get("limits", {})
|
|
if cpus:
|
|
limits["cpus"] = str(cpus)
|
|
else:
|
|
limits.pop("cpus", None)
|
|
if memory:
|
|
limits["memory"] = memory
|
|
else:
|
|
limits.pop("memory", None)
|
|
if limits:
|
|
resources["limits"] = limits
|
|
else:
|
|
resources.pop("limits", None)
|
|
|
|
reservations = resources.get("reservations", {})
|
|
if cpus_reserve:
|
|
reservations["cpus"] = str(cpus_reserve)
|
|
else:
|
|
reservations.pop("cpus", None)
|
|
if memory_reserve:
|
|
reservations["memory"] = memory_reserve
|
|
else:
|
|
reservations.pop("memory", None)
|
|
# Don't drop reservations entirely — it may hold GPU devices.
|
|
if reservations:
|
|
resources["reservations"] = reservations
|
|
elif "reservations" in resources and not resources["reservations"]:
|
|
resources.pop("reservations", None)
|
|
|
|
if not resources:
|
|
deploy.pop("resources", None)
|
|
if not deploy:
|
|
svc.pop("deploy", None)
|
|
return _dump(data)
|
|
|
|
|
|
def list_services(yaml_str: str) -> list[str]:
|
|
data = _load(yaml_str)
|
|
return list(data["services"].keys())
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Secrets & configs (compose file-based)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def add_secret(yaml_str: str, service: str, name: str, file_path: str) -> str:
|
|
"""Define a top-level file-based secret and attach it to ``service``."""
|
|
data = _load(yaml_str)
|
|
svc = _get_service(data, service)
|
|
secrets = data.setdefault("secrets", {})
|
|
if not isinstance(secrets, dict):
|
|
raise EditError("`secrets` must be a mapping")
|
|
secrets[name] = {"file": file_path}
|
|
refs = svc.setdefault("secrets", [])
|
|
if not isinstance(refs, list):
|
|
raise EditError(f"Service '{service}' secrets must be a list")
|
|
if name not in refs:
|
|
refs.append(name)
|
|
return _dump(data)
|
|
|
|
|
|
def remove_secret(yaml_str: str, service: str, name: str) -> str:
|
|
"""Detach a secret from ``service``; drop the top-level def if now unused."""
|
|
data = _load(yaml_str)
|
|
svc = data["services"].get(service)
|
|
if isinstance(svc, dict) and isinstance(svc.get("secrets"), list):
|
|
svc["secrets"] = [s for s in svc["secrets"] if s != name]
|
|
if not svc["secrets"]:
|
|
svc.pop("secrets", None)
|
|
_prune_top_level(data, "secrets", name, _secret_still_used)
|
|
return _dump(data)
|
|
|
|
|
|
def add_config(yaml_str: str, service: str, name: str, file_path: str, target: str) -> str:
|
|
"""Define a top-level file-based config and mount it into ``service`` at target."""
|
|
data = _load(yaml_str)
|
|
svc = _get_service(data, service)
|
|
configs = data.setdefault("configs", {})
|
|
if not isinstance(configs, dict):
|
|
raise EditError("`configs` must be a mapping")
|
|
configs[name] = {"file": file_path}
|
|
refs = svc.setdefault("configs", [])
|
|
if not isinstance(refs, list):
|
|
raise EditError(f"Service '{service}' configs must be a list")
|
|
if not any(isinstance(e, dict) and e.get("source") == name for e in refs):
|
|
refs.append({"source": name, "target": target})
|
|
return _dump(data)
|
|
|
|
|
|
def remove_config(yaml_str: str, service: str, name: str) -> str:
|
|
data = _load(yaml_str)
|
|
svc = data["services"].get(service)
|
|
if isinstance(svc, dict) and isinstance(svc.get("configs"), list):
|
|
svc["configs"] = [
|
|
e for e in svc["configs"]
|
|
if not (e == name or (isinstance(e, dict) and e.get("source") == name))
|
|
]
|
|
if not svc["configs"]:
|
|
svc.pop("configs", None)
|
|
_prune_top_level(data, "configs", name, _config_still_used)
|
|
return _dump(data)
|
|
|
|
|
|
def _secret_still_used(data: dict, name: str) -> bool:
|
|
for svc in data.get("services", {}).values():
|
|
if isinstance(svc, dict) and name in (svc.get("secrets") or []):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _config_still_used(data: dict, name: str) -> bool:
|
|
for svc in data.get("services", {}).values():
|
|
if not isinstance(svc, dict):
|
|
continue
|
|
for entry in svc.get("configs") or []:
|
|
if entry == name or (isinstance(entry, dict) and entry.get("source") == name):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _prune_top_level(data: dict, key: str, name: str, still_used) -> None:
|
|
top = data.get(key)
|
|
if isinstance(top, dict) and name in top and not still_used(data, name):
|
|
top.pop(name, None)
|