From 5cff7d85061079c9da1835aa317837f7d77e1097 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Tue, 21 Apr 2026 07:17:36 +0200 Subject: [PATCH] v0.2.1: redeploy_project post-start polling (30s timeout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DSM starts containers asynchronously - start_project returns immediately while containers are still initialising. Adds _wait_for_project_running: polls SYNO.Docker.Project/list every 2s up to 30s after issuing start. Reports RUNNING on success; emits a warning instead of failure on timeout so callers can still verify with get_project_status. Applies to all three redeploy paths (RUNNING, STOPPED, BUILD_FAILED). Also bumps version 0.2.0 → 0.2.1 and adds CHANGELOG entry. Co-Authored-By: Claude Haiku 4.5 --- CHANGELOG.md | 19 +++ pyproject.toml | 2 +- .../modules/projects.py | 75 +++++++++-- tests/test_modules/test_projects.py | 119 +++++++++++------- uv.lock | 2 +- 5 files changed, 162 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca2a5b8..6639d73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. +## [0.2.1] - 2026-04-21 + +### Fixed + +- `redeploy_project`: After issuing `start`, the tool now polls the project status every 2 seconds + for up to 30 seconds until the project reaches `RUNNING`. Previously DSM returned immediately + while containers were still starting, causing the project to appear as `exited` when checked + right after redeploy. On timeout a warning is returned instead of an error. +- `delete_image`: Now distinguishes between running and stopped container references. + A stopped container holding the image produces a clear hint to use `delete_container` + or `system_prune` instead of a generic "in use" error. +- `redeploy_project` (BUILD_FAILED path): Added explicit image pull step before restart + (`stop → pull → start`). Previously the old cached image could be reused. + +### Added + +- `delete_container` — delete a stopped container by name; refuses if container is still running; + requires `confirmed=True`. + ## [0.2.0] - 2026-04-14 ### Added diff --git a/pyproject.toml b/pyproject.toml index 08bf8f2..09a28df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-synology-container" -version = "0.2.0" +version = "0.2.1" description = "MCP server for Synology Container Manager" requires-python = ">=3.12" dependencies = [ diff --git a/src/mcp_synology_container/modules/projects.py b/src/mcp_synology_container/modules/projects.py index ed12326..99312a7 100644 --- a/src/mcp_synology_container/modules/projects.py +++ b/src/mcp_synology_container/modules/projects.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import contextlib import logging from typing import TYPE_CHECKING, Any @@ -14,6 +15,9 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +_POLL_INTERVAL = 2 # seconds between status checks +_POLL_TIMEOUT = 30 # maximum seconds to wait for RUNNING + def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None: """Register all project management tools with the MCP server.""" @@ -124,6 +128,10 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non - STOPPED → start directly (nothing to stop) - BUILD_FAILED → stop, pull images, then start + After issuing start, polls the project status every 2 seconds for up + to 30 seconds until the project reaches RUNNING. Reports the final + status; emits a warning on timeout instead of failing. + This operation will briefly take the project offline. Requires confirmation before executing. @@ -149,30 +157,30 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non try: if status == "STOPPED": results.append("Project is STOPPED — starting directly.") - results.append("Step 1/1: Starting project...") + results.append("Step 1/2: Starting project...") await client.request("SYNO.Docker.Project", "start", params={"id": project_id}) - results.append(" Project started.") + results.append(" Start issued.") elif status == "BUILD_FAILED": - results.append("Step 1/3: Stopping failed build...") + results.append("Step 1/4: Stopping failed build...") with contextlib.suppress(Exception): await client.request("SYNO.Docker.Project", "stop", params={"id": project_id}) results.append(" Build stopped.") - results.append("Step 2/3: Pulling updated images...") + results.append("Step 2/4: Pulling updated images...") with contextlib.suppress(Exception): await client.request("SYNO.Docker.Image", "pull", params={"id": project_id}) results.append(" Images pulled.") - results.append("Step 3/3: Starting project...") + results.append("Step 3/4: Starting project...") await client.request("SYNO.Docker.Project", "start", params={"id": project_id}) - results.append(" Project started.") + results.append(" Start issued.") elif status in ("RUNNING", ""): - results.append("Step 1/2: Stopping project...") + results.append("Step 1/3: Stopping project...") await client.request("SYNO.Docker.Project", "stop", params={"id": project_id}) results.append(" Project stopped.") - results.append("Step 2/2: Starting project...") + results.append("Step 2/3: Starting project...") await client.request("SYNO.Docker.Project", "start", params={"id": project_id}) - results.append(" Project started.") + results.append(" Start issued.") else: return ( @@ -180,7 +188,21 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non f"Workaround: use stop_project + start_project separately." ) - results.append(f"\nProject '{project_name}' redeployed successfully.") + # Poll until RUNNING or timeout + poll_step = ( + "2/2" if status == "STOPPED" else ("4/4" if status == "BUILD_FAILED" else "3/3") + ) + results.append(f"Step {poll_step}: Waiting for project to reach RUNNING state...") + final_status = await _wait_for_project_running(client, project_name) + 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"Containers may still be starting — check with get_project_status." + ) + results.append(f"\nProject '{project_name}' start issued (status: {final_status}).") except Exception as e: results.append(f"Error during redeploy: {e}") @@ -211,6 +233,39 @@ async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None: return None +async def _wait_for_project_running( + client: DsmClient, + name: str, + timeout: int = _POLL_TIMEOUT, + interval: int = _POLL_INTERVAL, +) -> str: + """Poll until the project reaches RUNNING status or timeout expires. + + Args: + client: DsmClient instance. + name: Project name to watch. + timeout: Maximum seconds to wait (default 30). + interval: Seconds between polls (default 2). + + Returns: + Final project status string (may not be "RUNNING" on timeout). + """ + elapsed = 0 + while elapsed < timeout: + await asyncio.sleep(interval) + elapsed += interval + project = await _find_project(client, name) + if project is None: + continue + current = (project.get("status") or "").upper() + logger.debug("Polling '%s': status=%s elapsed=%ds", name, current, elapsed) + if current == "RUNNING": + return current + # Return whatever status we last saw (or UNKNOWN on repeated failures) + project = await _find_project(client, name) + return (project.get("status") or "UNKNOWN").upper() if project else "UNKNOWN" + + def _format_project_detail(project: dict[str, Any]) -> str: """Format project details as human-readable text.""" lines = [ diff --git a/tests/test_modules/test_projects.py b/tests/test_modules/test_projects.py index 087d621..967f7c0 100644 --- a/tests/test_modules/test_projects.py +++ b/tests/test_modules/test_projects.py @@ -1,11 +1,11 @@ """Tests for modules/projects.py.""" +from unittest.mock import AsyncMock, patch + import pytest -from unittest.mock import AsyncMock, MagicMock from mcp_synology_container.modules.projects import _find_project, _format_project_detail - SAMPLE_PROJECTS = { "uuid-1": { "id": "uuid-1", @@ -83,8 +83,8 @@ def test_format_project_detail_no_containers(): @pytest.mark.asyncio async def test_list_projects_tool(): """Test list_projects tool via function registration.""" - from mcp_synology_container.modules.projects import register_projects from mcp_synology_container.config import AppConfig, ConnectionConfig + from mcp_synology_container.modules.projects import register_projects config = AppConfig( schema_version=1, @@ -114,8 +114,8 @@ async def test_list_projects_tool(): @pytest.mark.asyncio async def test_stop_project_requires_confirmation(): - from mcp_synology_container.modules.projects import register_projects from mcp_synology_container.config import AppConfig, ConnectionConfig + from mcp_synology_container.modules.projects import register_projects config = AppConfig( schema_version=1, @@ -141,8 +141,8 @@ async def test_stop_project_requires_confirmation(): @pytest.mark.asyncio async def test_redeploy_project_requires_confirmation(): - from mcp_synology_container.modules.projects import register_projects from mcp_synology_container.config import AppConfig, ConnectionConfig + from mcp_synology_container.modules.projects import register_projects config = AppConfig( schema_version=1, @@ -172,8 +172,8 @@ async def test_redeploy_project_requires_confirmation(): def make_projects_tools(client): - from mcp_synology_container.modules.projects import register_projects from mcp_synology_container.config import AppConfig, ConnectionConfig + from mcp_synology_container.modules.projects import register_projects config = AppConfig( schema_version=1, @@ -206,43 +206,58 @@ def project_list(status: str) -> dict: } -@pytest.mark.asyncio -async def test_redeploy_running_project(): - """RUNNING project: stop then start (2 steps).""" +def make_stateful_redeploy_mock(initial_status: str, stop_raises=None, pull_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. + """ client = AsyncMock() calls = [] + start_called = False async def mock_request(api, method, **kwargs): + nonlocal start_called calls.append((api, method)) - return project_list("RUNNING") if method == "list" else {} + if method == "start": + start_called = True + if method == "stop" and stop_raises: + raise stop_raises + if method == "pull" and pull_raises: + raise pull_raises + if method == "list": + return project_list("RUNNING") if start_called else project_list(initial_status) + return {} client.request.side_effect = mock_request + return client, calls + + +@pytest.mark.asyncio +async def test_redeploy_running_project(): + """RUNNING project: stop then start; polls until RUNNING.""" + client, calls = make_stateful_redeploy_mock("RUNNING") tools = make_projects_tools(client) - result = await tools["redeploy_project"]("myapp", confirmed=True) + with patch("mcp_synology_container.modules.projects.asyncio.sleep"): + result = await tools["redeploy_project"]("myapp", confirmed=True) assert "redeployed successfully" in result methods = [m for _, m in calls] assert "stop" in methods assert "start" in methods - # stop must come before start assert methods.index("stop") < methods.index("start") @pytest.mark.asyncio async def test_redeploy_stopped_project_starts_directly(): - """STOPPED project: skip stop, just start.""" - client = AsyncMock() - calls = [] - - async def mock_request(api, method, **kwargs): - calls.append((api, method)) - return project_list("STOPPED") if method == "list" else {} - - client.request.side_effect = mock_request + """STOPPED project: skip stop, just start; polls until RUNNING.""" + client, calls = make_stateful_redeploy_mock("STOPPED") tools = make_projects_tools(client) - result = await tools["redeploy_project"]("myapp", confirmed=True) + with patch("mcp_synology_container.modules.projects.asyncio.sleep"): + result = await tools["redeploy_project"]("myapp", confirmed=True) assert "redeployed successfully" in result methods = [m for _, m in calls] @@ -253,51 +268,69 @@ async def test_redeploy_stopped_project_starts_directly(): @pytest.mark.asyncio async def test_redeploy_build_failed_project(): - """BUILD_FAILED project: stop, pull images, then start (3 steps).""" - client = AsyncMock() - calls = [] - - async def mock_request(api, method, **kwargs): - calls.append((api, method)) - return project_list("BUILD_FAILED") if method == "list" else {} - - client.request.side_effect = mock_request + """BUILD_FAILED project: stop → pull → start; polls until RUNNING.""" + client, calls = make_stateful_redeploy_mock("BUILD_FAILED") tools = make_projects_tools(client) - result = await tools["redeploy_project"]("myapp", confirmed=True) + with patch("mcp_synology_container.modules.projects.asyncio.sleep"): + result = await tools["redeploy_project"]("myapp", confirmed=True) assert "redeployed successfully" in result methods = [m for _, m in calls] assert "stop" in methods - assert "pull" in methods # New: pull step + assert "pull" in methods assert "start" in methods - # Order: stop → pull → start assert methods.index("stop") < methods.index("pull") assert methods.index("pull") < methods.index("start") @pytest.mark.asyncio async def test_redeploy_build_failed_stop_error_nonfatal(): - """BUILD_FAILED: stop/pull failure must not abort the redeploy.""" + """BUILD_FAILED: stop/pull failures must not abort the redeploy.""" from mcp_synology_container.dsm_client import SynologyError + client, _ = make_stateful_redeploy_mock( + "BUILD_FAILED", + stop_raises=SynologyError("already stopped", code=2101), + pull_raises=SynologyError("pull failed", code=2102), + ) + 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 + + +@pytest.mark.asyncio +async def test_redeploy_poll_timeout(): + """If project never reaches RUNNING after start, a warning is emitted.""" client = AsyncMock() + start_called = False async def mock_request(api, method, **kwargs): + nonlocal start_called + if method == "start": + start_called = True if method == "list": - return project_list("BUILD_FAILED") - if method == "stop": - raise SynologyError("already stopped", code=2101) - if method == "pull": - raise SynologyError("pull failed", code=2102) - return {} # start succeeds + # 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") + return {} client.request.side_effect = mock_request tools = make_projects_tools(client) - result = await tools["redeploy_project"]("myapp", confirmed=True) + # 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._POLL_INTERVAL", 1), + ): + result = await tools["redeploy_project"]("myapp", confirmed=True) - assert "redeployed successfully" in result + assert "Warning" in result + assert "redeployed successfully" not in result @pytest.mark.asyncio diff --git a/uv.lock b/uv.lock index eac7600..ce258e7 100644 --- a/uv.lock +++ b/uv.lock @@ -362,7 +362,7 @@ wheels = [ [[package]] name = "mcp-synology-container" -version = "0.2.0" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "click" },