fix: v0.4.3 — inspect_image rendering polish (follow-up to #4)

Three live-NAS observations that the 0.4.2 implementation got wrong:

1. Header showed "?:?" — DSM Image/get returns image="" and tag=""
   for name:tag lookups; the response fields are unreliable. The
   header now echoes the user-supplied image_id, falling back to the
   sha256 id only when image_id itself is empty.

2. Ports printed as raw Python dicts ({'port':'22','protocol':'tcp'}).
   The ports array is a list of {port, protocol} objects — each entry
   now renders as "22/tcp".

3. Environment printed as raw Python dicts ({'key':'PATH','value':...}).
   The env array is a list of {key, value} objects — each entry now
   renders as "PATH=/usr/local/...".

cmd, entrypoint, and volumes (plain string arrays) were already
correct and are untouched. Tests updated to match the live shape;
two new tests guard the header fallback. References #4 (already
closed by 0.4.2 — no need to reopen for a rendering polish).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 13:13:42 +02:00
parent 4030b8d5ee
commit 82e8167f67
5 changed files with 106 additions and 23 deletions
+59 -13
View File
@@ -416,12 +416,17 @@ 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.
# Authoritative DSM SYNO.Docker.Image/get response shape (v1).
# Live-captured peculiarities:
# - `image` and `tag` come back as empty strings when the lookup is
# by name:tag — the header therefore has to fall back to image_id.
# - `ports` is an array of {"port", "protocol"} dicts, NOT strings.
# - `env` is an array of {"key", "value"} dicts, NOT strings.
# - `volumes` and `cmd`/`entrypoint` are plain string arrays.
# - There is no `layers` field on this endpoint.
SAMPLE_INSPECT = {
"image": "nginx",
"tag": "1.24",
"image": "",
"tag": "",
"id": "sha256:aaaa1234567890abcdef",
"digest": "sha256:digestabcdef",
"size": 50 * 1024 * 1024,
@@ -430,8 +435,14 @@ SAMPLE_INSPECT = {
"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"],
"env": [
{"key": "NGINX_VERSION", "value": "1.24"},
{"key": "PATH", "value": "/usr/local/sbin"},
],
"ports": [
{"port": "80", "protocol": "tcp"},
{"port": "443", "protocol": "tcp"},
],
"volumes": ["/var/cache/nginx"],
}
@@ -467,10 +478,12 @@ 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 returned by hash lookup
# Inspect data shaped for redis returned by hash lookup.
# image/tag come back empty (matches live DSM behavior) — display
# falls back to image_id, then to the id hash.
redis_inspect = {
"image": "redis",
"tag": "7",
"image": "",
"tag": "",
"id": "sha256:ccccredis",
"digest": "sha256:rdigest",
"size": 30 * 1024 * 1024,
@@ -478,7 +491,7 @@ async def test_inspect_image_by_hash():
"cmd": ["redis-server"],
"entrypoint": [],
"env": [],
"ports": ["6379/tcp"],
"ports": [{"port": "6379", "protocol": "tcp"}],
"volumes": [],
}
client = _make_inspect_client(inspect_payload=redis_inspect)
@@ -486,7 +499,39 @@ async def test_inspect_image_by_hash():
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="sha256:cccc")
# The header echoes the user-supplied image_id; "redis" surfaces via the cmd line.
assert "sha256:cccc" in result
assert "redis" in result
assert "6379/tcp" in result
@pytest.mark.asyncio
async def test_inspect_image_header_uses_image_id_when_fields_empty():
"""DSM Image/get returns image='' and tag='' for name:tag lookups — the header
must fall back to the user-supplied image_id, NOT render '?:?'."""
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client() # SAMPLE_INSPECT has image='' / tag=''
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="gitea/gitea:1.26.1")
assert "Image: gitea/gitea:1.26.1" in result
assert "?:?" not in result
@pytest.mark.asyncio
async def test_inspect_image_header_falls_back_to_id_when_image_id_empty():
"""If image_id were empty (defensive — should not happen in practice), the
header falls back to the sha256 id field."""
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client() # SAMPLE_INSPECT.id == sha256:aaaa1234567890abcdef
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="")
assert "Image: sha256:aaaa1234567890abcdef" in result
@pytest.mark.asyncio
@@ -605,8 +650,9 @@ async def test_inspect_image_registry_prefixed():
from mcp_synology_container.modules.images import register_images
registry_inspect = {
"image": "ghcr.io/foo/bar",
"tag": "v1",
# image / tag come back empty for name:tag lookups (live DSM behavior).
"image": "",
"tag": "",
"id": "sha256:dddd",
"digest": "sha256:gdigest",
"size": 100 * 1024 * 1024,