diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c55a0..cb4f5e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,51 @@ 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.` 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 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 35f880d..a857d6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,16 +33,16 @@ Only a second consecutive failure is treated as a real auth problem. --- -## Implemented tools (25) +## Implemented tools (33) | Category | Tools | |---|---| | 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` | -| 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` | -| 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`, `redeploy_project`, `create_project`, `delete_project`, `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` - DSM errors → human-readable message, no stack traces - No secrets in stderr output diff --git a/pyproject.toml b/pyproject.toml index 3c3a9f5..56b2088 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-synology-container" -version = "0.3.3" +version = "0.4.0" description = "MCP server for Synology Container Manager" requires-python = ">=3.12" dependencies = [ diff --git a/src/mcp_synology_container/modules/containers.py b/src/mcp_synology_container/modules/containers.py index bcb9fa3..1e39c72 100644 --- a/src/mcp_synology_container/modules/containers.py +++ b/src/mcp_synology_container/modules/containers.py @@ -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.""" diff --git a/src/mcp_synology_container/modules/images.py b/src/mcp_synology_container/modules/images.py index a32b9fb..2c15c20 100644 --- a/src/mcp_synology_container/modules/images.py +++ b/src/mcp_synology_container/modules/images.py @@ -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).""" diff --git a/src/mcp_synology_container/modules/system.py b/src/mcp_synology_container/modules/system.py index e4ed105..329d223 100644 --- a/src/mcp_synology_container/modules/system.py +++ b/src/mcp_synology_container/modules/system.py @@ -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.""" diff --git a/tests/test_modules/test_containers.py b/tests/test_modules/test_containers.py index 62c7dd4..152170e 100644 --- a/tests/test_modules/test_containers.py +++ b/tests/test_modules/test_containers.py @@ -704,3 +704,157 @@ async def test_container_stats_no_precpu_graceful(): result = await tools["container_stats"]("myapp") 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 diff --git a/tests/test_modules/test_images.py b/tests/test_modules/test_images.py index 588f38f..fdbad29 100644 --- a/tests/test_modules/test_images.py +++ b/tests/test_modules/test_images.py @@ -411,6 +411,216 @@ async def test_delete_image_api_error(): 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) # ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/test_modules/test_system.py b/tests/test_modules/test_system.py index 7d51906..b97ff79 100644 --- a/tests/test_modules/test_system.py +++ b/tests/test_modules/test_system.py @@ -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. assert "preview" in result.lower() 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 diff --git a/uv.lock b/uv.lock index bf9e6e2..bd8b111 100644 --- a/uv.lock +++ b/uv.lock @@ -362,7 +362,7 @@ wheels = [ [[package]] name = "mcp-synology-container" -version = "0.3.3" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "click" },