diff --git a/CHANGELOG.md b/CHANGELOG.md index 331168a..96c1dfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to this project will be documented in this file. +## [0.2.5] - 2026-04-21 + +### Changed + +- `redeploy_project`: Replaced the delete-before-start image workaround with + `SYNO.Docker.Project/build_stream` — the programmatic equivalent of the DSM + "Erstellen" (Build) button, confirmed via browser DevTools capture. + New 3-step flow for all project states: + 1. Stop (skipped for STOPPED; error-suppressed for BUILD_FAILED) + 2. `build_stream` — DSM pulls updated images and starts the project via SSE + 3. Poll for RUNNING (timeout raised from 30 s to 5 min to accommodate image pulls) + `build_stream` errors are now fatal (abort the redeploy with a clear message). + +### Added + +- `DsmClient.trigger_build_stream(project_id)` — fires a streaming GET to + `SYNO.Docker.Project/build_stream`, reads the first SSE chunk to confirm DSM + accepted the request, then closes the connection. The build continues + server-side. Handles immediate JSON error responses; swallows `ReadTimeout` + (stream still open = build running). + ## [0.2.4] - 2026-04-21 ### Changed diff --git a/pyproject.toml b/pyproject.toml index dda84c1..6810846 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-synology-container" -version = "0.2.4" +version = "0.2.5" description = "MCP server for Synology Container Manager" requires-python = ">=3.12" dependencies = [ diff --git a/src/mcp_synology_container/dsm_client.py b/src/mcp_synology_container/dsm_client.py index 1ccd47b..aa0525b 100644 --- a/src/mcp_synology_container/dsm_client.py +++ b/src/mcp_synology_container/dsm_client.py @@ -11,6 +11,7 @@ Thin async client wrapping Synology DSM Web API conventions: from __future__ import annotations import asyncio +import json as _json import logging import sys from typing import TYPE_CHECKING, Any @@ -358,6 +359,72 @@ class DsmClient: logger.debug("DSM POST response: %s/%s — error code %d", api, method, code) raise SynologyError(_error_message(code, api), code=code) + async def trigger_build_stream(self, project_id: str) -> None: + """Trigger SYNO.Docker.Project/build_stream — the "Erstellen" button equivalent. + + This is the proper way to force an image pull and project restart in DSM + Container Manager (confirmed via browser DevTools). The endpoint is a + Server-Sent Events (SSE) stream that reports build/pull progress; we read + enough of it to confirm DSM accepted the request, then close the HTTP + connection. The build (image pull + container start) continues server-side + regardless of whether the connection stays open. Callers should poll + SYNO.Docker.Project/list for the resulting RUNNING status. + + Args: + project_id: Project UUID from SYNO.Docker.Project/list. + + Raises: + SynologyError: If DSM returns an immediate JSON error response. + httpx.HTTPStatusError: If the HTTP request itself fails. + """ + await self._ensure_initialized() + http = self._get_http() + api = "SYNO.Docker.Project" + + 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] + url = f"{self._base_url}/webapi/{info['path']}" + params: dict[str, Any] = { + "api": api, + "version": "1", + "method": "build_stream", + "id": project_id, + } + if self._sid: + params["_sid"] = self._sid + + sys.stderr.write(f"[dsm] trigger_build_stream: project={project_id}\n") + sys.stderr.flush() + logger.debug("build_stream: project_id=%s", project_id) + + # Short read timeout so we return quickly once DSM starts streaming. + # The build continues server-side after this connection closes. + try: + async with http.stream( + "GET", + url, + params=params, + timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=5.0), + ) as resp: + resp.raise_for_status() + content_type = resp.headers.get("content-type", "") + if "application/json" in content_type: + # Immediate JSON error (e.g. bad project id, permission denied) + raw = await resp.aread() + data = _json.loads(raw) + if not data.get("success"): + code = data.get("error", {}).get("code", 0) + raise SynologyError(_error_message(code, api), code=code) + return + # SSE stream: read until first event chunk to confirm DSM started. + async for _chunk in resp.aiter_bytes(): + break # Got first byte — build is underway on the NAS + except httpx.ReadTimeout: + # Stream is still open on DSM side; the build is running. Expected. + pass + async def upload_text( self, dest_folder: str, diff --git a/src/mcp_synology_container/modules/projects.py b/src/mcp_synology_container/modules/projects.py index f470b22..48bddee 100644 --- a/src/mcp_synology_container/modules/projects.py +++ b/src/mcp_synology_container/modules/projects.py @@ -4,13 +4,9 @@ from __future__ import annotations import asyncio import contextlib -import json import logging -import re from typing import TYPE_CHECKING, Any -import yaml - if TYPE_CHECKING: from mcp.server.fastmcp import FastMCP @@ -20,16 +16,8 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) _POLL_INTERVAL = 2 # seconds between status checks -_POLL_TIMEOUT = 30 # maximum seconds to wait for RUNNING - -# Compose file names probed in priority order (mirrors compose.py) -_COMPOSE_FILENAMES = [ - "docker-compose.yml", - "docker-compose.yaml", - "compose.yml", - "compose.yaml", -] -_VOLUME_PREFIX_RE = re.compile(r"^/volume\d+") +_POLL_TIMEOUT = 30 # seconds for ordinary start polling +_BUILD_POLL_TIMEOUT = 300 # seconds for build_stream polling (image pull can be slow) def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None: @@ -134,18 +122,17 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non @mcp.tool() async def redeploy_project(project_name: str, confirmed: bool = False) -> str: - """Redeploy a project, forcing a fresh image pull via delete-before-start. + """Redeploy a project, pulling the latest images via SYNO.Docker.Project/build_stream. - Unified 4-step flow for all project states: + This is the programmatic equivalent of the DSM "Erstellen" (Build) button. + Unified 3-step flow for all project states: Step 1 — Stop (skipped for STOPPED; error-suppressed for BUILD_FAILED) - Step 2 — Delete cached images from compose.yaml so that DSM/Docker - pulls the latest version when the project starts. - Image deletion is non-fatal: if it fails the project still - starts (possibly using the cached image). - Step 3 — Start project (DSM auto-pulls missing images) - Step 4 — Poll SYNO.Docker.Project/list every 2 s for up to 30 s - until the project reaches RUNNING. A warning is emitted on + Step 2 — Trigger build_stream: DSM pulls updated images and starts the + project. This is a Server-Sent Events endpoint; the call + returns once DSM confirms it accepted the request. + Step 3 — Poll SYNO.Docker.Project/list every 2 s for up to 5 min + until the project reaches RUNNING. A warning is emitted on timeout instead of returning an error. Requires confirmation before executing. @@ -157,7 +144,7 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non if not confirmed: return ( f"Redeploying project '{project_name}' will stop and restart all its " - f"containers, deleting cached images to force a fresh pull.\n\n" + f"containers, pulling the latest images.\n\n" f"Call this tool again with confirmed=True to proceed." ) @@ -176,49 +163,43 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non results: list[str] = [] - # Read compose images before stopping (best-effort; empty list on failure) - images_to_delete = await _read_compose_images_for_project(client, config, project_name) - try: # ── Step 1: Stop ────────────────────────────────────────────────── if status == "STOPPED": - results.append("Step 1/4: Project is STOPPED — skipping stop.") + results.append("Step 1/3: Project is STOPPED — skipping stop.") elif status in ("BUILD_FAILED", ""): - results.append("Step 1/4: Stopping failed build...") + results.append("Step 1/3: Stopping failed build...") with contextlib.suppress(Exception): await client.request("SYNO.Docker.Project", "stop", params={"id": project_id}) results.append(" Stopped.") else: # RUNNING - results.append("Step 1/4: Stopping project...") + results.append("Step 1/3: Stopping project...") await client.request("SYNO.Docker.Project", "stop", params={"id": project_id}) results.append(" Stopped.") - # ── Step 2: Delete cached images ────────────────────────────────── - results.append("Step 2/4: Removing cached images to force re-pull...") - if images_to_delete: - for img_ref in images_to_delete: - results.append(f" {img_ref}:") - await _try_delete_image(client, img_ref, results) - else: - results.append(" Compose file not readable — skipping image removal.") + # ── Step 2: build_stream (pull images + start) ──────────────────── + results.append("Step 2/3: Triggering image pull and project start (build_stream)...") + await client.trigger_build_stream(project_id) + results.append(" Build request accepted by DSM.") - # ── Step 3: Start ───────────────────────────────────────────────── - results.append("Step 3/4: Starting project...") - await client.request("SYNO.Docker.Project", "start", params={"id": project_id}) - results.append(" Start issued.") - - # ── Step 4: Poll ────────────────────────────────────────────────── - results.append("Step 4/4: Waiting for project to reach RUNNING state...") - final_status = await _wait_for_project_running(client, project_name) + # ── Step 3: Poll ────────────────────────────────────────────────── + results.append( + f"Step 3/3: Waiting for project to reach RUNNING state " + f"(up to {_BUILD_POLL_TIMEOUT}s)..." + ) + final_status = await _wait_for_project_running( + client, project_name, timeout=_BUILD_POLL_TIMEOUT + ) if final_status == "RUNNING": results.append(" Project is RUNNING.") results.append(f"\nProject '{project_name}' redeployed successfully.") else: results.append( - f" Warning: project status is '{final_status}' after {_POLL_TIMEOUT}s. " + f" Warning: project status is '{final_status}' after " + f"{_BUILD_POLL_TIMEOUT}s. " f"Containers may still be starting — check with get_project_status." ) - results.append(f"\nProject '{project_name}' start issued (status: {final_status}).") + results.append(f"\nProject '{project_name}' build issued (status: {final_status}).") except Exception as e: results.append(f"Error during redeploy: {e}") @@ -249,162 +230,6 @@ async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None: return None -async def _read_compose_images_for_project( - client: DsmClient, - config: AppConfig, - project_name: str, -) -> list[str]: - """Read all image references from a project's compose file. - - Uses the project's filesystem path from the DSM API, then lists the - directory via FileStation to find the compose file, downloads and - parses it. - - Args: - client: DsmClient instance. - config: AppConfig (provides compose_base_path fallback). - project_name: Name of the project. - - Returns: - Deduplicated list of image ref strings (e.g. ["nginx:1.24"]). - Returns an empty list on any error. - """ - project = await _find_project(client, project_name) - if project is None: - return [] - - raw_path = project.get("path", "").rstrip("/") - if not raw_path: - raw_path = f"{config.compose_base_path}/{project_name}" - - fs_base = _VOLUME_PREFIX_RE.sub("", raw_path) - - try: - data = await client.request( - "SYNO.FileStation.List", - "list", - params={"folder_path": fs_base, "additional": "[]"}, - ) - names_present = {f.get("name", "") for f in data.get("files", [])} - except Exception as e: - logger.debug("Could not list compose directory '%s': %s", fs_base, e) - return [] - - compose_path: str | None = None - for fname in _COMPOSE_FILENAMES: - if fname in names_present: - compose_path = f"{fs_base}/{fname}" - break - - if compose_path is None: - logger.debug("No compose file found in '%s'", fs_base) - return [] - - try: - content = await client.download_text(compose_path) - parsed = yaml.safe_load(content) - except Exception as e: - logger.debug("Could not read/parse compose file '%s': %s", compose_path, e) - return [] - - if not isinstance(parsed, dict): - return [] - - images: set[str] = set() - for service in (parsed.get("services") or {}).values(): - img = (service or {}).get("image", "") - if img: - images.add(img) - - return list(images) - - -async def _try_delete_image( - client: DsmClient, - image_ref: str, - results: list[str], -) -> None: - """Best-effort delete of a local Docker image to force re-pull on start. - - Only blocks deletion if a *running* container (not from this project) - uses the image. Stopped-container references are ignored so that the - just-stopped project containers do not prevent deletion. - All DSM errors are non-fatal. - - Args: - client: DsmClient instance. - image_ref: Image reference as "name:tag". - results: Output list; status lines are appended here. - """ - name, sep, tag = image_ref.rpartition(":") - if not sep: - name = image_ref - tag = "latest" - - # Locate the image in the local registry - try: - img_data = await client.request( - "SYNO.Docker.Image", - "list", - params={"limit": "-1", "offset": "0", "show_dsm": "false"}, - ) - except Exception as e: - results.append(f" Could not list images: {e}") - return - - images: list[dict[str, Any]] = img_data.get("images", []) - target: dict[str, Any] | None = None - for img in images: - if img.get("repository", "") == name and tag in (img.get("tags") or []): - target = img - break - - if target is None: - results.append(f" '{image_ref}' not in local cache — DSM will pull on start.") - return - - img_hash = target.get("id", "") - hash_prefix = img_hash[:12] if img_hash else "" - - # Block only on running containers (stopped containers are non-blocking) - try: - ctr_data = await client.request( - "SYNO.Docker.Container", - "list", - params={"limit": "-1", "offset": "0", "type": "all"}, - ) - for ctr in ctr_data.get("containers", []): - ctr_img_id = ctr.get("image_id") or ctr.get("ImageID") or ctr.get("Image", "") - if ( - img_hash - and (ctr_img_id == img_hash or (hash_prefix and ctr_img_id.startswith(hash_prefix))) - and ctr.get("status", ctr.get("state", "")).lower() == "running" - ): - ctr_name = ctr.get("name", "?") - results.append( - f" Skipped: '{image_ref}' is used by running container '{ctr_name}'." - ) - return - except Exception: - pass # Best-effort check; proceed with delete attempt - - # Attempt deletion - repo = target.get("repository", name) - img_tags = target.get("tags") or [tag] - images_param = json.dumps([{"repository": repo, "tags": [img_tags[0]]}]) - - try: - await client.post_request( - "SYNO.Docker.Image", - "delete", - version=1, - params={"images": images_param}, - ) - results.append(f" Removed '{image_ref}' from local cache.") - except Exception as e: - results.append(f" Could not remove '{image_ref}' (non-fatal): {e}") - - async def _wait_for_project_running( client: DsmClient, name: str, diff --git a/tests/test_modules/test_projects.py b/tests/test_modules/test_projects.py index 959c00d..94a76c0 100644 --- a/tests/test_modules/test_projects.py +++ b/tests/test_modules/test_projects.py @@ -206,41 +206,44 @@ def project_list(status: str) -> dict: } -def make_stateful_redeploy_mock(initial_status: str, stop_raises=None): +def make_stateful_redeploy_mock( + initial_status: str, + stop_raises=None, + build_stream_raises=None, +): """Create a stateful client mock for redeploy tests. - Returns (client, calls_list). After ``start`` is called, subsequent - ``list`` calls return RUNNING so the polling loop terminates immediately. - asyncio.sleep is NOT patched here — patch it at call-site. - - FileStation.List returns an empty file list so compose image detection is - skipped (image deletion is tested separately). + Returns (client, calls_list). After ``trigger_build_stream`` is called, + subsequent ``list`` calls return RUNNING so the polling loop terminates + immediately. asyncio.sleep is NOT patched here — patch it at call-site. """ client = AsyncMock() calls = [] - start_called = False + build_done = False async def mock_request(api, method, **kwargs): - nonlocal start_called calls.append((api, method)) - if api == "SYNO.FileStation.List": - return {"files": []} # No compose file → skip image deletion - if method == "start": - start_called = True if method == "stop" and stop_raises: raise stop_raises if method == "list": - return project_list("RUNNING") if start_called else project_list(initial_status) + return project_list("RUNNING") if build_done else project_list(initial_status) return {} + async def mock_trigger_build_stream(project_id): + nonlocal build_done + calls.append(("SYNO.Docker.Project", "build_stream")) + if build_stream_raises: + raise build_stream_raises + build_done = True # After build_stream, polling returns RUNNING + client.request.side_effect = mock_request - client.post_request = AsyncMock() + client.trigger_build_stream = AsyncMock(side_effect=mock_trigger_build_stream) return client, calls @pytest.mark.asyncio async def test_redeploy_running_project(): - """RUNNING project: stop then start; polls until RUNNING.""" + """RUNNING project: stop → build_stream → poll until RUNNING.""" client, calls = make_stateful_redeploy_mock("RUNNING") tools = make_projects_tools(client) @@ -250,13 +253,13 @@ async def test_redeploy_running_project(): assert "redeployed successfully" in result methods = [m for _, m in calls] assert "stop" in methods - assert "start" in methods - assert methods.index("stop") < methods.index("start") + assert "build_stream" in methods + assert methods.index("stop") < methods.index("build_stream") @pytest.mark.asyncio -async def test_redeploy_stopped_project_starts_directly(): - """STOPPED project: skip stop, just start; polls until RUNNING.""" +async def test_redeploy_stopped_project_skips_stop(): + """STOPPED project: skip stop, call build_stream directly; polls until RUNNING.""" client, calls = make_stateful_redeploy_mock("STOPPED") tools = make_projects_tools(client) @@ -266,13 +269,13 @@ async def test_redeploy_stopped_project_starts_directly(): assert "redeployed successfully" in result methods = [m for _, m in calls] assert "stop" not in methods - assert "start" in methods - assert "STOPPED" in result or "starting directly" in result.lower() + assert "build_stream" in methods + assert "STOPPED" in result @pytest.mark.asyncio async def test_redeploy_build_failed_project(): - """BUILD_FAILED project: stop → (delete images) → start; polls until RUNNING.""" + """BUILD_FAILED project: stop (suppressed) → build_stream → poll until RUNNING.""" client, calls = make_stateful_redeploy_mock("BUILD_FAILED") tools = make_projects_tools(client) @@ -282,16 +285,16 @@ async def test_redeploy_build_failed_project(): assert "redeployed successfully" in result methods = [m for _, m in calls] assert "stop" in methods - assert "start" in methods - assert methods.index("stop") < methods.index("start") + assert "build_stream" in methods + assert methods.index("stop") < methods.index("build_stream") @pytest.mark.asyncio async def test_redeploy_build_failed_stop_error_nonfatal(): - """BUILD_FAILED: stop failure is non-fatal and must not abort the redeploy.""" + """BUILD_FAILED: stop failure is non-fatal — build_stream must still be called.""" from mcp_synology_container.dsm_client import SynologyError - client, _ = make_stateful_redeploy_mock( + client, calls = make_stateful_redeploy_mock( "BUILD_FAILED", stop_raises=SynologyError("already stopped", code=2101), ) @@ -301,80 +304,57 @@ async def test_redeploy_build_failed_stop_error_nonfatal(): result = await tools["redeploy_project"]("myapp", confirmed=True) assert "redeployed successfully" in result + methods = [m for _, m in calls] + assert "build_stream" in methods @pytest.mark.asyncio -async def test_redeploy_image_delete_failure_nonfatal(): - """Image deletion failure must be non-fatal: start must still be called.""" - client = AsyncMock() - start_called = False - - async def mock_request(api, method, **kwargs): - nonlocal start_called - if api == "SYNO.FileStation.List": - # Return a compose file so that image listing is attempted - return {"files": [{"name": "docker-compose.yml"}]} - if api == "SYNO.Docker.Image" and method == "list": - # Return one image matching the compose service - return { - "images": [ - { - "id": "sha256:abc123", - "repository": "nginx", - "tags": ["1.24"], - "size": 50000000, - } - ] - } - if api == "SYNO.Docker.Container" and method == "list": - return {"containers": []} - if method == "start": - start_called = True - if method == "list": - return project_list("RUNNING") if start_called else project_list("RUNNING") - return {} - - client.request.side_effect = mock_request - # Simulate FileStation download of compose.yaml - client.download_text = AsyncMock(return_value="services:\n web:\n image: nginx:1.24\n") - # post_request (image delete) raises an error — must be non-fatal - client.post_request = AsyncMock(side_effect=Exception("delete failed")) +async def test_redeploy_build_stream_error_aborts(): + """If build_stream raises, redeploy must abort with a clear error message.""" + from mcp_synology_container.dsm_client import SynologyError + client, calls = make_stateful_redeploy_mock( + "RUNNING", + build_stream_raises=SynologyError("build failed", code=114), + ) tools = make_projects_tools(client) with patch("mcp_synology_container.modules.projects.asyncio.sleep"): result = await tools["redeploy_project"]("myapp", confirmed=True) - assert "redeployed successfully" in result - assert start_called, "start must be called even when image deletion fails" + assert "redeployed successfully" not in result + assert "build failed" in result or "Error during redeploy" in result + # Polling must not have been called after build_stream failure + methods = [m for _, m in calls] + list_calls = [m for m in methods if m == "list"] + assert len(list_calls) <= 1 # at most the initial find_project call @pytest.mark.asyncio async def test_redeploy_poll_timeout(): - """If project never reaches RUNNING after start, a warning is emitted.""" + """If project never reaches RUNNING after build_stream, a warning is emitted.""" client = AsyncMock() - start_called = False + build_done = False async def mock_request(api, method, **kwargs): - nonlocal start_called - if api == "SYNO.FileStation.List": - return {"files": []} - if method == "start": - start_called = True if method == "list": - # Before start: return RUNNING so initial status check picks a valid path. - # After start: return STARTING to simulate a stuck container — triggers timeout. - return project_list("STARTING") if start_called else project_list("RUNNING") + # Before build: RUNNING (so initial status check is valid) + # After build: STARTING (simulate stuck containers) + return project_list("STARTING") if build_done else project_list("RUNNING") return {} + async def mock_build_stream(project_id): + nonlocal build_done + build_done = True + client.request.side_effect = mock_request - client.post_request = AsyncMock() + client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream) tools = make_projects_tools(client) # Use tiny timeout so the test is instant (interval=1, timeout=1 → 1 poll) with ( patch("mcp_synology_container.modules.projects.asyncio.sleep"), - patch("mcp_synology_container.modules.projects._POLL_TIMEOUT", 1), + patch("mcp_synology_container.modules.projects._BUILD_POLL_TIMEOUT", 1), patch("mcp_synology_container.modules.projects._POLL_INTERVAL", 1), ): result = await tools["redeploy_project"]("myapp", confirmed=True) @@ -389,14 +369,12 @@ async def test_redeploy_unknown_status_returns_error(): client = AsyncMock() async def mock_request(api, method, **kwargs): - if api == "SYNO.FileStation.List": - return {"files": []} if method == "list": return project_list("UPDATING") return {} client.request.side_effect = mock_request - client.post_request = AsyncMock() + client.trigger_build_stream = AsyncMock() tools = make_projects_tools(client) result = await tools["redeploy_project"]("myapp", confirmed=True)