diff --git a/CLAUDE.md b/CLAUDE.md index d0ff391..6f8e696 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,14 +32,14 @@ via Container Manager. Der MCP-Server ist in Claude Desktop aktiv verbunden. ## Aktueller Stand -### Implementierte Tools (14) +### Implementierte Tools (17) | Kategorie | Tools | |---|---| | Projekte | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project` | -| Container | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container` | +| Container | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats` | | Compose | `read_compose`, `update_compose`, `update_image_tag`, `update_env_var` | -| Images | `check_image_updates` | +| Images | `check_image_updates`, `list_images`, `delete_image` | ### Bekannte Bugs @@ -58,8 +58,8 @@ via Container Manager. Der MCP-Server ist in Claude Desktop aktiv verbunden. - `pull_image` – Image manuell aus Registry ziehen ### Container -- `container_stats` – Live CPU/RAM-Verbrauch pro Container -- `rename_container` – Container umbenennen +- ~~`container_stats`~~ – implementiert +- ~~`rename_container`~~ – entfällt (DSM bietet kein Container-Umbenennen) ### Netzwerke - `list_networks` – alle Docker-Netzwerke auflisten @@ -152,19 +152,15 @@ Implementiere **eine Gruppe nach der anderen**. Commit + Push nach jeder Gruppe, --- -#### Gruppe 2 – Container `modules/containers.py` ✦ Prio: mittel +#### Gruppe 2 – Container `modules/containers.py` ✦ Prio: mittel ✅ erledigt -**`rename_container`** -- Signatur: `rename_container(container_name: str, new_name: str, confirmed: bool = False) -> str` -- DSM API: `SYNO.Docker.Container` rename (oder Docker Engine API direkt) -- Hinweis nach Erfolg: compose.yml ggf. manuell anpassen (`container_name`) -- Confirmation: **ja** - -**`container_stats`** +**`container_stats`** ✅ - Signatur: `container_stats(container_name: str) -> str` - Ausgabe: CPU %, RAM verwendet/limit, Netzwerk I/O, Block I/O - Confirmation: nein +**`rename_container`** – entfällt (DSM bietet kein Container-Umbenennen) + --- #### Gruppe 3 – System `modules/system.py` (neu) ✦ Prio: hoch diff --git a/src/mcp_synology_container/modules/containers.py b/src/mcp_synology_container/modules/containers.py index 0aa7108..0f31d08 100644 --- a/src/mcp_synology_container/modules/containers.py +++ b/src/mcp_synology_container/modules/containers.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from mcp.server.fastmcp import FastMCP + from mcp_synology_container.config import AppConfig from mcp_synology_container.dsm_client import DsmClient @@ -40,9 +41,9 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N # Filter by project if specified if project_name: containers = [ - c for c in containers - if c.get("project_name") == project_name - or _container_in_project(c, project_name) + c + for c in containers + if c.get("project_name") == project_name or _container_in_project(c, project_name) ] if not containers: return f"No containers found for project '{project_name}'." @@ -129,6 +130,92 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N return header + "\n".join(lines) + @mcp.tool() + async def container_stats(container_name: str) -> str: + """Get live resource usage statistics for a container. + + Reports CPU %, RAM used/limit, Network I/O (rx/tx), and Block I/O + (read/write) for the named container. + + Args: + container_name: Name of the container (e.g. "jenkins"). + """ + try: + data = await client.request("SYNO.Docker.Container", "stats") + except Exception as e: + return f"Error fetching container stats: {e}" + + if not data: + return "No stats data returned." + + # Response is a dict keyed by container ID hash; each entry has "name" + # with a leading slash (e.g. "/jenkins"). + target: dict[str, Any] | None = None + for entry in data.values(): + entry_name = entry.get("name", "").lstrip("/") + if entry_name == container_name.lstrip("/"): + target = entry + break + + if target is None: + return ( + f"Container '{container_name}' not found in stats. " + f"Available: {', '.join(v.get('name', '?').lstrip('/') for v in data.values())}" + ) + + # ── CPU % ──────────────────────────────────────────────────────────── + cpu_stats = target.get("cpu_stats", {}) + precpu_stats = target.get("precpu_stats", {}) + cpu_usage = cpu_stats.get("cpu_usage", {}) + precpu_usage = precpu_stats.get("cpu_usage", {}) + + 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: + cpu_pct = (cpu_delta / system_delta) * online_cpus * 100.0 + else: + cpu_pct = 0.0 + + # ── Memory ─────────────────────────────────────────────────────────── + mem_stats = target.get("memory_stats", {}) + mem_usage = mem_stats.get("usage", 0) + mem_limit = mem_stats.get("limit", 0) + + # ── Network I/O ────────────────────────────────────────────────────── + net_rx = 0 + net_tx = 0 + for iface in (target.get("networks") or {}).values(): + net_rx += iface.get("rx_bytes", 0) + net_tx += iface.get("tx_bytes", 0) + + # ── Block I/O ──────────────────────────────────────────────────────── + blk_read = 0 + blk_write = 0 + for entry_io in target.get("blkio_stats", {}).get("io_service_bytes_recursive") or []: + op = entry_io.get("op", "").lower() + val = entry_io.get("value", 0) + if op == "read": + blk_read += val + elif op == "write": + blk_write += val + + # ── Format ─────────────────────────────────────────────────────────── + from mcp_synology_container.modules.images import _human_size # reuse helper + + mem_limit_str = f" / {_human_size(mem_limit)}" if mem_limit else "" + lines = [ + f"Stats for {container_name}:", + f" CPU: {cpu_pct:.2f}% ({online_cpus} CPUs)", + f" Memory: {_human_size(mem_usage)}{mem_limit_str}", + f" Net I/O: rx {_human_size(net_rx)} / tx {_human_size(net_tx)}", + f" Block I/O: read {_human_size(blk_read)} / write {_human_size(blk_write)}", + ] + return "\n".join(lines) + @mcp.tool() async def exec_in_container( container_name: str, diff --git a/tests/test_modules/test_containers.py b/tests/test_modules/test_containers.py index 94d1aee..04a442a 100644 --- a/tests/test_modules/test_containers.py +++ b/tests/test_modules/test_containers.py @@ -1,8 +1,9 @@ """Tests for modules/containers.py.""" -import pytest from unittest.mock import AsyncMock +import pytest + def make_mock_mcp(): tools: dict = {} @@ -12,6 +13,7 @@ def make_mock_mcp(): def decorator(fn): tools[fn.__name__] = fn return fn + return decorator return MockMCP(), tools @@ -19,6 +21,7 @@ def make_mock_mcp(): def make_config(): from mcp_synology_container.config import AppConfig, ConnectionConfig + return AppConfig( schema_version=1, connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True), @@ -171,3 +174,175 @@ async def test_exec_in_container_confirmed(): result = await tools["exec_in_container"]("myapp_web", "ls /app", confirmed=True) assert "file1.py" in result assert "Exit code: 0" in result + + +# ────────────────────────────────────────────────────────────────────────────── +# container_stats +# ────────────────────────────────────────────────────────────────────────────── + +# Realistic stats snapshot for a container named "glance" +SAMPLE_STATS = { + "ee220111cff2": { + "id": "ee220111cff2", + "name": "/glance", + "cpu_stats": { + "cpu_usage": { + "total_usage": 1_022_653_189, + "percpu_usage": [286394951, 245078386, 304613157, 186566695], + }, + "system_cpu_usage": 990_015_100_000_000, + "online_cpus": 4, + }, + "precpu_stats": { + "cpu_usage": {"total_usage": 1_000_000_000}, + "system_cpu_usage": 989_000_000_000_000, + }, + "memory_stats": { + "usage": 21_127_168, + "limit": 4_079_349_760, + }, + "networks": { + "eth0": {"rx_bytes": 876, "tx_bytes": 0}, + "eth1": {"rx_bytes": 1024, "tx_bytes": 512}, + }, + "blkio_stats": { + "io_service_bytes_recursive": [ + {"op": "Read", "value": 4096}, + {"op": "Write", "value": 8192}, + {"op": "Total", "value": 12288}, + ] + }, + } +} + + +@pytest.mark.asyncio +async def test_container_stats_found(): + from mcp_synology_container.modules.containers import register_containers + + client = AsyncMock() + client.request.return_value = SAMPLE_STATS + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["container_stats"]("glance") + assert "glance" in result + assert "CPU" in result + assert "%" in result + assert "Memory" in result + assert "Net I/O" in result + assert "Block I/O" in result + + +@pytest.mark.asyncio +async def test_container_stats_cpu_calculation(): + """CPU% is computed via the standard Docker formula.""" + from mcp_synology_container.modules.containers import register_containers + + client = AsyncMock() + client.request.return_value = SAMPLE_STATS + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["container_stats"]("glance") + + # cpu_delta = 1_022_653_189 - 1_000_000_000 = 22_653_189 + # system_delta = 990_015_100_000_000 - 989_000_000_000_000 = 1_015_100_000_000 + # cpu_pct = (22_653_189 / 1_015_100_000_000) * 4 * 100 ≈ 0.0089 % + assert "0.00" in result or "%" in result # value near zero but formatted + + +@pytest.mark.asyncio +async def test_container_stats_memory_human_readable(): + from mcp_synology_container.modules.containers import register_containers + + client = AsyncMock() + client.request.return_value = SAMPLE_STATS + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["container_stats"]("glance") + # 21_127_168 bytes ≈ 20 MiB; limit 4_079_349_760 ≈ 3.8 GiB + assert "MiB" in result or "GiB" in result + + +@pytest.mark.asyncio +async def test_container_stats_name_with_slash(): + """Container name matching strips leading slash from DSM response.""" + from mcp_synology_container.modules.containers import register_containers + + client = AsyncMock() + client.request.return_value = SAMPLE_STATS + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + # Should match even if called without the leading slash + result = await tools["container_stats"]("glance") + assert "not found" not in result.lower() + + +@pytest.mark.asyncio +async def test_container_stats_not_found(): + from mcp_synology_container.modules.containers import register_containers + + client = AsyncMock() + client.request.return_value = SAMPLE_STATS + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["container_stats"]("nonexistent") + assert "not found" in result.lower() + assert "glance" in result # shows available containers + + +@pytest.mark.asyncio +async def test_container_stats_api_error(): + 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["container_stats"]("glance") + assert "Error" in result + + +@pytest.mark.asyncio +async def test_container_stats_no_precpu_graceful(): + """When precpu_stats has no system_cpu_usage (first poll), CPU% = 0.""" + from mcp_synology_container.modules.containers import register_containers + + stats_no_pre = { + "abc123": { + "name": "/myapp", + "cpu_stats": { + "cpu_usage": {"total_usage": 500_000, "percpu_usage": [500_000]}, + "system_cpu_usage": 100_000_000_000, + "online_cpus": 1, + }, + "precpu_stats": { + "cpu_usage": {"total_usage": 0}, + # system_cpu_usage absent → system_delta = 0 + }, + "memory_stats": {"usage": 1024, "limit": 2048}, + "networks": {}, + "blkio_stats": {"io_service_bytes_recursive": []}, + } + } + + client = AsyncMock() + client.request.return_value = stats_no_pre + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["container_stats"]("myapp") + assert "0.00%" in result # graceful fallback to 0