584d53e6e4
- get_container_status now strips hash prefix from user input and calls
SYNO.Docker.Container/get with the clean name (e.g. 'jenkins'), not the
hash-prefixed form — the get endpoint accepts only the clean name
- _format_container_detail: unwraps 'container' wrapper key if present
(DSM may return {"container": {State, Config, ...}} at the data level)
- Flat-format fallback: reads lowercase 'status'/'image' fields when
Docker Engine nested format (State/Config) is absent
- Diagnostic stderr logging for data_keys, unwrap, status, image
- 25 container tests all passing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
560 lines
18 KiB
Python
560 lines
18 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",
|
|
},
|
|
]
|
|
}
|
|
|
|
# Container data where DSM returns hash-prefixed names
|
|
HASH_PREFIXED_CONTAINERS_DATA = {
|
|
"containers": [
|
|
{
|
|
"name": "f93cb8b504f7_jenkins",
|
|
"status": "running",
|
|
"image": "jenkins/jenkins:lts",
|
|
"project_name": "frostiq",
|
|
},
|
|
]
|
|
}
|
|
|
|
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
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Bug 1: hash-prefix stripping
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_strip_hash_prefix_strips_prefix():
|
|
from mcp_synology_container.modules.containers import _strip_hash_prefix
|
|
|
|
assert _strip_hash_prefix("f93cb8b504f7_jenkins") == "jenkins"
|
|
|
|
|
|
def test_strip_hash_prefix_no_prefix():
|
|
from mcp_synology_container.modules.containers import _strip_hash_prefix
|
|
|
|
assert _strip_hash_prefix("jenkins") == "jenkins"
|
|
|
|
|
|
def test_strip_hash_prefix_leading_slash():
|
|
from mcp_synology_container.modules.containers import _strip_hash_prefix
|
|
|
|
assert _strip_hash_prefix("/jenkins") == "jenkins"
|
|
|
|
|
|
def test_strip_hash_prefix_slash_with_hash():
|
|
from mcp_synology_container.modules.containers import _strip_hash_prefix
|
|
|
|
assert _strip_hash_prefix("/f93cb8b504f7_jenkins") == "jenkins"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_containers_strips_hash_prefix():
|
|
"""list_containers must display the clean name without the hash prefix."""
|
|
from mcp_synology_container.modules.containers import register_containers
|
|
|
|
client = AsyncMock()
|
|
client.request.return_value = HASH_PREFIXED_CONTAINERS_DATA
|
|
|
|
mcp, tools = make_mock_mcp()
|
|
register_containers(mcp, make_config(), client)
|
|
|
|
result = await tools["list_containers"]()
|
|
assert "jenkins" in result
|
|
assert "f93cb8b504f7_jenkins" not in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_container_stats_strips_hash_prefix():
|
|
"""container_stats must match even when DSM returns a hash-prefixed name."""
|
|
from mcp_synology_container.modules.containers import register_containers
|
|
|
|
stats_with_hash = {
|
|
"abc123": {
|
|
"name": "/f93cb8b504f7_jenkins",
|
|
"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": 0,
|
|
},
|
|
"memory_stats": {"usage": 1024, "limit": 2048},
|
|
"networks": {},
|
|
"blkio_stats": {"io_service_bytes_recursive": []},
|
|
}
|
|
}
|
|
|
|
client = AsyncMock()
|
|
client.request.return_value = stats_with_hash
|
|
|
|
mcp, tools = make_mock_mcp()
|
|
register_containers(mcp, make_config(), client)
|
|
|
|
# User passes clean name; must still match the hash-prefixed entry
|
|
result = await tools["container_stats"]("jenkins")
|
|
assert "not found" not in result.lower()
|
|
assert "CPU" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_container_status_uses_clean_name():
|
|
"""get_container_status strips hash prefix and calls get with clean name."""
|
|
from mcp_synology_container.modules.containers import register_containers
|
|
|
|
detail_data = {"State": {"Status": "running", "Running": True}, "Config": {"Image": "jenkins/jenkins:lts"}}
|
|
|
|
async def mock_request(api, method, **kwargs):
|
|
if api == "SYNO.Docker.Container" and method == "get":
|
|
# Must be called with the CLEAN name (no hash prefix)
|
|
assert kwargs["params"]["name"] == "jenkins"
|
|
return detail_data
|
|
return {}
|
|
|
|
client = AsyncMock()
|
|
client.request.side_effect = mock_request
|
|
|
|
mcp, tools = make_mock_mcp()
|
|
register_containers(mcp, make_config(), client)
|
|
|
|
# User passes hash-prefixed name → should be stripped before get call
|
|
result = await tools["get_container_status"]("f93cb8b504f7_jenkins")
|
|
assert "running" in result
|
|
assert "f93cb8b504f7" not in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_container_status_container_key_unwrap():
|
|
"""get_container_status unwraps DSM 'container' wrapper key."""
|
|
from mcp_synology_container.modules.containers import register_containers
|
|
|
|
# DSM may wrap the inspect data under a "container" key
|
|
wrapped_data = {
|
|
"container": {
|
|
"State": {"Status": "running", "Running": True},
|
|
"Config": {"Image": "jenkins/jenkins:lts"},
|
|
}
|
|
}
|
|
|
|
client = AsyncMock()
|
|
client.request.return_value = wrapped_data
|
|
|
|
mcp, tools = make_mock_mcp()
|
|
register_containers(mcp, make_config(), client)
|
|
|
|
result = await tools["get_container_status"]("jenkins")
|
|
assert "running" in result
|
|
assert "jenkins/jenkins:lts" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_container_status_flat_format():
|
|
"""get_container_status handles DSM flat format (lowercase status/image)."""
|
|
from mcp_synology_container.modules.containers import register_containers
|
|
|
|
flat_data = {
|
|
"status": "running",
|
|
"image": "jenkins/jenkins:lts",
|
|
"name": "jenkins",
|
|
}
|
|
|
|
client = AsyncMock()
|
|
client.request.return_value = flat_data
|
|
|
|
mcp, tools = make_mock_mcp()
|
|
register_containers(mcp, make_config(), client)
|
|
|
|
result = await tools["get_container_status"]("jenkins")
|
|
assert "running" in result
|
|
assert "jenkins/jenkins:lts" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_container_logs_resolves_hash_prefix():
|
|
"""get_container_logs resolves 'jenkins' to 'f93cb8b504f7_jenkins' for DSM call."""
|
|
from mcp_synology_container.modules.containers import register_containers
|
|
|
|
async def mock_request(api, method, **kwargs):
|
|
if api == "SYNO.Docker.Container" and method == "list":
|
|
return HASH_PREFIXED_CONTAINERS_DATA
|
|
if api == "SYNO.Docker.Container.Log" and method == "get":
|
|
assert kwargs["params"]["name"] == "f93cb8b504f7_jenkins"
|
|
return {"logs": [{"created": "2025-01-01", "stream": "stdout", "text": "started"}], "total": 1}
|
|
return {}
|
|
|
|
client = AsyncMock()
|
|
client.request.side_effect = mock_request
|
|
|
|
mcp, tools = make_mock_mcp()
|
|
register_containers(mcp, make_config(), client)
|
|
|
|
result = await tools["get_container_logs"]("jenkins")
|
|
assert "started" in result
|
|
assert "f93cb8b504f7" not in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_in_container_resolves_hash_prefix():
|
|
"""exec_in_container resolves 'jenkins' to 'f93cb8b504f7_jenkins' for DSM call."""
|
|
from mcp_synology_container.modules.containers import register_containers
|
|
|
|
async def mock_request(api, method, **kwargs):
|
|
if api == "SYNO.Docker.Container" and method == "list":
|
|
return HASH_PREFIXED_CONTAINERS_DATA
|
|
if api == "SYNO.Docker.Container" and method == "exec":
|
|
assert kwargs["params"]["name"] == "f93cb8b504f7_jenkins"
|
|
return {"output": "ok", "exit_code": 0}
|
|
return {}
|
|
|
|
client = AsyncMock()
|
|
client.request.side_effect = mock_request
|
|
|
|
mcp, tools = make_mock_mcp()
|
|
register_containers(mcp, make_config(), client)
|
|
|
|
result = await tools["exec_in_container"]("jenkins", "echo ok", confirmed=True)
|
|
assert "ok" 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
|