feat: v0.4.0 — welle A (8 new tools: container lifecycle, inspect_image, system_overview)

Closes #1, #4, #6, #7.

Container lifecycle (#1, #7):
- start_container, stop_container, restart_container, pause_container,
  unpause_container — all via SYNO.Docker.Container with JSON-encoded
  name parameter, routed through _resolve_container_name for hash-
  prefix resolution. stop is live-verified; the other four are
  implemented by symmetry on the same API surface.

inspect_image (#4):
- Returns full image detail (layers, env, ports, entrypoint/cmd,
  labels) via SYNO.Docker.Image/get. Accepts name:tag, registry-
  prefixed names, and bare hashes. Defensive response parsing
  handles both wrapped (details.*) and flat envelopes.

system_overview (#6):
- Aggregates CPU %, RAM, network and block I/O across all running
  containers plus running/stopped counts. No new DSM endpoint —
  composed from list + stats, reusing the container_stats CPU
  formula. Per-source errors are non-fatal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 12:40:11 +02:00
parent 8adcf93b6a
commit 12d532da7b
10 changed files with 989 additions and 7 deletions
+210
View File
@@ -411,6 +411,216 @@ async def test_delete_image_api_error():
assert "114" in result
# ──────────────────────────────────────────────────────────────────────────────
# inspect_image
# ──────────────────────────────────────────────────────────────────────────────
SAMPLE_INSPECT = {
"details": {
"Id": "sha256:aaaa",
"RepoTags": ["nginx:1.24"],
"Size": 50 * 1024 * 1024,
"Created": "2024-01-01T00:00:00Z",
"Config": {
"Env": ["NGINX_VERSION=1.24", "PATH=/usr/local/sbin"],
"ExposedPorts": {"80/tcp": {}, "443/tcp": {}},
"Entrypoint": ["/docker-entrypoint.sh"],
"Cmd": ["nginx", "-g", "daemon off;"],
"WorkingDir": "/",
"Labels": {"maintainer": "NGINX Docker Maintainers"},
},
"RootFS": {
"Type": "layers",
"Layers": ["sha256:layer1", "sha256:layer2"],
},
}
}
def _make_inspect_client(inspect_payload=None, images_payload=None):
"""Build a mock DsmClient that returns SAMPLE_IMAGES for list and inspect_payload for get."""
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
return images_payload if images_payload is not None else SAMPLE_IMAGES
if api == "SYNO.Docker.Image" and method == "get":
return inspect_payload if inspect_payload is not None else SAMPLE_INSPECT
return {}
client.request.side_effect = mock_request
return client
@pytest.mark.asyncio
async def test_inspect_image_by_name_tag():
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client()
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="nginx:1.24")
assert "nginx" in result
assert "1.24" in result
assert "MiB" in result # size formatted via _human_size
@pytest.mark.asyncio
async def test_inspect_image_by_hash():
from mcp_synology_container.modules.images import register_images
# Inspect data shaped for redis (sha256:cccc)
redis_inspect = {
"details": {
"Id": "sha256:cccc",
"RepoTags": ["redis:7"],
"Size": 30 * 1024 * 1024,
"Config": {"Env": [], "ExposedPorts": {}, "Cmd": ["redis-server"]},
"RootFS": {"Layers": ["sha256:rlayer1"]},
}
}
client = _make_inspect_client(inspect_payload=redis_inspect)
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="sha256:cccc")
assert "redis" in result
@pytest.mark.asyncio
async def test_inspect_image_not_found():
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client()
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="bogus:latest")
assert "not found" in result
@pytest.mark.asyncio
async def test_inspect_image_shows_env_vars():
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client()
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="nginx:1.24")
assert "NGINX_VERSION=1.24" in result
assert "PATH=/usr/local/sbin" in result
@pytest.mark.asyncio
async def test_inspect_image_shows_exposed_ports():
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client()
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="nginx:1.24")
assert "80/tcp" in result
assert "443/tcp" in result
@pytest.mark.asyncio
async def test_inspect_image_shows_layers():
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client()
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="nginx:1.24")
assert "Layers" in result
# Layer hashes truncated to 12 chars after sha256:
assert "layer1" in result
assert "layer2" in result
@pytest.mark.asyncio
async def test_inspect_image_shows_entrypoint_cmd():
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client()
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="nginx:1.24")
assert "/docker-entrypoint.sh" in result
assert "nginx" in result
assert "daemon off;" in result
@pytest.mark.asyncio
async def test_inspect_image_registry_prefixed():
from mcp_synology_container.modules.images import register_images
registry_images = {
"images": [
{
"id": "sha256:dddd",
"repository": "ghcr.io/foo/bar",
"tags": ["v1"],
"size": 100 * 1024 * 1024,
"created": 1700000000,
"upgradable": False,
}
]
}
registry_inspect = {
"details": {
"Id": "sha256:dddd",
"RepoTags": ["ghcr.io/foo/bar:v1"],
"Size": 100 * 1024 * 1024,
"Config": {"Env": [], "ExposedPorts": {}, "Cmd": ["/app"]},
"RootFS": {"Layers": ["sha256:rlayer1"]},
}
}
client = _make_inspect_client(inspect_payload=registry_inspect, images_payload=registry_images)
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="ghcr.io/foo/bar:v1")
assert "ghcr.io/foo/bar" in result
assert "v1" in result
# Verify the get call used the full registry-prefixed repository name
get_calls = [
c for c in client.request.call_args_list if c.args[:2] == ("SYNO.Docker.Image", "get")
]
assert get_calls, "inspect_image must call SYNO.Docker.Image/get"
params = get_calls[0].kwargs.get("params") or {}
assert params.get("name") == "ghcr.io/foo/bar"
assert params.get("tag") == "v1"
@pytest.mark.asyncio
async def test_inspect_image_api_error():
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.images import register_images
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
return SAMPLE_IMAGES
if api == "SYNO.Docker.Image" and method == "get":
raise SynologyError("inspect failed", code=120)
return {}
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="nginx:1.24")
assert "Error" in result
# ──────────────────────────────────────────────────────────────────────────────
# check_image_updates (existing tests preserved)
# ──────────────────────────────────────────────────────────────────────────────