feat: v0.4.0 — welle A (8 new tools: container lifecycle, inspect_image, system_overview)
Closes #1, #4, #6, #7. Container lifecycle (#1, #7): - start_container, stop_container, restart_container, pause_container, unpause_container — all via SYNO.Docker.Container with JSON-encoded name parameter, routed through _resolve_container_name for hash- prefix resolution. stop is live-verified; the other four are implemented by symmetry on the same API surface. inspect_image (#4): - Returns full image detail (layers, env, ports, entrypoint/cmd, labels) via SYNO.Docker.Image/get. Accepts name:tag, registry- prefixed names, and bare hashes. Defensive response parsing handles both wrapped (details.*) and flat envelopes. system_overview (#6): - Aggregates CPU %, RAM, network and block I/O across all running containers plus running/stopped counts. No new DSM endpoint — composed from list + stats, reusing the container_stats CPU formula. Per-source errors are non-fatal. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -330,6 +330,104 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
||||
except Exception as e:
|
||||
return f"Error deleting '{display_name}': {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def start_container(container_name: str):
|
||||
"""Start a stopped container."""
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
display_name = _strip_hash_prefix(resolved_name)
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"start",
|
||||
version=1,
|
||||
params={"name": json.dumps(resolved_name)},
|
||||
)
|
||||
return f"Started container '{display_name}'."
|
||||
except Exception as e:
|
||||
return f"Error starting container '{container_name}': {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def stop_container(container_name: str, confirmed: bool = False):
|
||||
"""Stop a running container. Requires confirmed=True."""
|
||||
if not confirmed:
|
||||
return (
|
||||
f"Preview: would stop container '{container_name}'.\n"
|
||||
f"Call this tool again with confirmed=True to proceed."
|
||||
)
|
||||
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
display_name = _strip_hash_prefix(resolved_name)
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"stop",
|
||||
version=1,
|
||||
params={"name": json.dumps(resolved_name)},
|
||||
)
|
||||
return f"Stopped container '{display_name}'."
|
||||
except Exception as e:
|
||||
return f"Error stopping container '{container_name}': {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def restart_container(container_name: str, confirmed: bool = False):
|
||||
"""Restart a container. Requires confirmed=True."""
|
||||
if not confirmed:
|
||||
return (
|
||||
f"Preview: would restart container '{container_name}'.\n"
|
||||
f"Call this tool again with confirmed=True to proceed."
|
||||
)
|
||||
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
display_name = _strip_hash_prefix(resolved_name)
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"restart",
|
||||
version=1,
|
||||
params={"name": json.dumps(resolved_name)},
|
||||
)
|
||||
return f"Restarted container '{display_name}'."
|
||||
except Exception as e:
|
||||
return f"Error restarting container '{container_name}': {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def pause_container(container_name: str, confirmed: bool = False):
|
||||
"""Pause a running container. Requires confirmed=True."""
|
||||
if not confirmed:
|
||||
return (
|
||||
f"Preview: would pause container '{container_name}'.\n"
|
||||
f"Call this tool again with confirmed=True to proceed."
|
||||
)
|
||||
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
display_name = _strip_hash_prefix(resolved_name)
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"pause",
|
||||
version=1,
|
||||
params={"name": json.dumps(resolved_name)},
|
||||
)
|
||||
return f"Paused container '{display_name}'."
|
||||
except Exception as e:
|
||||
return f"Error pausing container '{container_name}': {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def unpause_container(container_name: str):
|
||||
"""Unpause a paused container."""
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
display_name = _strip_hash_prefix(resolved_name)
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"unpause",
|
||||
version=1,
|
||||
params={"name": json.dumps(resolved_name)},
|
||||
)
|
||||
return f"Unpaused container '{display_name}'."
|
||||
except Exception as e:
|
||||
return f"Error unpausing container '{container_name}': {e}"
|
||||
|
||||
|
||||
def _container_in_project(container: dict[str, Any], project_name: str) -> bool:
|
||||
"""Check if a container belongs to a project based on its labels."""
|
||||
|
||||
@@ -271,6 +271,166 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
|
||||
return f"Deleted {display_name} — {size_str} freed."
|
||||
|
||||
@mcp.tool()
|
||||
async def inspect_image(image_id: str):
|
||||
"""Inspect a local image by name:tag or hash — shows config, layers, env, ports."""
|
||||
# Parse name and tag using the last ":" as separator so registry-prefixed
|
||||
# images (e.g. "ghcr.io/foo/bar:v1") are handled correctly.
|
||||
name, sep, tag = image_id.rpartition(":")
|
||||
if not sep:
|
||||
name = image_id
|
||||
tag = "latest"
|
||||
|
||||
# Resolve the image against the local list so the user can pass either
|
||||
# name:tag or a hash (full or 12-char prefix).
|
||||
try:
|
||||
img_data = await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error inspecting image '{image_id}': {e}"
|
||||
|
||||
images: list[dict[str, Any]] = img_data.get("images", [])
|
||||
is_hash = image_id.startswith("sha256:") or (len(image_id) >= 12 and ":" not in image_id)
|
||||
target: dict[str, Any] | None = None
|
||||
|
||||
for img in images:
|
||||
if is_hash:
|
||||
img_hash = img.get("id", "")
|
||||
if img_hash == image_id or img_hash.startswith(image_id):
|
||||
target = img
|
||||
break
|
||||
else:
|
||||
repo = img.get("repository", "")
|
||||
img_tags = img.get("tags") or []
|
||||
if repo == name and tag in img_tags:
|
||||
target = img
|
||||
break
|
||||
|
||||
if target is None:
|
||||
return f"Image '{image_id}' not found locally."
|
||||
|
||||
repo = target.get("repository", name)
|
||||
img_tags = target.get("tags") or [tag]
|
||||
display_name = f"{repo}:{img_tags[0]}"
|
||||
img_hash = target.get("id", "")
|
||||
|
||||
# Call SYNO.Docker.Image/get — consistent with SYNO.Docker.Container/get
|
||||
# used elsewhere in this codebase. Pass both name+tag and id defensively
|
||||
# so DSM can pick whichever shape it accepts.
|
||||
try:
|
||||
inspect_params: dict[str, Any] = {
|
||||
"name": repo,
|
||||
"tag": img_tags[0] if img_tags else tag,
|
||||
}
|
||||
if img_hash:
|
||||
inspect_params["id"] = img_hash
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
"get",
|
||||
params=inspect_params,
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error inspecting image '{image_id}': {e}"
|
||||
|
||||
# DSM may wrap the inspect blob under "details" (like SYNO.Docker.Container/get)
|
||||
# or return Docker-engine-style fields at top level. Try both.
|
||||
details: dict[str, Any] = data.get("details") if isinstance(data, dict) else None
|
||||
if not isinstance(details, dict):
|
||||
details = data if isinstance(data, dict) else {}
|
||||
|
||||
# Identity — prefer fields from inspect, fall back to list_images entry
|
||||
inspect_id = details.get("Id") or img_hash or "unknown"
|
||||
size_val = details.get("Size", target.get("size", 0)) or 0
|
||||
size_str = _human_size(size_val)
|
||||
|
||||
# Created — DSM list_images returns a Unix int; inspect typically returns ISO string
|
||||
created_field: Any = details.get("Created") or target.get("created", 0)
|
||||
if isinstance(created_field, int):
|
||||
created_str = _format_created(created_field)
|
||||
elif isinstance(created_field, str) and created_field:
|
||||
# Trim ISO timestamp to date portion if possible
|
||||
created_str = created_field.split("T")[0]
|
||||
else:
|
||||
created_str = "unknown"
|
||||
|
||||
config: dict[str, Any] = details.get("Config") or {}
|
||||
env_list: list[str] = config.get("Env") or []
|
||||
exposed_ports: dict[str, Any] = config.get("ExposedPorts") or {}
|
||||
entrypoint = config.get("Entrypoint")
|
||||
cmd = config.get("Cmd")
|
||||
working_dir = config.get("WorkingDir") or ""
|
||||
labels: dict[str, Any] = config.get("Labels") or {}
|
||||
|
||||
rootfs: dict[str, Any] = details.get("RootFS") or {}
|
||||
layers: list[Any] = rootfs.get("Layers") or []
|
||||
|
||||
lines = [
|
||||
f"Image: {display_name}",
|
||||
f" Hash: {inspect_id}",
|
||||
f" Size: {size_str}",
|
||||
f" Created: {created_str}",
|
||||
]
|
||||
|
||||
if working_dir:
|
||||
lines.append(f" Working dir: {working_dir}")
|
||||
|
||||
if entrypoint:
|
||||
ep_str = (
|
||||
" ".join(str(x) for x in entrypoint)
|
||||
if isinstance(entrypoint, list)
|
||||
else str(entrypoint)
|
||||
)
|
||||
lines.append(f" Entrypoint: {ep_str}")
|
||||
|
||||
if cmd:
|
||||
cmd_str = " ".join(str(x) for x in cmd) if isinstance(cmd, list) else str(cmd)
|
||||
lines.append(f" Cmd: {cmd_str}")
|
||||
|
||||
if exposed_ports:
|
||||
lines.append("")
|
||||
lines.append(f"Exposed ports ({len(exposed_ports)}):")
|
||||
for port in exposed_ports:
|
||||
lines.append(f" {port}")
|
||||
|
||||
if env_list:
|
||||
lines.append("")
|
||||
lines.append(f"Environment ({len(env_list)}):")
|
||||
for var in env_list:
|
||||
lines.append(f" {var}")
|
||||
|
||||
if layers:
|
||||
lines.append("")
|
||||
lines.append(f"Layers ({len(layers)}):")
|
||||
for layer in layers:
|
||||
# Layer may be a string hash or a dict with size info
|
||||
if isinstance(layer, dict):
|
||||
layer_hash = layer.get("digest") or layer.get("Id") or ""
|
||||
layer_size = layer.get("size") or layer.get("Size")
|
||||
short = layer_hash.split(":")[-1][:12] if layer_hash else "?"
|
||||
if isinstance(layer_size, int) and layer_size > 0:
|
||||
lines.append(f" {short} {_human_size(layer_size)}")
|
||||
else:
|
||||
lines.append(f" {short}")
|
||||
else:
|
||||
layer_str = str(layer)
|
||||
short = layer_str.split(":")[-1][:12] if layer_str else "?"
|
||||
lines.append(f" {short}")
|
||||
|
||||
if labels:
|
||||
label_items = list(labels.items())
|
||||
shown = label_items[:5]
|
||||
lines.append("")
|
||||
lines.append(f"Labels ({len(label_items)}):")
|
||||
for key, value in shown:
|
||||
lines.append(f" {key}={value}")
|
||||
if len(label_items) > len(shown):
|
||||
lines.append(f" ... and {len(label_items) - len(shown)} more")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def check_image_updates(project_name: str | None = None):
|
||||
"""Check which local images have updates available (upgradable flag from NAS registry)."""
|
||||
|
||||
@@ -93,6 +93,114 @@ def register_system(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def system_overview():
|
||||
"""Aggregated CPU, memory, network, and block I/O across all running containers."""
|
||||
errors: list[str] = []
|
||||
|
||||
# ── Container list (for running/stopped counts) ──────────────────────
|
||||
containers: list[dict[str, Any]] = []
|
||||
try:
|
||||
ctr_data = await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "type": "all"},
|
||||
)
|
||||
containers = ctr_data.get("containers", []) or []
|
||||
except Exception as e:
|
||||
errors.append(f"list: {e}")
|
||||
|
||||
running = sum(1 for c in containers if c.get("status") in ("running", "up"))
|
||||
stopped = len(containers) - running
|
||||
|
||||
# ── Stats (for aggregation) ──────────────────────────────────────────
|
||||
stats_data: dict[str, Any] = {}
|
||||
try:
|
||||
stats_data = await client.request("SYNO.Docker.Container", "stats") or {}
|
||||
except Exception as e:
|
||||
errors.append(f"stats: {e}")
|
||||
|
||||
total_cpu_pct = 0.0
|
||||
total_mem_usage = 0
|
||||
total_mem_limit = 0
|
||||
total_net_rx = 0
|
||||
total_net_tx = 0
|
||||
total_blk_read = 0
|
||||
total_blk_write = 0
|
||||
aggregated_count = 0
|
||||
|
||||
for entry in stats_data.values():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
aggregated_count += 1
|
||||
|
||||
# CPU % — Docker formula, mirrors container_stats logic
|
||||
cpu_stats = entry.get("cpu_stats", {}) or {}
|
||||
precpu_stats = entry.get("precpu_stats", {}) or {}
|
||||
cpu_usage = cpu_stats.get("cpu_usage", {}) or {}
|
||||
precpu_usage = precpu_stats.get("cpu_usage", {}) or {}
|
||||
|
||||
cpu_delta = cpu_usage.get("total_usage", 0) - precpu_usage.get("total_usage", 0)
|
||||
system_delta = cpu_stats.get("system_cpu_usage", 0) - precpu_stats.get(
|
||||
"system_cpu_usage", 0
|
||||
)
|
||||
online_cpus = cpu_stats.get("online_cpus") or len(cpu_usage.get("percpu_usage") or [1])
|
||||
if system_delta > 0 and cpu_delta >= 0:
|
||||
total_cpu_pct += (cpu_delta / system_delta) * online_cpus * 100.0
|
||||
|
||||
# Memory
|
||||
mem_stats = entry.get("memory_stats", {}) or {}
|
||||
total_mem_usage += mem_stats.get("usage", 0)
|
||||
total_mem_limit += mem_stats.get("limit", 0)
|
||||
|
||||
# Network I/O
|
||||
for iface in (entry.get("networks") or {}).values():
|
||||
if not isinstance(iface, dict):
|
||||
continue
|
||||
total_net_rx += iface.get("rx_bytes", 0)
|
||||
total_net_tx += iface.get("tx_bytes", 0)
|
||||
|
||||
# Block I/O
|
||||
for entry_io in (entry.get("blkio_stats", {}) or {}).get(
|
||||
"io_service_bytes_recursive"
|
||||
) or []:
|
||||
if not isinstance(entry_io, dict):
|
||||
continue
|
||||
op = (entry_io.get("op") or "").lower()
|
||||
val = entry_io.get("value", 0)
|
||||
if op == "read":
|
||||
total_blk_read += val
|
||||
elif op == "write":
|
||||
total_blk_write += val
|
||||
|
||||
# ── Format output ────────────────────────────────────────────────────
|
||||
lines = ["Docker System Overview", ""]
|
||||
lines.append(" Containers:")
|
||||
lines.append(f" {running:>3} running")
|
||||
lines.append(f" {stopped:>3} stopped")
|
||||
|
||||
if aggregated_count > 0:
|
||||
mem_limit_str = _human_size(total_mem_limit) if total_mem_limit else "—"
|
||||
lines.append("")
|
||||
lines.append(" Aggregated (running containers):")
|
||||
lines.append(f" CPU: {total_cpu_pct:.2f}%")
|
||||
lines.append(f" Memory: {_human_size(total_mem_usage)} / {mem_limit_str}")
|
||||
lines.append(
|
||||
f" Net I/O: rx {_human_size(total_net_rx)} / tx {_human_size(total_net_tx)}"
|
||||
)
|
||||
lines.append(
|
||||
f" Block I/O: read {_human_size(total_blk_read)}"
|
||||
f" / write {_human_size(total_blk_write)}"
|
||||
)
|
||||
|
||||
if errors:
|
||||
lines.append("")
|
||||
lines.append(" Warnings:")
|
||||
for err in errors:
|
||||
lines.append(f" {err}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def system_prune(confirmed: bool = False):
|
||||
"""Remove unused Docker resources (images, stopped containers). Requires confirmed=True."""
|
||||
|
||||
Reference in New Issue
Block a user