Add container_stats tool (Gruppe 2); remove rename_container
container_stats(container_name) calls SYNO.Docker.Container/stats, locates the entry by name (stripping the DSM-added leading slash), and reports: - CPU % (standard Docker formula: cpu_delta / system_delta * cpus * 100) - Memory used / limit (human-readable) - Network I/O rx / tx (summed across all interfaces) - Block I/O read / write (from io_service_bytes_recursive) Gracefully handles first-poll (precpu system_cpu_usage absent → 0%). 7 unit tests covering: found, CPU formula, memory format, slash-strip, not-found, API error, no-precpu fallback. rename_container removed: DSM Container Manager offers no rename API. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -32,14 +32,14 @@ via Container Manager. Der MCP-Server ist in Claude Desktop aktiv verbunden.
|
|||||||
|
|
||||||
## Aktueller Stand
|
## Aktueller Stand
|
||||||
|
|
||||||
### Implementierte Tools (14)
|
### Implementierte Tools (17)
|
||||||
|
|
||||||
| Kategorie | Tools |
|
| Kategorie | Tools |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Projekte | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project` |
|
| 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` |
|
| 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
|
### 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
|
- `pull_image` – Image manuell aus Registry ziehen
|
||||||
|
|
||||||
### Container
|
### Container
|
||||||
- `container_stats` – Live CPU/RAM-Verbrauch pro Container
|
- ~~`container_stats`~~ – implementiert
|
||||||
- `rename_container` – Container umbenennen
|
- ~~`rename_container`~~ – entfällt (DSM bietet kein Container-Umbenennen)
|
||||||
|
|
||||||
### Netzwerke
|
### Netzwerke
|
||||||
- `list_networks` – alle Docker-Netzwerke auflisten
|
- `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`**
|
**`container_stats`** ✅
|
||||||
- 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`**
|
|
||||||
- Signatur: `container_stats(container_name: str) -> str`
|
- Signatur: `container_stats(container_name: str) -> str`
|
||||||
- Ausgabe: CPU %, RAM verwendet/limit, Netzwerk I/O, Block I/O
|
- Ausgabe: CPU %, RAM verwendet/limit, Netzwerk I/O, Block I/O
|
||||||
- Confirmation: nein
|
- Confirmation: nein
|
||||||
|
|
||||||
|
**`rename_container`** – entfällt (DSM bietet kein Container-Umbenennen)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Gruppe 3 – System `modules/system.py` (neu) ✦ Prio: hoch
|
#### Gruppe 3 – System `modules/system.py` (neu) ✦ Prio: hoch
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from mcp_synology_container.config import AppConfig
|
from mcp_synology_container.config import AppConfig
|
||||||
from mcp_synology_container.dsm_client import DsmClient
|
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
|
# Filter by project if specified
|
||||||
if project_name:
|
if project_name:
|
||||||
containers = [
|
containers = [
|
||||||
c for c in containers
|
c
|
||||||
if c.get("project_name") == project_name
|
for c in containers
|
||||||
or _container_in_project(c, project_name)
|
if c.get("project_name") == project_name or _container_in_project(c, project_name)
|
||||||
]
|
]
|
||||||
if not containers:
|
if not containers:
|
||||||
return f"No containers found for project '{project_name}'."
|
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)
|
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()
|
@mcp.tool()
|
||||||
async def exec_in_container(
|
async def exec_in_container(
|
||||||
container_name: str,
|
container_name: str,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Tests for modules/containers.py."""
|
"""Tests for modules/containers.py."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
def make_mock_mcp():
|
def make_mock_mcp():
|
||||||
tools: dict = {}
|
tools: dict = {}
|
||||||
@@ -12,6 +13,7 @@ def make_mock_mcp():
|
|||||||
def decorator(fn):
|
def decorator(fn):
|
||||||
tools[fn.__name__] = fn
|
tools[fn.__name__] = fn
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
return MockMCP(), tools
|
return MockMCP(), tools
|
||||||
@@ -19,6 +21,7 @@ def make_mock_mcp():
|
|||||||
|
|
||||||
def make_config():
|
def make_config():
|
||||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||||
|
|
||||||
return AppConfig(
|
return AppConfig(
|
||||||
schema_version=1,
|
schema_version=1,
|
||||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
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)
|
result = await tools["exec_in_container"]("myapp_web", "ls /app", confirmed=True)
|
||||||
assert "file1.py" in result
|
assert "file1.py" in result
|
||||||
assert "Exit code: 0" 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
|
||||||
|
|||||||
Reference in New Issue
Block a user