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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user