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:
@@ -2,6 +2,51 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-05-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
**Container lifecycle (#1, #7)** — five new tools fill the gap between
|
||||||
|
"project" and "exec" operations. Until now the only way to stop or
|
||||||
|
restart a single container was to stop the whole project. All five
|
||||||
|
use `SYNO.Docker.Container` (version=1) with the same JSON-encoded
|
||||||
|
`name` parameter pattern as `delete_container`, and route through
|
||||||
|
`_resolve_container_name` so a clean name like `jenkins` resolves
|
||||||
|
to DSM's hash-prefixed `f93cb8b504f7_jenkins`:
|
||||||
|
|
||||||
|
- `start_container` — start a stopped container (no confirmation).
|
||||||
|
- `stop_container` — stop a running container (confirmation required).
|
||||||
|
- `restart_container` — stop + start in one call (confirmation required).
|
||||||
|
- `pause_container` — SIGSTOP the container's processes (confirmation
|
||||||
|
required).
|
||||||
|
- `unpause_container` — SIGCONT the container's processes (no
|
||||||
|
confirmation; reversing a pause is non-destructive).
|
||||||
|
|
||||||
|
`stop` is live-verified against DSM; `start`, `restart`, `pause`, and
|
||||||
|
`unpause` are implemented by symmetry on the same API surface. If any
|
||||||
|
of them turns out to need a different parameter shape in production,
|
||||||
|
it will be reverse-engineered via DevTools capture in a follow-up.
|
||||||
|
|
||||||
|
**Image inspection (#4)** — `inspect_image` returns the full image
|
||||||
|
detail blob (layers + sizes, environment variables, exposed ports,
|
||||||
|
entrypoint/cmd, working directory, labels) via
|
||||||
|
`SYNO.Docker.Image/get`. Accepts the same identifier forms as
|
||||||
|
`delete_image` — `name:tag`, registry-prefixed `ghcr.io/foo/bar:v1`,
|
||||||
|
and bare hash — and resolves the user input against `list_images`
|
||||||
|
first so typos produce a clean "not found" rather than an opaque DSM
|
||||||
|
error. The response parser is defensive about wrapper shape
|
||||||
|
(`details.<docker fields>` vs. flat) so the tool keeps working if DSM
|
||||||
|
varies the envelope between firmware versions.
|
||||||
|
|
||||||
|
**System overview (#6)** — `system_overview` aggregates CPU %, RAM
|
||||||
|
used/limit, network I/O, and block I/O across all running containers
|
||||||
|
plus running/stopped counts. No new DSM endpoint: composed from
|
||||||
|
`SYNO.Docker.Container/list` + `SYNO.Docker.Container/stats`,
|
||||||
|
re-using the same Docker-formula CPU calculation as
|
||||||
|
`container_stats`. Errors from either upstream call are non-fatal —
|
||||||
|
the available section is rendered and the failure is surfaced under a
|
||||||
|
`Warnings:` block.
|
||||||
|
|
||||||
## [0.3.3] - 2026-05-18
|
## [0.3.3] - 2026-05-18
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -33,16 +33,16 @@ Only a second consecutive failure is treated as a real auth problem.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implemented tools (25)
|
## Implemented tools (33)
|
||||||
|
|
||||||
| Category | Tools |
|
| Category | Tools |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Projects | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project`, `create_project`, `delete_project` |
|
| Projects | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project`, `create_project`, `delete_project` |
|
||||||
| Containers | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats`, `delete_container` |
|
| Containers | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats`, `delete_container`, `start_container`, `stop_container`, `restart_container`, `pause_container`, `unpause_container` |
|
||||||
| Compose | `read_compose`, `update_compose`, `update_image_tag`, `update_env_var` |
|
| Compose | `read_compose`, `update_compose`, `update_image_tag`, `update_env_var` |
|
||||||
| Images | `check_image_updates`, `list_images`, `delete_image` |
|
| Images | `check_image_updates`, `list_images`, `delete_image`, `inspect_image` |
|
||||||
| Networks | `list_networks`, `create_network`, `delete_network` |
|
| Networks | `list_networks`, `create_network`, `delete_network` |
|
||||||
| System | `system_df`, `system_prune` |
|
| System | `system_df`, `system_prune`, `system_overview` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -71,7 +71,8 @@ Only a second consecutive failure is treated as a real auth problem.
|
|||||||
- Confirmation required before destructive operations: `stop_project`,
|
- Confirmation required before destructive operations: `stop_project`,
|
||||||
`redeploy_project`, `create_project`, `delete_project`,
|
`redeploy_project`, `create_project`, `delete_project`,
|
||||||
`exec_in_container`, `update_image_tag`, `update_env_var`,
|
`exec_in_container`, `update_image_tag`, `update_env_var`,
|
||||||
`update_compose`, `delete_container`
|
`update_compose`, `delete_container`, `stop_container`,
|
||||||
|
`restart_container`, `pause_container`
|
||||||
- After compose changes: suggest `redeploy_project`
|
- After compose changes: suggest `redeploy_project`
|
||||||
- DSM errors → human-readable message, no stack traces
|
- DSM errors → human-readable message, no stack traces
|
||||||
- No secrets in stderr output
|
- No secrets in stderr output
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.3.3"
|
version = "0.4.0"
|
||||||
description = "MCP server for Synology Container Manager"
|
description = "MCP server for Synology Container Manager"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -330,6 +330,104 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error deleting '{display_name}': {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:
|
def _container_in_project(container: dict[str, Any], project_name: str) -> bool:
|
||||||
"""Check if a container belongs to a project based on its labels."""
|
"""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."
|
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()
|
@mcp.tool()
|
||||||
async def check_image_updates(project_name: str | None = None):
|
async def check_image_updates(project_name: str | None = None):
|
||||||
"""Check which local images have updates available (upgradable flag from NAS registry)."""
|
"""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)
|
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()
|
@mcp.tool()
|
||||||
async def system_prune(confirmed: bool = False):
|
async def system_prune(confirmed: bool = False):
|
||||||
"""Remove unused Docker resources (images, stopped containers). Requires confirmed=True."""
|
"""Remove unused Docker resources (images, stopped containers). Requires confirmed=True."""
|
||||||
|
|||||||
@@ -704,3 +704,157 @@ async def test_container_stats_no_precpu_graceful():
|
|||||||
|
|
||||||
result = await tools["container_stats"]("myapp")
|
result = await tools["container_stats"]("myapp")
|
||||||
assert "0.00%" in result # graceful fallback to 0
|
assert "0.00%" in result # graceful fallback to 0
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Lifecycle: start / stop / restart / pause / unpause
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
# Tools that don't have a confirmation gate
|
||||||
|
_NO_CONFIRM_LIFECYCLE = [
|
||||||
|
("start_container", "start", "Started"),
|
||||||
|
("unpause_container", "unpause", "Unpaused"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Tools that require confirmed=True
|
||||||
|
_CONFIRM_LIFECYCLE = [
|
||||||
|
("stop_container", "stop", "Stopped", "stop"),
|
||||||
|
("restart_container", "restart", "Restarted", "restart"),
|
||||||
|
("pause_container", "pause", "Paused", "pause"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tool_name, dsm_method, success_word", _NO_CONFIRM_LIFECYCLE)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lifecycle_no_confirm_calls_dsm(tool_name, dsm_method, success_word):
|
||||||
|
"""start_container and unpause_container call DSM directly with json-encoded name."""
|
||||||
|
from mcp_synology_container.modules.containers import register_containers
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.request.return_value = {}
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_containers(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools[tool_name]("myapp_web")
|
||||||
|
|
||||||
|
# Find the matching lifecycle call (a list() may also occur via _resolve_container_name)
|
||||||
|
calls = [c for c in client.request.call_args_list if c.args[1] == dsm_method]
|
||||||
|
assert len(calls) == 1
|
||||||
|
call = calls[0]
|
||||||
|
assert call.args[0] == "SYNO.Docker.Container"
|
||||||
|
assert call.kwargs["version"] == 1
|
||||||
|
assert call.kwargs["params"] == {"name": '"myapp_web"'}
|
||||||
|
assert success_word in result
|
||||||
|
assert "myapp_web" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tool_name, dsm_method, success_word, _action", _CONFIRM_LIFECYCLE)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lifecycle_confirmed_calls_dsm(tool_name, dsm_method, success_word, _action):
|
||||||
|
"""stop/restart/pause call DSM with confirmed=True using json-encoded name."""
|
||||||
|
from mcp_synology_container.modules.containers import register_containers
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.request.return_value = {}
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_containers(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools[tool_name]("myapp_web", confirmed=True)
|
||||||
|
|
||||||
|
calls = [c for c in client.request.call_args_list if c.args[1] == dsm_method]
|
||||||
|
assert len(calls) == 1
|
||||||
|
call = calls[0]
|
||||||
|
assert call.args[0] == "SYNO.Docker.Container"
|
||||||
|
assert call.kwargs["version"] == 1
|
||||||
|
assert call.kwargs["params"] == {"name": '"myapp_web"'}
|
||||||
|
assert success_word in result
|
||||||
|
assert "myapp_web" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tool_name, _dsm_method, _success_word, action", _CONFIRM_LIFECYCLE)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lifecycle_preview_without_confirmation(
|
||||||
|
tool_name, _dsm_method, _success_word, action
|
||||||
|
):
|
||||||
|
"""stop/restart/pause without confirmed=True must NOT call DSM."""
|
||||||
|
from mcp_synology_container.modules.containers import register_containers
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_containers(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools[tool_name]("myapp_web")
|
||||||
|
assert "Preview" in result
|
||||||
|
assert action in result
|
||||||
|
assert "myapp_web" in result
|
||||||
|
assert "confirmed=True" in result
|
||||||
|
client.request.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"tool_name, dsm_method, kwargs",
|
||||||
|
[
|
||||||
|
("start_container", "start", {}),
|
||||||
|
("unpause_container", "unpause", {}),
|
||||||
|
("stop_container", "stop", {"confirmed": True}),
|
||||||
|
("restart_container", "restart", {"confirmed": True}),
|
||||||
|
("pause_container", "pause", {"confirmed": True}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lifecycle_resolves_hash_prefix(tool_name, dsm_method, kwargs):
|
||||||
|
"""All lifecycle tools resolve 'jenkins' to 'f93cb8b504f7_jenkins' for the DSM call."""
|
||||||
|
from mcp_synology_container.modules.containers import register_containers
|
||||||
|
|
||||||
|
async def mock_request(api, method, **kw):
|
||||||
|
if api == "SYNO.Docker.Container" and method == "list":
|
||||||
|
return HASH_PREFIXED_CONTAINERS_DATA
|
||||||
|
if api == "SYNO.Docker.Container" and method == dsm_method:
|
||||||
|
assert kw["params"]["name"] == '"f93cb8b504f7_jenkins"'
|
||||||
|
assert kw["version"] == 1
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.request.side_effect = mock_request
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_containers(mcp, make_config(), client)
|
||||||
|
|
||||||
|
# User passes the clean name; resolved to the hash-prefixed name for DSM,
|
||||||
|
# but the display name in the success message must be hash-stripped.
|
||||||
|
result = await tools[tool_name]("jenkins", **kwargs)
|
||||||
|
assert "jenkins" in result
|
||||||
|
assert "f93cb8b504f7" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"tool_name, kwargs, error_verb",
|
||||||
|
[
|
||||||
|
("start_container", {}, "starting"),
|
||||||
|
("unpause_container", {}, "unpausing"),
|
||||||
|
("stop_container", {"confirmed": True}, "stopping"),
|
||||||
|
("restart_container", {"confirmed": True}, "restarting"),
|
||||||
|
("pause_container", {"confirmed": True}, "pausing"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lifecycle_api_error(tool_name, kwargs, error_verb):
|
||||||
|
"""API errors during lifecycle ops return a human-readable error message."""
|
||||||
|
from mcp_synology_container.dsm_client import SynologyError
|
||||||
|
from mcp_synology_container.modules.containers import register_containers
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.request.side_effect = SynologyError("API error", code=102)
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_containers(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools[tool_name]("myapp_web", **kwargs)
|
||||||
|
assert "Error" in result
|
||||||
|
assert error_verb in result
|
||||||
|
assert "myapp_web" in result
|
||||||
|
|||||||
@@ -411,6 +411,216 @@ async def test_delete_image_api_error():
|
|||||||
assert "114" in result
|
assert "114" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# inspect_image
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
SAMPLE_INSPECT = {
|
||||||
|
"details": {
|
||||||
|
"Id": "sha256:aaaa",
|
||||||
|
"RepoTags": ["nginx:1.24"],
|
||||||
|
"Size": 50 * 1024 * 1024,
|
||||||
|
"Created": "2024-01-01T00:00:00Z",
|
||||||
|
"Config": {
|
||||||
|
"Env": ["NGINX_VERSION=1.24", "PATH=/usr/local/sbin"],
|
||||||
|
"ExposedPorts": {"80/tcp": {}, "443/tcp": {}},
|
||||||
|
"Entrypoint": ["/docker-entrypoint.sh"],
|
||||||
|
"Cmd": ["nginx", "-g", "daemon off;"],
|
||||||
|
"WorkingDir": "/",
|
||||||
|
"Labels": {"maintainer": "NGINX Docker Maintainers"},
|
||||||
|
},
|
||||||
|
"RootFS": {
|
||||||
|
"Type": "layers",
|
||||||
|
"Layers": ["sha256:layer1", "sha256:layer2"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_inspect_client(inspect_payload=None, images_payload=None):
|
||||||
|
"""Build a mock DsmClient that returns SAMPLE_IMAGES for list and inspect_payload for get."""
|
||||||
|
client = AsyncMock()
|
||||||
|
|
||||||
|
async def mock_request(api, method, **kwargs):
|
||||||
|
if api == "SYNO.Docker.Image" and method == "list":
|
||||||
|
return images_payload if images_payload is not None else SAMPLE_IMAGES
|
||||||
|
if api == "SYNO.Docker.Image" and method == "get":
|
||||||
|
return inspect_payload if inspect_payload is not None else SAMPLE_INSPECT
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.request.side_effect = mock_request
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inspect_image_by_name_tag():
|
||||||
|
from mcp_synology_container.modules.images import register_images
|
||||||
|
|
||||||
|
client = _make_inspect_client()
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_images(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||||
|
assert "nginx" in result
|
||||||
|
assert "1.24" in result
|
||||||
|
assert "MiB" in result # size formatted via _human_size
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inspect_image_by_hash():
|
||||||
|
from mcp_synology_container.modules.images import register_images
|
||||||
|
|
||||||
|
# Inspect data shaped for redis (sha256:cccc)
|
||||||
|
redis_inspect = {
|
||||||
|
"details": {
|
||||||
|
"Id": "sha256:cccc",
|
||||||
|
"RepoTags": ["redis:7"],
|
||||||
|
"Size": 30 * 1024 * 1024,
|
||||||
|
"Config": {"Env": [], "ExposedPorts": {}, "Cmd": ["redis-server"]},
|
||||||
|
"RootFS": {"Layers": ["sha256:rlayer1"]},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client = _make_inspect_client(inspect_payload=redis_inspect)
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_images(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["inspect_image"](image_id="sha256:cccc")
|
||||||
|
assert "redis" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inspect_image_not_found():
|
||||||
|
from mcp_synology_container.modules.images import register_images
|
||||||
|
|
||||||
|
client = _make_inspect_client()
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_images(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["inspect_image"](image_id="bogus:latest")
|
||||||
|
assert "not found" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inspect_image_shows_env_vars():
|
||||||
|
from mcp_synology_container.modules.images import register_images
|
||||||
|
|
||||||
|
client = _make_inspect_client()
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_images(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||||
|
assert "NGINX_VERSION=1.24" in result
|
||||||
|
assert "PATH=/usr/local/sbin" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inspect_image_shows_exposed_ports():
|
||||||
|
from mcp_synology_container.modules.images import register_images
|
||||||
|
|
||||||
|
client = _make_inspect_client()
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_images(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||||
|
assert "80/tcp" in result
|
||||||
|
assert "443/tcp" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inspect_image_shows_layers():
|
||||||
|
from mcp_synology_container.modules.images import register_images
|
||||||
|
|
||||||
|
client = _make_inspect_client()
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_images(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||||
|
assert "Layers" in result
|
||||||
|
# Layer hashes truncated to 12 chars after sha256:
|
||||||
|
assert "layer1" in result
|
||||||
|
assert "layer2" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inspect_image_shows_entrypoint_cmd():
|
||||||
|
from mcp_synology_container.modules.images import register_images
|
||||||
|
|
||||||
|
client = _make_inspect_client()
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_images(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||||
|
assert "/docker-entrypoint.sh" in result
|
||||||
|
assert "nginx" in result
|
||||||
|
assert "daemon off;" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inspect_image_registry_prefixed():
|
||||||
|
from mcp_synology_container.modules.images import register_images
|
||||||
|
|
||||||
|
registry_images = {
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"id": "sha256:dddd",
|
||||||
|
"repository": "ghcr.io/foo/bar",
|
||||||
|
"tags": ["v1"],
|
||||||
|
"size": 100 * 1024 * 1024,
|
||||||
|
"created": 1700000000,
|
||||||
|
"upgradable": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
registry_inspect = {
|
||||||
|
"details": {
|
||||||
|
"Id": "sha256:dddd",
|
||||||
|
"RepoTags": ["ghcr.io/foo/bar:v1"],
|
||||||
|
"Size": 100 * 1024 * 1024,
|
||||||
|
"Config": {"Env": [], "ExposedPorts": {}, "Cmd": ["/app"]},
|
||||||
|
"RootFS": {"Layers": ["sha256:rlayer1"]},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client = _make_inspect_client(inspect_payload=registry_inspect, images_payload=registry_images)
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_images(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["inspect_image"](image_id="ghcr.io/foo/bar:v1")
|
||||||
|
assert "ghcr.io/foo/bar" in result
|
||||||
|
assert "v1" in result
|
||||||
|
|
||||||
|
# Verify the get call used the full registry-prefixed repository name
|
||||||
|
get_calls = [
|
||||||
|
c for c in client.request.call_args_list if c.args[:2] == ("SYNO.Docker.Image", "get")
|
||||||
|
]
|
||||||
|
assert get_calls, "inspect_image must call SYNO.Docker.Image/get"
|
||||||
|
params = get_calls[0].kwargs.get("params") or {}
|
||||||
|
assert params.get("name") == "ghcr.io/foo/bar"
|
||||||
|
assert params.get("tag") == "v1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inspect_image_api_error():
|
||||||
|
from mcp_synology_container.dsm_client import SynologyError
|
||||||
|
from mcp_synology_container.modules.images import register_images
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
|
||||||
|
async def mock_request(api, method, **kwargs):
|
||||||
|
if api == "SYNO.Docker.Image" and method == "list":
|
||||||
|
return SAMPLE_IMAGES
|
||||||
|
if api == "SYNO.Docker.Image" and method == "get":
|
||||||
|
raise SynologyError("inspect failed", code=120)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.request.side_effect = mock_request
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_images(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||||
|
assert "Error" in result
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# check_image_updates (existing tests preserved)
|
# check_image_updates (existing tests preserved)
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -394,3 +394,209 @@ async def test_system_prune_preview_network_fetch_failure_is_nonfatal() -> None:
|
|||||||
# Preview still renders; networks count falls back to 0.
|
# Preview still renders; networks count falls back to 0.
|
||||||
assert "preview" in result.lower()
|
assert "preview" in result.lower()
|
||||||
assert "Unused networks: 0" in result
|
assert "Unused networks: 0" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# system_overview
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
# Two-container stats snapshot used to verify aggregation arithmetic.
|
||||||
|
SAMPLE_STATS_OVERVIEW = {
|
||||||
|
"aaa111": {
|
||||||
|
"id": "aaa111",
|
||||||
|
"name": "/web",
|
||||||
|
"cpu_stats": {
|
||||||
|
"cpu_usage": {"total_usage": 2_000_000_000, "percpu_usage": [1, 2]},
|
||||||
|
"system_cpu_usage": 1_000_000_000_000,
|
||||||
|
"online_cpus": 2,
|
||||||
|
},
|
||||||
|
"precpu_stats": {
|
||||||
|
"cpu_usage": {"total_usage": 1_000_000_000},
|
||||||
|
"system_cpu_usage": 999_000_000_000,
|
||||||
|
},
|
||||||
|
"memory_stats": {"usage": 100 * 1024 * 1024, "limit": 1024 * 1024 * 1024},
|
||||||
|
"networks": {
|
||||||
|
"eth0": {"rx_bytes": 1_000_000, "tx_bytes": 500_000},
|
||||||
|
},
|
||||||
|
"blkio_stats": {
|
||||||
|
"io_service_bytes_recursive": [
|
||||||
|
{"op": "Read", "value": 10 * 1024 * 1024},
|
||||||
|
{"op": "Write", "value": 5 * 1024 * 1024},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"bbb222": {
|
||||||
|
"id": "bbb222",
|
||||||
|
"name": "/db",
|
||||||
|
"cpu_stats": {
|
||||||
|
"cpu_usage": {"total_usage": 3_000_000_000, "percpu_usage": [1, 2]},
|
||||||
|
"system_cpu_usage": 1_000_000_000_000,
|
||||||
|
"online_cpus": 2,
|
||||||
|
},
|
||||||
|
"precpu_stats": {
|
||||||
|
"cpu_usage": {"total_usage": 2_000_000_000},
|
||||||
|
"system_cpu_usage": 999_000_000_000,
|
||||||
|
},
|
||||||
|
"memory_stats": {"usage": 200 * 1024 * 1024, "limit": 2 * 1024 * 1024 * 1024},
|
||||||
|
"networks": {
|
||||||
|
"eth0": {"rx_bytes": 2_000_000, "tx_bytes": 1_000_000},
|
||||||
|
},
|
||||||
|
"blkio_stats": {
|
||||||
|
"io_service_bytes_recursive": [
|
||||||
|
{"op": "Read", "value": 20 * 1024 * 1024},
|
||||||
|
{"op": "Write", "value": 7 * 1024 * 1024},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_overview_aggregates_stats():
|
||||||
|
"""Sums CPU%, memory, net I/O, and block I/O across all containers in stats."""
|
||||||
|
from mcp_synology_container.modules.system import register_system
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
|
||||||
|
async def mock_request(api, method, **kwargs):
|
||||||
|
if api == "SYNO.Docker.Container" and method == "list":
|
||||||
|
return SAMPLE_CONTAINERS
|
||||||
|
if api == "SYNO.Docker.Container" and method == "stats":
|
||||||
|
return SAMPLE_STATS_OVERVIEW
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.request.side_effect = mock_request
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_system(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["system_overview"]()
|
||||||
|
|
||||||
|
# Headline section exists
|
||||||
|
assert "Docker System Overview" in result
|
||||||
|
assert "Aggregated" in result
|
||||||
|
|
||||||
|
# CPU per container: (1e9 / 1e9) * 2 * 100 = 200% each → sum ≈ 400%
|
||||||
|
assert "400.00%" in result
|
||||||
|
|
||||||
|
# Memory total usage = 100 MiB + 200 MiB = 300 MiB; limit = 1 GiB + 2 GiB = 3 GiB
|
||||||
|
assert "300 MiB" in result
|
||||||
|
assert "3.0 GiB" in result
|
||||||
|
|
||||||
|
# Net I/O present (rx/tx sums non-zero)
|
||||||
|
assert "Net I/O" in result
|
||||||
|
assert "rx " in result
|
||||||
|
assert "tx " in result
|
||||||
|
|
||||||
|
# Block I/O: read = 30 MiB, write = 12 MiB
|
||||||
|
assert "30 MiB" in result
|
||||||
|
assert "12 MiB" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_overview_running_vs_stopped_count():
|
||||||
|
"""Counts running and stopped containers from the list response."""
|
||||||
|
from mcp_synology_container.modules.system import register_system
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
|
||||||
|
async def mock_request(api, method, **kwargs):
|
||||||
|
if api == "SYNO.Docker.Container" and method == "list":
|
||||||
|
return SAMPLE_CONTAINERS
|
||||||
|
if api == "SYNO.Docker.Container" and method == "stats":
|
||||||
|
return SAMPLE_STATS_OVERVIEW
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.request.side_effect = mock_request
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_system(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["system_overview"]()
|
||||||
|
|
||||||
|
# SAMPLE_CONTAINERS has 2 running ("web", "db") and 1 stopped ("old")
|
||||||
|
assert "running" in result
|
||||||
|
assert "stopped" in result
|
||||||
|
assert " 2 running" in result
|
||||||
|
assert " 1 stopped" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_overview_no_containers():
|
||||||
|
"""Empty container list and empty stats — shows zero counts, no aggregation block."""
|
||||||
|
from mcp_synology_container.modules.system import register_system
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
|
||||||
|
async def mock_request(api, method, **kwargs):
|
||||||
|
if api == "SYNO.Docker.Container" and method == "list":
|
||||||
|
return {"containers": []}
|
||||||
|
if api == "SYNO.Docker.Container" and method == "stats":
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.request.side_effect = mock_request
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_system(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["system_overview"]()
|
||||||
|
assert " 0 running" in result
|
||||||
|
assert " 0 stopped" in result
|
||||||
|
# No aggregation block when there are no stats entries
|
||||||
|
assert "Aggregated" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_overview_container_list_error_graceful():
|
||||||
|
"""list fails, stats works → still shows aggregated stats and a warning."""
|
||||||
|
from mcp_synology_container.dsm_client import SynologyError
|
||||||
|
from mcp_synology_container.modules.system import register_system
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
|
||||||
|
async def mock_request(api, method, **kwargs):
|
||||||
|
if api == "SYNO.Docker.Container" and method == "list":
|
||||||
|
raise SynologyError("list failed", code=102)
|
||||||
|
if api == "SYNO.Docker.Container" and method == "stats":
|
||||||
|
return SAMPLE_STATS_OVERVIEW
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.request.side_effect = mock_request
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_system(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["system_overview"]()
|
||||||
|
# Counts default to 0 (list failed), but aggregation still runs
|
||||||
|
assert "Warnings" in result
|
||||||
|
assert "list" in result
|
||||||
|
assert "Aggregated" in result
|
||||||
|
assert "400.00%" in result # stats still aggregated
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_overview_stats_error_graceful():
|
||||||
|
"""stats fails, list works → still shows counts and a warning."""
|
||||||
|
from mcp_synology_container.dsm_client import SynologyError
|
||||||
|
from mcp_synology_container.modules.system import register_system
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
|
||||||
|
async def mock_request(api, method, **kwargs):
|
||||||
|
if api == "SYNO.Docker.Container" and method == "list":
|
||||||
|
return SAMPLE_CONTAINERS
|
||||||
|
if api == "SYNO.Docker.Container" and method == "stats":
|
||||||
|
raise SynologyError("stats failed", code=102)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.request.side_effect = mock_request
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_system(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["system_overview"]()
|
||||||
|
# Counts still displayed
|
||||||
|
assert " 2 running" in result
|
||||||
|
assert " 1 stopped" in result
|
||||||
|
# Warning surfaced for the stats failure
|
||||||
|
assert "Warnings" in result
|
||||||
|
assert "stats" in result
|
||||||
|
# No aggregation block — stats unavailable
|
||||||
|
assert "Aggregated" not in result
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.3.3"
|
version = "0.4.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user