feat: v0.7.0 — inspect_container (full-path mount source)

New tool inspect_container surfaces the full configuration of a single
container as the foundation for a future GUI-container → Compose
migration workflow. Output covers image, status, restart policy,
network mode + per-network IPs, port bindings, volume mounts, env
vars, labels, entrypoint/command, links, and capabilities.

Mount paths come from details.Mounts[].Source (full /volume1/...
path), NOT from profile.volume_bindings[].host_volume_file — the
latter is share-relative (e.g. /docker/foo for /volume1/docker/foo)
and not directly Compose-usable. Verified live against the NAS;
quirk documented in CLAUDE.md.

DSM API: SYNO.Docker.Container/get with name JSON-encoded (action
inspect does not exist and returns code 103). Hash-prefixed names
are resolved transparently, matching the convention of the other
container tools.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 15:59:51 +02:00
parent 036429e9bf
commit 7bb9b00dcc
6 changed files with 433 additions and 4 deletions
+224
View File
@@ -623,6 +623,230 @@ async def test_get_container_status_shows_mounts():
assert "/var/jenkins_home" in result
# ──────────────────────────────────────────────────────────────────────────────
# inspect_container
# ──────────────────────────────────────────────────────────────────────────────
# Live-captured-style response (homeassistant) — proves that
# details.Mounts[].Source carries the full /volume1/... path while
# profile.volume_bindings[].host_volume_file is share-relative.
INSPECT_RESPONSE = {
"details": {
"Config": {
"Image": "homeassistant/home-assistant:stable",
"Entrypoint": ["/init"],
"Cmd": None,
"Env": ["TZ=Europe/Berlin"],
},
"HostConfig": {
"RestartPolicy": {"Name": "always", "MaximumRetryCount": 0},
"NetworkMode": "frostiq_net",
"Privileged": False,
"CapAdd": None,
"CapDrop": None,
"Binds": ["/volume1/docker/homeassistant:/config:rw"],
},
"Mounts": [
{
"Type": "bind",
"Source": "/volume1/docker/homeassistant",
"Destination": "/config",
"RW": True,
}
],
"NetworkSettings": {
"Networks": {
"frostiq_net": {"IPAddress": "172.18.0.5"},
}
},
"RestartCount": 0,
"State": {"Status": "running", "Running": True},
},
"profile": {
"image": "homeassistant/home-assistant:stable",
"name": "homeassistant",
"enable_restart_policy": True,
"network_mode": "frostiq_net",
"use_host_network": False,
"port_bindings": [{"container_port": 8123, "host_port": 8123, "type": "tcp"}],
# Share-relative — must NOT be the path the tool reports.
"volume_bindings": [
{
"host_volume_file": "/docker/homeassistant",
"mount_point": "/config",
"type": "rw",
"is_directory": True,
}
],
"env_variables": [
{"key": "TZ", "value": "Europe/Berlin"},
{"key": "LANG", "value": "C.UTF-8"},
],
"labels": {"io.hass.type": "core", "io.hass.version": "2026.2.3"},
"links": [],
"cmd": "",
"cmd_v2": "",
"privileged": False,
"CapAdd": None,
"CapDrop": None,
},
}
@pytest.mark.asyncio
async def test_inspect_container_uses_full_host_path():
"""inspect_container must report /volume1/docker/... (full) — not
/docker/... (share-relative) — for volume mounts. The compose-rebuild
workflow depends on the full host path."""
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 {"containers": [{"name": "homeassistant"}]}
if api == "SYNO.Docker.Container" and method == "get":
return INSPECT_RESPONSE
return {}
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["inspect_container"]("homeassistant")
assert "/volume1/docker/homeassistant" in result
# The share-relative shortcut must not appear as a mount source.
assert " /docker/homeassistant " not in result # not as standalone path
assert "→ /config" in result
@pytest.mark.asyncio
async def test_inspect_container_shows_core_fields():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = INSPECT_RESPONSE
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["inspect_container"]("homeassistant")
# Header
assert "Container: homeassistant" in result
assert "homeassistant/home-assistant:stable" in result
assert "running" in result
# Restart policy
assert "always" in result
# Network
assert "frostiq_net" in result
assert "172.18.0.5" in result
# Ports
assert "8123" in result
# Env
assert "TZ=Europe/Berlin" in result
# Labels
assert "io.hass.version=2026.2.3" in result
# Entrypoint
assert "/init" in result
@pytest.mark.asyncio
async def test_inspect_container_calls_get_with_json_name():
"""inspect_container must send name= as a JSON-encoded string (DSM
Container/get is documented to accept both but json.dumps keeps the
convention shared with start/stop/restart/delete)."""
from mcp_synology_container.modules.containers import register_containers
seen: dict[str, object] = {}
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Container" and method == "list":
return {"containers": [{"name": "homeassistant"}]}
if api == "SYNO.Docker.Container" and method == "get":
seen["params"] = kwargs.get("params")
return INSPECT_RESPONSE
return {}
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
await tools["inspect_container"]("homeassistant")
assert seen["params"] == {"name": '"homeassistant"'}
@pytest.mark.asyncio
async def test_inspect_container_resolves_hash_prefix():
"""If DSM stores the container as 'abcdef012345_homeassistant', a user
request for 'homeassistant' must resolve to the prefixed name and the
displayed header must show the clean name."""
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 {"containers": [{"name": "abcdef012345_homeassistant"}]}
if api == "SYNO.Docker.Container" and method == "get":
assert kwargs["params"]["name"] == '"abcdef012345_homeassistant"'
return INSPECT_RESPONSE
return {}
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["inspect_container"]("homeassistant")
assert "Container: homeassistant" in result
assert "abcdef012345" not in result
@pytest.mark.asyncio
async def test_inspect_container_not_found():
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 {"containers": []}
if api == "SYNO.Docker.Container" and method == "get":
return None
return {}
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["inspect_container"]("ghost")
assert "not found" in result
@pytest.mark.asyncio
async def test_inspect_container_api_error():
from mcp_synology_container.dsm_client import SynologyError
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 {"containers": [{"name": "homeassistant"}]}
if api == "SYNO.Docker.Container" and method == "get":
raise SynologyError("API error", code=102)
return {}
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["inspect_container"]("homeassistant")
assert "Error" in result
assert "homeassistant" 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."""