feat: v0.5.0 — welle B Teil 1 (registry tools: search, tags, pull)
Three new SYNO.Docker.Registry tools, reverse-engineered from a live DSM API capture (n4s4 reference disagrees on param names and methods). - search_registry (#5): SYNO.Docker.Registry/search v1 with JSON-encoded q, plus offset/limit/page_size. Renders stars, downloads, official flag, truncated description, and total match count. Read-only. - list_image_tags: SYNO.Docker.Registry/tags v1 with JSON-encoded repo (not name — DSM live capture diverges from n4s4). Response shape is unusual: tag list comes back as the envelope's data field directly. Output capped by limit (default 50); accepts both list and dict response shapes defensively. Read-only. - pull_image (#3): SYNO.Docker.Registry/pull_start v1 with both repository and tag JSON-encoded. Async pull — no pull_status method confirmed on this DSM, so completion is detected by polling SYNO.Docker.Image/list (2–10 s backoff, 240 s budget under the Claude Desktop ~4 min tool-call ceiling). Timeout returns a non-fatal "still running" hint. Short-circuits when the image is already present locally. Confirmation gate required. Tool count: 31 → 34. CLAUDE.md confirmation list updated. New DSM quirks documented for pull_start (no pull_status) and tags (repo param name, top-level data array). Closes #3 Closes #5 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+34
-1
@@ -2,7 +2,40 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.4.3] - 2026-05-18
|
||||
## [0.5.0] - 2026-05-18
|
||||
|
||||
### Added
|
||||
|
||||
**Welle B Teil 1 — Registry tools (#3, #5).** Three new `SYNO.Docker.Registry`
|
||||
tools, reverse-engineered from a live DSM API capture (the n4s4 reference
|
||||
disagrees on parameter names and methods; the live capture wins):
|
||||
|
||||
- `search_registry` (#5) — search the active Docker registry by query string.
|
||||
Uses `SYNO.Docker.Registry/search` (version 1) with the query JSON-encoded
|
||||
as `q`, plus `offset`, `limit`, and `page_size`. Renders stars, downloads,
|
||||
the official-image flag, and a truncated description per hit, and shows
|
||||
the total match count so the caller knows when to raise `limit`. No
|
||||
confirmation gate (read-only).
|
||||
- `list_image_tags` — list available tags for a repository (bonus tool).
|
||||
Uses `SYNO.Docker.Registry/tags` (version 1) with the repository
|
||||
JSON-encoded as `repo` — note the parameter name diverges from the n4s4
|
||||
reference which uses `name`. The response shape is unusual: DSM returns
|
||||
the tag list as the envelope's `data` field directly (not wrapped in a
|
||||
sub-key), so the tool accepts both shapes defensively. Output is capped
|
||||
by `limit` (default 50) because popular images like `alpine` ship 200+
|
||||
tags. No confirmation gate (read-only).
|
||||
- `pull_image` (#3) — pull an image into the local cache via
|
||||
`SYNO.Docker.Registry/pull_start` (version 1) with both `repository` and
|
||||
`tag` JSON-encoded. Requires `confirmed=True`. DSM exposes no confirmed
|
||||
`pull_status` method, so completion is detected by polling
|
||||
`SYNO.Docker.Image/list` for the new `repository:tag` pair with a 2–10 s
|
||||
backoff schedule and a 240 s overall budget (kept under the Claude
|
||||
Desktop ~4 min tool-call ceiling). A timeout returns a non-fatal "still
|
||||
running" hint pointing at `list_images` instead of raising — DSM keeps
|
||||
pulling server-side regardless. Short-circuits when the image is already
|
||||
present locally so a repeated call is cheap. Closes #3 and #5.
|
||||
|
||||
Tool count rises from 31 to 34.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ Only a second consecutive failure is treated as a real auth problem.
|
||||
|
||||
---
|
||||
|
||||
## Implemented tools (31)
|
||||
## Implemented tools (34)
|
||||
|
||||
| Category | Tools |
|
||||
|---|---|
|
||||
@@ -41,6 +41,7 @@ Only a second consecutive failure is treated as a real auth problem.
|
||||
| Containers | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats`, `delete_container`, `start_container`, `stop_container`, `restart_container` |
|
||||
| Compose | `read_compose`, `update_compose`, `update_image_tag`, `update_env_var` |
|
||||
| Images | `check_image_updates`, `list_images`, `delete_image`, `inspect_image` |
|
||||
| Registry | `search_registry`, `list_image_tags`, `pull_image` |
|
||||
| Networks | `list_networks`, `create_network`, `delete_network` |
|
||||
| System | `system_df`, `system_prune`, `system_overview` |
|
||||
|
||||
@@ -58,7 +59,17 @@ Only a second consecutive failure is treated as a real auth problem.
|
||||
- **Image delete** — requires a form-encoded POST with a JSON `images` array
|
||||
(confirmed via browser DevTools); uses `DsmClient.post_request()`.
|
||||
- **`SYNO.Docker.Image/pull`** — API method exists but behaviour varies by
|
||||
DSM version; not exposed as a standalone tool.
|
||||
DSM version; not exposed as a standalone tool. `pull_image` uses
|
||||
`SYNO.Docker.Registry/pull_start` instead (see below).
|
||||
- **`SYNO.Docker.Registry/pull_start`** — asynchronous pull entry point;
|
||||
no matching `pull_status` method confirmed. `pull_image` polls
|
||||
`SYNO.Docker.Image/list` until `repository:tag` appears (2–10 s backoff,
|
||||
240 s budget) and returns a "still running" hint on timeout instead of
|
||||
raising — DSM keeps pulling server-side regardless of the HTTP response.
|
||||
- **`SYNO.Docker.Registry/tags`** — uses `repo` (JSON-encoded) as the
|
||||
parameter name; the n4s4 reference's `name` does not work on this DSM
|
||||
version. Returns the tag list as the envelope's `data` field directly,
|
||||
not wrapped in a sub-key.
|
||||
- **`SYNO.Docker.Volume`** — endpoint does not exist; volume management is
|
||||
not available via the DSM WebAPI.
|
||||
- **`SYNO.Docker.Registry/get`** — does not behave as documented; registry
|
||||
@@ -77,7 +88,7 @@ Only a second consecutive failure is treated as a real auth problem.
|
||||
`redeploy_project`, `create_project`, `delete_project`,
|
||||
`exec_in_container`, `update_image_tag`, `update_env_var`,
|
||||
`update_compose`, `delete_container`, `stop_container`,
|
||||
`restart_container`
|
||||
`restart_container`, `pull_image`
|
||||
- After compose changes: suggest `redeploy_project`
|
||||
- DSM errors → human-readable message, no stack traces
|
||||
- No secrets in stderr output
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mcp-synology-container"
|
||||
version = "0.4.3"
|
||||
version = "0.5.0"
|
||||
description = "MCP server for Synology Container Manager"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""MCP tools for SYNO.Docker.Registry: search, tags, pull."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from mcp_synology_container.config import AppConfig
|
||||
from mcp_synology_container.dsm_client import DsmClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pull polling: backoff schedule capped at 10 s between checks. Total budget
|
||||
# stays under the Claude Desktop ~4 min tool-call ceiling.
|
||||
_PULL_POLL_TIMEOUT = 240.0
|
||||
_PULL_POLL_INTERVALS: tuple[float, ...] = (2.0, 3.0, 5.0, 8.0, 10.0)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int = 80) -> str:
|
||||
"""Truncate text to limit with an ellipsis."""
|
||||
text = (text or "").replace("\n", " ").replace("\r", " ").strip()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
async def _image_present(client: DsmClient, repository: str, tag: str) -> bool:
|
||||
"""Return True if repository:tag is in the local image list."""
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Pull polling: image list failed: %s", e)
|
||||
return False
|
||||
|
||||
images: list[dict[str, Any]] = data.get("images", []) if isinstance(data, dict) else []
|
||||
for img in images:
|
||||
repo = img.get("repository", "")
|
||||
tags = img.get("tags") or []
|
||||
if repo == repository and tag in tags:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def register_registry(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
"""Register all registry management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def search_registry(query: str, limit: int = 20):
|
||||
"""Search the active Docker registry for images matching query."""
|
||||
if limit < 1:
|
||||
limit = 1
|
||||
elif limit > 100:
|
||||
limit = 100
|
||||
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Registry",
|
||||
"search",
|
||||
version=1,
|
||||
params={
|
||||
"q": json.dumps(query),
|
||||
"offset": "0",
|
||||
"limit": str(limit),
|
||||
"page_size": str(limit),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error searching registry for '{query}': {e}"
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
if isinstance(data, dict):
|
||||
results = data.get("data", []) or []
|
||||
total = int(data.get("total", 0) or 0)
|
||||
|
||||
if not results:
|
||||
return f"No results found for '{query}'."
|
||||
|
||||
lines = [f"Search results for '{query}' ({len(results)} of {total} total):", ""]
|
||||
lines.append(" Stars Downloads Name (official) Description")
|
||||
for hit in results:
|
||||
name = hit.get("name", "?")
|
||||
description = _truncate(hit.get("description", ""))
|
||||
stars = int(hit.get("star_count", 0) or 0)
|
||||
downloads = int(hit.get("downloads", 0) or 0)
|
||||
official = " [official]" if hit.get("is_official") else ""
|
||||
lines.append(f" {stars:>5} {downloads:>9} {name}{official}")
|
||||
if description:
|
||||
lines.append(f" {description}")
|
||||
|
||||
if total > len(results):
|
||||
lines.append("")
|
||||
lines.append(f"Showing {len(results)} of {total}; raise limit to see more.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def list_image_tags(repository: str, limit: int = 50):
|
||||
"""List available tags for a registry image (e.g. 'nginx', 'grafana/grafana')."""
|
||||
if limit < 1:
|
||||
limit = 1
|
||||
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Registry",
|
||||
"tags",
|
||||
version=1,
|
||||
params={"repo": json.dumps(repository)},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error fetching tags for '{repository}': {e}"
|
||||
|
||||
# DSM returns the tag list as the envelope's `data` field directly.
|
||||
# When empty, DsmClient.request() coerces to {} via `or {}`, so we
|
||||
# accept both shapes here.
|
||||
if isinstance(data, list):
|
||||
entries: list[Any] = data
|
||||
elif isinstance(data, dict):
|
||||
# Defensive: some DSM versions wrap as {"tags": [...]}.
|
||||
entries = data.get("tags") or data.get("data") or []
|
||||
else:
|
||||
entries = []
|
||||
|
||||
tags: list[str] = []
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict):
|
||||
tag = entry.get("tag")
|
||||
if isinstance(tag, str) and tag:
|
||||
tags.append(tag)
|
||||
elif isinstance(entry, str):
|
||||
tags.append(entry)
|
||||
|
||||
if not tags:
|
||||
return f"No tags found for '{repository}'."
|
||||
|
||||
total = len(tags)
|
||||
shown = tags[:limit]
|
||||
lines = [f"Tags for '{repository}' ({total} total):", ""]
|
||||
lines.extend(f" {t}" for t in shown)
|
||||
|
||||
if total > limit:
|
||||
lines.append("")
|
||||
lines.append(f"Showing first {limit} of {total}; raise limit to see more.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def pull_image(repository: str, tag: str = "latest", confirmed: bool = False):
|
||||
"""Pull image from the active registry. Requires confirmed=True; polls for completion."""
|
||||
target = f"{repository}:{tag}"
|
||||
|
||||
if not confirmed:
|
||||
return (
|
||||
f"Preview: would pull {target} from the active registry.\n"
|
||||
f"Call pull_image(repository={repository!r}, tag={tag!r}, "
|
||||
"confirmed=True) to proceed."
|
||||
)
|
||||
|
||||
# Short-circuit: if the image already exists locally, no pull needed.
|
||||
if await _image_present(client, repository, tag):
|
||||
return f"{target} is already present locally — nothing to pull."
|
||||
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Registry",
|
||||
"pull_start",
|
||||
version=1,
|
||||
params={
|
||||
"repository": json.dumps(repository),
|
||||
"tag": json.dumps(tag),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error starting pull for '{target}': {e}"
|
||||
|
||||
# Async pull — DSM exposes no confirmed pull_status method, so we poll
|
||||
# Image/list for the new tag. Backoff schedule capped at 10 s; total
|
||||
# budget under the Claude Desktop ~4 min tool-call ceiling.
|
||||
loop = asyncio.get_event_loop()
|
||||
deadline = loop.time() + _PULL_POLL_TIMEOUT
|
||||
attempt = 0
|
||||
while loop.time() < deadline:
|
||||
interval = _PULL_POLL_INTERVALS[min(attempt, len(_PULL_POLL_INTERVALS) - 1)]
|
||||
remaining = deadline - loop.time()
|
||||
await asyncio.sleep(min(interval, max(remaining, 0.0)))
|
||||
if await _image_present(client, repository, tag):
|
||||
return f"Pulled {target} successfully."
|
||||
attempt += 1
|
||||
|
||||
return (
|
||||
f"Pull of {target} started, still running — "
|
||||
f"verify later with list_images or check_image_updates."
|
||||
)
|
||||
@@ -31,6 +31,7 @@ def create_server(config: AppConfig, client: DsmClient) -> FastMCP:
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
register_projects(mcp, config, client)
|
||||
@@ -39,6 +40,7 @@ def create_server(config: AppConfig, client: DsmClient) -> FastMCP:
|
||||
register_images(mcp, config, client)
|
||||
register_system(mcp, config, client)
|
||||
register_networks(mcp, config, client)
|
||||
register_registry(mcp, config, client)
|
||||
|
||||
logger.info("MCP server configured with all tool modules")
|
||||
return mcp
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Tests for modules/registry.py."""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def make_mock_mcp():
|
||||
tools: dict = {}
|
||||
|
||||
class MockMCP:
|
||||
def tool(self):
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
return MockMCP(), tools
|
||||
|
||||
|
||||
def make_config():
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
|
||||
return AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# search_registry — Issue #5
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
SEARCH_RESPONSE = {
|
||||
"data": [
|
||||
{
|
||||
"name": "caddy",
|
||||
"description": (
|
||||
"Caddy 2 is a powerful, enterprise-ready, open source web "
|
||||
"server with automatic HTTPS written in Go."
|
||||
),
|
||||
"downloads": 650989718,
|
||||
"is_automated": False,
|
||||
"is_official": True,
|
||||
"star_count": 881,
|
||||
"registry": "https://registry.hub.docker.com",
|
||||
},
|
||||
{
|
||||
"name": "abiosoft/caddy",
|
||||
"description": "Caddy is a lightweight, general-purpose web server.",
|
||||
"downloads": 111974910,
|
||||
"is_automated": True,
|
||||
"is_official": False,
|
||||
"star_count": 289,
|
||||
"registry": "https://registry.hub.docker.com",
|
||||
},
|
||||
],
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"page_size": 20,
|
||||
"total": 6647,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_registry_renders_hits():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SEARCH_RESPONSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["search_registry"](query="caddy")
|
||||
|
||||
assert "caddy" in result
|
||||
assert "abiosoft/caddy" in result
|
||||
assert "[official]" in result
|
||||
assert "881" in result # stars
|
||||
assert "650989718" in result # downloads
|
||||
assert "2 of 6647" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_registry_uses_json_encoded_query():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SEARCH_RESPONSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
await tools["search_registry"](query="nginx", limit=10)
|
||||
|
||||
call = client.request.call_args
|
||||
assert call.args[0] == "SYNO.Docker.Registry"
|
||||
assert call.args[1] == "search"
|
||||
assert call.kwargs.get("version") == 1
|
||||
params = call.kwargs.get("params", {})
|
||||
assert json.loads(params["q"]) == "nginx"
|
||||
assert params["offset"] == "0"
|
||||
assert params["limit"] == "10"
|
||||
assert params["page_size"] == "10"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_registry_empty():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {"data": [], "total": 0}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["search_registry"](query="does-not-exist")
|
||||
assert "No results" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_registry_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = SynologyError("Auth failure", code=119)
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["search_registry"](query="caddy")
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# list_image_tags — bonus
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_image_tags_array_response():
|
||||
"""DSM returns the envelope's data field directly as a list."""
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = [
|
||||
{"tag": "latest"},
|
||||
{"tag": "3.20"},
|
||||
{"tag": "3.19"},
|
||||
]
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_image_tags"](repository="alpine")
|
||||
|
||||
assert "alpine" in result
|
||||
assert "latest" in result
|
||||
assert "3.20" in result
|
||||
assert "3.19" in result
|
||||
assert "3 total" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_image_tags_uses_json_repo_param():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = [{"tag": "latest"}]
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
await tools["list_image_tags"](repository="grafana/grafana")
|
||||
|
||||
call = client.request.call_args
|
||||
assert call.args[0] == "SYNO.Docker.Registry"
|
||||
assert call.args[1] == "tags"
|
||||
assert call.kwargs.get("version") == 1
|
||||
params = call.kwargs.get("params", {})
|
||||
assert json.loads(params["repo"]) == "grafana/grafana"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_image_tags_limit_truncates():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
tags = [{"tag": f"v{i}"} for i in range(120)]
|
||||
client = AsyncMock()
|
||||
client.request.return_value = tags
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_image_tags"](repository="alpine", limit=10)
|
||||
assert "120 total" in result
|
||||
assert "Showing first 10 of 120" in result
|
||||
# Tag #10 must not appear (only v0..v9)
|
||||
assert " v9" in result
|
||||
assert " v10" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_image_tags_empty():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
# Empty list → DsmClient.request coerces to {} via `or {}`
|
||||
client.request.return_value = {}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_image_tags"](repository="nonexistent/image")
|
||||
assert "No tags found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_image_tags_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = SynologyError("Boom", code=100)
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_image_tags"](repository="alpine")
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# pull_image — Issue #3
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_preview():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24")
|
||||
assert "Preview" in result
|
||||
assert "nginx:1.24" in result
|
||||
assert "confirmed=True" in result
|
||||
client.request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_already_present():
|
||||
"""If image is already in Image/list, no pull_start call is made."""
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return {
|
||||
"images": [
|
||||
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
|
||||
]
|
||||
}
|
||||
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||
assert "already present" in result
|
||||
|
||||
# Only the Image/list pre-check was called; pull_start must NOT fire.
|
||||
calls = client.request.call_args_list
|
||||
assert all(c.args[1] != "pull_start" for c in calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_confirmed_success(monkeypatch):
|
||||
"""pull_start succeeds, then Image/list shows the new tag on first poll."""
|
||||
import mcp_synology_container.modules.registry as registry_mod
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
# Make polling instant
|
||||
async def fake_sleep(_):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
|
||||
|
||||
state = {"pulled": False}
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
if state["pulled"]:
|
||||
return {
|
||||
"images": [
|
||||
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
|
||||
]
|
||||
}
|
||||
return {"images": []}
|
||||
if api == "SYNO.Docker.Registry" and method == "pull_start":
|
||||
state["pulled"] = True
|
||||
return {}
|
||||
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||
assert "Pulled" in result
|
||||
assert "nginx:1.24" in result
|
||||
|
||||
# Verify pull_start was invoked with JSON-encoded params
|
||||
pull_calls = [c for c in client.request.call_args_list if c.args[1] == "pull_start"]
|
||||
assert len(pull_calls) == 1
|
||||
params = pull_calls[0].kwargs.get("params", {})
|
||||
assert json.loads(params["repository"]) == "nginx"
|
||||
assert json.loads(params["tag"]) == "1.24"
|
||||
assert pull_calls[0].kwargs.get("version") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_timeout(monkeypatch):
|
||||
"""If the image never appears, the tool returns a still-running hint."""
|
||||
import mcp_synology_container.modules.registry as registry_mod
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
# Make polling instant
|
||||
async def fake_sleep(_):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
|
||||
# Shrink the budget so the loop exits quickly
|
||||
monkeypatch.setattr(registry_mod, "_PULL_POLL_TIMEOUT", 0.05)
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return {"images": []}
|
||||
if api == "SYNO.Docker.Registry" and method == "pull_start":
|
||||
return {}
|
||||
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||
assert "still running" in result
|
||||
assert "nginx:1.24" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_start_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return {"images": []}
|
||||
if api == "SYNO.Docker.Registry" and method == "pull_start":
|
||||
raise SynologyError("Permission denied", code=105)
|
||||
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||
assert "Error starting pull" in result
|
||||
Reference in New Issue
Block a user