fix: v0.4.2 — inspect_image used wrong DSM parameter contract

Live DSM API capture revealed the actual SYNO.Docker.Image/get
contract: a single JSON-encoded parameter named `identity` that
accepts both `name:tag` and `sha256:<hash>` forms. The 0.4.0 code
passed `name` + `tag` + `id` and was rejected by DSM with error 114.

Response shape also corrected — the endpoint returns flat top-level
fields (image, tag, id, digest, size, virtual_size, author,
docker_version, cmd, entrypoint, env, ports, volumes), NOT the
Docker-engine inspect shape with details.Config.* + RootFS.Layers
that the previous implementation assumed. Layer rendering removed;
digest / author / docker_version / volumes are now displayed.

Pre-resolution against list_images is no longer needed — the user
input goes straight into `identity` JSON-encoded.

Closes #4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 13:07:53 +02:00
parent 24b97338ba
commit 4030b8d5ee
5 changed files with 167 additions and 190 deletions
+97 -63
View File
@@ -416,35 +416,31 @@ async def test_delete_image_api_error():
# ──────────────────────────────────────────────────────────────────────────────
# Authoritative DSM SYNO.Docker.Image/get response shape (v1). Top-level
# fields only — there is NO "layers" field on this endpoint, confirmed
# via API capture against the live NAS.
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"],
},
}
"image": "nginx",
"tag": "1.24",
"id": "sha256:aaaa1234567890abcdef",
"digest": "sha256:digestabcdef",
"size": 50 * 1024 * 1024,
"virtual_size": 50 * 1024 * 1024,
"author": "NGINX Team",
"docker_version": "20.10.0",
"cmd": ["nginx", "-g", "daemon off;"],
"entrypoint": ["/docker-entrypoint.sh"],
"env": ["NGINX_VERSION=1.24", "PATH=/usr/local/sbin"],
"ports": ["80/tcp", "443/tcp"],
"volumes": ["/var/cache/nginx"],
}
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."""
def _make_inspect_client(inspect_payload=None):
"""Build a mock DsmClient that returns inspect_payload for SYNO.Docker.Image/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 {}
@@ -471,15 +467,19 @@ async def test_inspect_image_by_name_tag():
async def test_inspect_image_by_hash():
from mcp_synology_container.modules.images import register_images
# Inspect data shaped for redis (sha256:cccc)
# Inspect data shaped for redis returned by hash lookup
redis_inspect = {
"details": {
"Id": "sha256:cccc",
"RepoTags": ["redis:7"],
"Size": 30 * 1024 * 1024,
"Config": {"Env": [], "ExposedPorts": {}, "Cmd": ["redis-server"]},
"RootFS": {"Layers": ["sha256:rlayer1"]},
}
"image": "redis",
"tag": "7",
"id": "sha256:ccccredis",
"digest": "sha256:rdigest",
"size": 30 * 1024 * 1024,
"virtual_size": 30 * 1024 * 1024,
"cmd": ["redis-server"],
"entrypoint": [],
"env": [],
"ports": ["6379/tcp"],
"volumes": [],
}
client = _make_inspect_client(inspect_payload=redis_inspect)
mcp, tools = make_mock_mcp()
@@ -490,13 +490,41 @@ async def test_inspect_image_by_hash():
@pytest.mark.asyncio
async def test_inspect_image_not_found():
async def test_inspect_image_uses_identity_param():
"""DSM SYNO.Docker.Image/get expects parameter 'identity' (JSON-encoded), version=1.
Using name/tag/id (the old shape) returned DSM error 114 — this test guards
against regressing to that contract.
"""
import json
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client()
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
await tools["inspect_image"](image_id="nginx:1.24")
get_calls = [
c for c in client.request.call_args_list if c.args[:2] == ("SYNO.Docker.Image", "get")
]
assert len(get_calls) == 1
call = get_calls[0]
assert call.kwargs.get("version") == 1
params = call.kwargs.get("params") or {}
assert params == {"identity": json.dumps("nginx:1.24")}
@pytest.mark.asyncio
async def test_inspect_image_empty_response_treated_as_not_found():
"""An empty response dict surfaces as a clean 'not found' message."""
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client(inspect_payload={})
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
@@ -528,7 +556,7 @@ async def test_inspect_image_shows_exposed_ports():
@pytest.mark.asyncio
async def test_inspect_image_shows_layers():
async def test_inspect_image_shows_volumes():
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client()
@@ -536,10 +564,7 @@ async def test_inspect_image_shows_layers():
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
assert "/var/cache/nginx" in result
@pytest.mark.asyncio
@@ -557,31 +582,42 @@ async def test_inspect_image_shows_entrypoint_cmd():
@pytest.mark.asyncio
async def test_inspect_image_registry_prefixed():
async def test_inspect_image_shows_digest_author_docker_version():
"""Identity-block fields specific to the DSM Image/get response are surfaced."""
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 "sha256:digestabcdef" in result
assert "NGINX Team" in result
assert "20.10.0" in result
@pytest.mark.asyncio
async def test_inspect_image_registry_prefixed():
"""A registry-prefixed identifier like 'ghcr.io/foo/bar:v1' is passed through verbatim
in the JSON-encoded identity parameter."""
import json
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"]},
}
"image": "ghcr.io/foo/bar",
"tag": "v1",
"id": "sha256:dddd",
"digest": "sha256:gdigest",
"size": 100 * 1024 * 1024,
"virtual_size": 100 * 1024 * 1024,
"cmd": ["/app"],
"entrypoint": [],
"env": [],
"ports": [],
"volumes": [],
}
client = _make_inspect_client(inspect_payload=registry_inspect, images_payload=registry_images)
client = _make_inspect_client(inspect_payload=registry_inspect)
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
@@ -589,14 +625,14 @@ async def test_inspect_image_registry_prefixed():
assert "ghcr.io/foo/bar" in result
assert "v1" in result
# Verify the get call used the full registry-prefixed repository name
# The full identifier (with ':') is JSON-encoded into a single string —
# registry-prefixed names are not split into name + tag.
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"
assert get_calls
params = get_calls[0].kwargs.get("params") or {}
assert params.get("name") == "ghcr.io/foo/bar"
assert params.get("tag") == "v1"
assert params.get("identity") == json.dumps("ghcr.io/foo/bar:v1")
@pytest.mark.asyncio
@@ -607,8 +643,6 @@ async def test_inspect_image_api_error():
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 {}