Fix delete_image: POST with images JSON array (DevTools-confirmed format)

DSM Container Manager rejects name+tag and sha256 id params (error 114).
Browser DevTools capture shows the correct call is:

  POST SYNO.Docker.Image / delete / version=1
  images=[{"repository":"nouchka/sqlite3","tags":["latest"]}]

Changes:
- Add DsmClient.post_request() for form-encoded POST requests
- delete_image now calls post_request with version=1 and the images
  JSON array built from the resolved repository name and tag
- Remove unused docker error-code dict from _error_message()
- Tests mock post_request and assert the images param content

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 18:19:22 +02:00
parent 2b1e2ead7d
commit 5edd051830
3 changed files with 120 additions and 35 deletions
+76 -8
View File
@@ -61,12 +61,6 @@ def _error_message(code: int, api: str = "") -> str:
407: "Too many failed login attempts — account temporarily locked",
408: "IP blocked due to excessive failed attempts",
}
# Docker API codes
docker = {
1: "Project not found",
2: "Container not found",
}
if "Auth" in api and code in auth:
return auth[code]
if code in common:
@@ -269,7 +263,9 @@ class DsmClient:
# Log with sensitive fields masked
log_params = {k: ("***" if k in _SENSITIVE_PARAMS else v) for k, v in req_params.items()}
retry_tag = " (retry)" if _is_retry else ""
logger.debug("DSM GET%s: %s/%s v%d%s", retry_tag, api, method, resolved_version, log_params)
logger.debug(
"DSM GET%s: %s/%s v%d%s", retry_tag, api, method, resolved_version, log_params
)
resp = await http.get(url, params=req_params)
resp.raise_for_status()
@@ -296,6 +292,72 @@ class DsmClient:
raise SynologyError(_error_message(code, api), code=code)
async def post_request(
self,
api: str,
method: str,
version: int | None = None,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Make a POST (form-encoded) request to the DSM API.
Identical semantics to request(), but sends params as a form body
instead of query-string — required by some Container Manager endpoints
(e.g. SYNO.Docker.Image/delete).
Args:
api: DSM API name (e.g. "SYNO.Docker.Image").
method: API method (e.g. "delete").
version: API version. Defaults to maxVersion from API info.
params: Additional form fields.
Returns:
Response data dict from the "data" field of the envelope.
Raises:
SynologyError: On API errors.
"""
sys.stderr.write(f"[dsm] post_request: {api}/{method}\n")
sys.stderr.flush()
await self._ensure_initialized()
http = self._get_http()
if api not in self._api_cache:
raise SynologyError(
f"API '{api}' not found. Call query_api_info() first.",
code=102,
)
info = self._api_cache[api]
resolved_version = version if version is not None else info["maxVersion"]
url = f"{self._base_url}/webapi/{info['path']}"
form: dict[str, Any] = {
"api": api,
"version": str(resolved_version),
"method": method,
}
if params:
form.update(params)
query_params: dict[str, str] = {}
if self._sid:
query_params["_sid"] = self._sid
log_form = {k: ("***" if k in _SENSITIVE_PARAMS else v) for k, v in form.items()}
logger.debug("DSM POST: %s/%s v%d%s", api, method, resolved_version, log_form)
resp = await http.post(url, params=query_params, data=form)
resp.raise_for_status()
body = resp.json()
if body.get("success"):
return body.get("data") or {}
code = body.get("error", {}).get("code", 0)
logger.debug("DSM POST response: %s/%s — error code %d", api, method, code)
raise SynologyError(_error_message(code, api), code=code)
async def upload_text(
self,
dest_folder: str,
@@ -340,7 +402,13 @@ class DsmClient:
if self._sid:
query_params["_sid"] = self._sid
logger.debug("DSM POST: %s/upload v%d — path=%s filename=%s", api, resolved_version, dest_folder, filename)
logger.debug(
"DSM POST: %s/upload v%d — path=%s filename=%s",
api,
resolved_version,
dest_folder,
filename,
)
encoded = content.encode("utf-8")
resp = await http.post(
+13 -7
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import logging
import sys
from datetime import UTC, datetime
@@ -243,17 +244,22 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
f"Call delete_image(image_id={image_id!r}, confirmed=True) to confirm."
)
# DSM requires the sha256 image ID for deletion, not name+tag.
if not img_hash:
return f"Cannot delete '{display_name}': image ID (sha256) not found in list."
sys.stderr.write(f"[delete_image] api=SYNO.Docker.Image method=delete id={img_hash!r}\n")
# DSM Container Manager expects a POST with version=1 and an
# "images" JSON array — confirmed via browser DevTools capture.
# Format: images=[{"repository": "nginx", "tags": ["1.24"]}]
delete_repo = repo
delete_tag = img_tags[0] if img_tags else tag
images_param = json.dumps([{"repository": delete_repo, "tags": [delete_tag]}])
sys.stderr.write(
f"[delete_image] POST SYNO.Docker.Image/delete v1 images={images_param!r}\n"
)
sys.stderr.flush()
try:
await client.request(
await client.post_request(
"SYNO.Docker.Image",
"delete",
params={"id": img_hash},
version=1,
params={"images": images_param},
)
except Exception as e:
code = getattr(e, "code", "?")