Add pull_image + list_registries; remove Gruppe 5 (no Volume API)

- pull_image: SYNO.Docker.Image/pull with repository+tag split via
  rpartition; polls image list every 3 s until image appears, 120 s timeout
- list_registries: SYNO.Docker.Registry/get; shows name, URL, active marker
- Gruppe 5 (Volumes) removed from roadmap — SYNO.Docker.Volume does not exist
- CLAUDE.md: tool count 17 → 19, Volumes section removed
- 28 tests all passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 19:28:45 +02:00
parent 59f7fc1d6c
commit 5fe8f5bc73
6 changed files with 421 additions and 46 deletions
+138 -1
View File
@@ -1,6 +1,6 @@
"""Tests for modules/images.py."""
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
import pytest
@@ -491,3 +491,140 @@ async def test_check_image_updates_for_project():
result = await tools["check_image_updates"](project_name="myapp")
assert "myapp" in result
assert "nginx:1.24" in result
# ──────────────────────────────────────────────────────────────────────────────
# pull_image
# ──────────────────────────────────────────────────────────────────────────────
PULLED_IMAGE = {
"id": "sha256:dddd",
"repository": "postgres",
"tags": ["17.8"],
"size": 80 * 1024 * 1024,
"created": 1700000000,
"upgradable": False,
}
@pytest.mark.asyncio
async def test_pull_image_success():
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 == "pull":
return {}
if api == "SYNO.Docker.Image" and method == "list":
return {"images": [PULLED_IMAGE]}
return {}
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
with patch("asyncio.sleep", new=AsyncMock()):
result = await tools["pull_image"]("postgres:17.8")
assert "Pulled postgres:17.8" in result
assert "MiB" in result
@pytest.mark.asyncio
async def test_pull_image_no_tag_defaults_to_latest():
from mcp_synology_container.modules.images import register_images
client = AsyncMock()
found_image = {**PULLED_IMAGE, "repository": "nginx", "tags": ["latest"]}
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "pull":
# Verify tag defaults to "latest"
assert kwargs.get("params", {}).get("tag") == "latest"
return {}
if api == "SYNO.Docker.Image" and method == "list":
return {"images": [found_image]}
return {}
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
with patch("asyncio.sleep", new=AsyncMock()):
result = await tools["pull_image"]("nginx")
assert "Pulled nginx:latest" in result
@pytest.mark.asyncio
async def test_pull_image_registry_prefixed():
"""Registry-prefixed images (e.g. ghcr.io/foo/bar:v1) split at last ':'."""
from mcp_synology_container.modules.images import register_images
client = AsyncMock()
found_image = {
**PULLED_IMAGE,
"repository": "ghcr.io/open-webui/open-webui",
"tags": ["v0.9.0"],
}
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "pull":
params = kwargs.get("params", {})
assert params["repository"] == "ghcr.io/open-webui/open-webui"
assert params["tag"] == "v0.9.0"
return {}
if api == "SYNO.Docker.Image" and method == "list":
return {"images": [found_image]}
return {}
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
with patch("asyncio.sleep", new=AsyncMock()):
result = await tools["pull_image"]("ghcr.io/open-webui/open-webui:v0.9.0")
assert "Pulled ghcr.io/open-webui/open-webui:v0.9.0" in result
@pytest.mark.asyncio
async def test_pull_image_timeout():
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 == "pull":
return {}
# image never appears in list
return {"images": []}
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
with patch("asyncio.sleep", new=AsyncMock()):
result = await tools["pull_image"]("nonexistent:latest")
assert "did not complete" in result
assert "120" in result
@pytest.mark.asyncio
async def test_pull_image_api_error():
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.images import register_images
client = AsyncMock()
client.request.side_effect = SynologyError("pull failed", code=400)
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
with patch("asyncio.sleep", new=AsyncMock()):
result = await tools["pull_image"]("bad/image:tag")
assert "Error" in result
assert "bad/image:tag" in result