Files
mcp-synology-container/tests/test_modules/test_containers.py
T
marcus a8da306ce5 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>
2026-04-13 18:29:17 +02:00

349 lines
10 KiB
Python

"""Tests for modules/containers.py."""
from unittest.mock import AsyncMock
import pytest
def make_mock_mcp():
tools: dict = {}
class MockMCP:
def tool(self):
def decorator(fn):
tools[fn.__name__] = fn
return fn
return decorator
return MockMCP(), tools
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),
)
SAMPLE_CONTAINERS_DATA = {
"containers": [
{
"name": "myapp_web",
"status": "running",
"image": "nginx:alpine",
"project_name": "myapp",
},
{
"name": "myapp_db",
"status": "running",
"image": "postgres:15",
"project_name": "myapp",
},
{
"name": "other_svc",
"status": "stopped",
"image": "redis:7",
"project_name": "other",
},
]
}
SAMPLE_LOGS_DATA = {
"logs": [
{
"created": "2025-01-01T10:00:00Z",
"stream": "stdout",
"text": "Server started",
"docid": "1",
},
{
"created": "2025-01-01T10:00:01Z",
"stream": "stderr",
"text": "Warning: deprecated option",
"docid": "2",
},
],
"total": 2,
}
@pytest.mark.asyncio
async def test_list_containers_all():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = SAMPLE_CONTAINERS_DATA
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["list_containers"]()
assert "myapp_web" in result
assert "myapp_db" in result
assert "other_svc" in result
@pytest.mark.asyncio
async def test_list_containers_filtered_by_project():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = SAMPLE_CONTAINERS_DATA
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["list_containers"](project_name="myapp")
assert "myapp_web" in result
assert "myapp_db" in result
assert "other_svc" not in result
@pytest.mark.asyncio
async def test_list_containers_empty():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = {"containers": []}
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["list_containers"]()
assert "No containers found" in result
@pytest.mark.asyncio
async def test_get_container_logs():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = SAMPLE_LOGS_DATA
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["get_container_logs"]("myapp_web", tail=50)
assert "myapp_web" in result
assert "Server started" in result
assert "Warning: deprecated option" in result
@pytest.mark.asyncio
async def test_get_container_logs_with_keyword():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = SAMPLE_LOGS_DATA
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
await tools["get_container_logs"]("myapp_web", tail=100, keyword="error")
call_params = client.request.call_args[1]["params"]
assert call_params["keyword"] == "error"
@pytest.mark.asyncio
async def test_exec_in_container_requires_confirmation():
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["exec_in_container"]("myapp_web", "ls /app", confirmed=False)
assert "confirmed=True" in result
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_exec_in_container_confirmed():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = {"output": "file1.py\nfile2.py", "exit_code": 0}
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
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