feat: v0.7.0 — inspect_container (full-path mount source)
New tool inspect_container surfaces the full configuration of a single container as the foundation for a future GUI-container → Compose migration workflow. Output covers image, status, restart policy, network mode + per-network IPs, port bindings, volume mounts, env vars, labels, entrypoint/command, links, and capabilities. Mount paths come from details.Mounts[].Source (full /volume1/... path), NOT from profile.volume_bindings[].host_volume_file — the latter is share-relative (e.g. /docker/foo for /volume1/docker/foo) and not directly Compose-usable. Verified live against the NAS; quirk documented in CLAUDE.md. DSM API: SYNO.Docker.Container/get with name JSON-encoded (action inspect does not exist and returns code 103). Hash-prefixed names are resolved transparently, matching the convention of the other container tools. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -368,6 +368,25 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
||||
except Exception as e:
|
||||
return f"Error stopping container '{container_name}': {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def inspect_container(container_name: str):
|
||||
"""Inspect a container — image, ports, mounts (full host paths), env, labels, network."""
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
display_name = _strip_hash_prefix(resolved_name)
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"get",
|
||||
params={"name": json.dumps(resolved_name)},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error inspecting container '{container_name}': {e}"
|
||||
|
||||
if not data:
|
||||
return f"Container '{container_name}' not found."
|
||||
|
||||
return _format_container_inspect(display_name, data)
|
||||
|
||||
@mcp.tool()
|
||||
async def restart_container(container_name: str, confirmed: bool = False):
|
||||
"""Restart a container. Requires confirmed=True."""
|
||||
@@ -457,3 +476,144 @@ def _format_container_detail(name: str, data: dict[str, Any]) -> str:
|
||||
lines.append(f" {src} → {dst}{type_tag}{rw}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_container_inspect(name: str, data: dict[str, Any]) -> str:
|
||||
"""Format SYNO.Docker.Container/get for the migration-oriented inspect view.
|
||||
|
||||
Reads the volume host path from ``details.Mounts[].Source`` (full
|
||||
``/volume1/...`` path), NOT from ``profile.volume_bindings[].host_volume_file``
|
||||
— the latter is share-relative (e.g. ``/docker/foo`` for ``/volume1/docker/foo``)
|
||||
and is not directly Compose-usable. See CLAUDE.md DSM quirks.
|
||||
"""
|
||||
details: dict[str, Any] = data.get("details", {}) or {}
|
||||
profile: dict[str, Any] = data.get("profile", {}) or {}
|
||||
|
||||
state: dict[str, Any] = details.get("State", {}) or {}
|
||||
host_config: dict[str, Any] = details.get("HostConfig", {}) or {}
|
||||
|
||||
image_str = profile.get("image") or details.get("Config", {}).get("Image", "?")
|
||||
status_str = state.get("Status", "?")
|
||||
running = state.get("Running", False)
|
||||
restart_count = details.get("RestartCount", 0)
|
||||
|
||||
lines = [
|
||||
f"Container: {name}",
|
||||
f" Image: {image_str}",
|
||||
f" Status: {status_str} (running={running})",
|
||||
]
|
||||
if restart_count:
|
||||
lines.append(f" Restarts: {restart_count}")
|
||||
|
||||
# ── Restart policy ──────────────────────────────────────────────────────
|
||||
restart_policy = host_config.get("RestartPolicy", {}) or {}
|
||||
policy_name = restart_policy.get("Name", "")
|
||||
if policy_name:
|
||||
max_retry = restart_policy.get("MaximumRetryCount", 0)
|
||||
suffix = f" (max {max_retry})" if policy_name == "on-failure" and max_retry else ""
|
||||
lines.append(f" Restart: {policy_name}{suffix}")
|
||||
elif profile.get("enable_restart_policy"):
|
||||
lines.append(" Restart: enabled")
|
||||
|
||||
# ── Network ─────────────────────────────────────────────────────────────
|
||||
net_mode = profile.get("network_mode") or host_config.get("NetworkMode", "")
|
||||
use_host = profile.get("use_host_network", False)
|
||||
if use_host:
|
||||
lines.append(" Network: host")
|
||||
elif net_mode:
|
||||
lines.append(f" Network: {net_mode}")
|
||||
|
||||
networks: dict[str, Any] = (details.get("NetworkSettings", {}) or {}).get("Networks", {}) or {}
|
||||
for net_name, net in networks.items():
|
||||
ip = (net or {}).get("IPAddress", "")
|
||||
if ip:
|
||||
lines.append(f" {net_name}: {ip}")
|
||||
|
||||
# ── Ports ───────────────────────────────────────────────────────────────
|
||||
port_bindings: list[dict[str, Any]] = profile.get("port_bindings", []) or []
|
||||
if port_bindings:
|
||||
lines.append("")
|
||||
lines.append(f"Ports ({len(port_bindings)}):")
|
||||
for pb in port_bindings:
|
||||
host = pb.get("host_port", "?")
|
||||
ctr = pb.get("container_port", "?")
|
||||
proto = pb.get("type", "tcp")
|
||||
lines.append(f" {host} → {ctr}/{proto}")
|
||||
|
||||
# ── Mounts ──────────────────────────────────────────────────────────────
|
||||
# Use details.Mounts[].Source — it's the full /volume1/... path.
|
||||
# profile.volume_bindings[].host_volume_file is share-relative and not
|
||||
# Compose-usable (DSM quirk; see CLAUDE.md).
|
||||
mounts: list[dict[str, Any]] = details.get("Mounts", []) or []
|
||||
if mounts:
|
||||
lines.append("")
|
||||
lines.append(f"Mounts ({len(mounts)}):")
|
||||
for m in mounts:
|
||||
src = m.get("Source", "?")
|
||||
dst = m.get("Destination", "?")
|
||||
mtype = m.get("Type", "")
|
||||
rw = "" if m.get("RW", True) else " [ro]"
|
||||
type_tag = f" ({mtype})" if mtype else ""
|
||||
lines.append(f" {src} → {dst}{type_tag}{rw}")
|
||||
|
||||
# ── Environment ─────────────────────────────────────────────────────────
|
||||
env_vars: list[Any] = profile.get("env_variables") or []
|
||||
if env_vars:
|
||||
lines.append("")
|
||||
lines.append(f"Environment ({len(env_vars)}):")
|
||||
for var in env_vars:
|
||||
if isinstance(var, dict):
|
||||
lines.append(f" {var.get('key', '?')}={var.get('value', '')}")
|
||||
else:
|
||||
lines.append(f" {var}")
|
||||
|
||||
# ── Labels ──────────────────────────────────────────────────────────────
|
||||
labels: dict[str, Any] = profile.get("labels") or {}
|
||||
if labels:
|
||||
lines.append("")
|
||||
lines.append(f"Labels ({len(labels)}):")
|
||||
for key in sorted(labels):
|
||||
lines.append(f" {key}={labels[key]}")
|
||||
|
||||
# ── Command / Entrypoint ────────────────────────────────────────────────
|
||||
cfg = details.get("Config", {}) or {}
|
||||
entrypoint = cfg.get("Entrypoint") or []
|
||||
cmd = cfg.get("Cmd") or profile.get("cmd_v2") or profile.get("cmd") or ""
|
||||
|
||||
if entrypoint:
|
||||
ep_str = (
|
||||
" ".join(str(x) for x in entrypoint)
|
||||
if isinstance(entrypoint, list)
|
||||
else str(entrypoint)
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"Entrypoint: {ep_str}")
|
||||
if cmd:
|
||||
cmd_str = " ".join(str(x) for x in cmd) if isinstance(cmd, list) else str(cmd)
|
||||
if not entrypoint:
|
||||
lines.append("")
|
||||
lines.append(f"Cmd: {cmd_str}")
|
||||
|
||||
# ── Links ───────────────────────────────────────────────────────────────
|
||||
links: list[Any] = profile.get("links") or []
|
||||
if links:
|
||||
lines.append("")
|
||||
lines.append(f"Links ({len(links)}):")
|
||||
for link in links:
|
||||
lines.append(f" {link}")
|
||||
|
||||
# ── Capabilities / Privileged ───────────────────────────────────────────
|
||||
cap_add = profile.get("CapAdd") or host_config.get("CapAdd") or []
|
||||
cap_drop = profile.get("CapDrop") or host_config.get("CapDrop") or []
|
||||
privileged = profile.get("privileged") or host_config.get("Privileged", False)
|
||||
if cap_add or cap_drop or privileged:
|
||||
lines.append("")
|
||||
lines.append("Security:")
|
||||
if privileged:
|
||||
lines.append(" privileged=true")
|
||||
if cap_add:
|
||||
lines.append(f" CapAdd: {', '.join(str(c) for c in cap_add)}")
|
||||
if cap_drop:
|
||||
lines.append(f" CapDrop: {', '.join(str(c) for c in cap_drop)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
Reference in New Issue
Block a user