diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd9bd5..f344a1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to this project will be documented in this file. +## [0.10.0] - 2026-06-23 + +### Fixed (cached `build:` projects no longer stick in CREATED) +- `create_project` / `redeploy_project`: after `build_stream` finishes, the + project status is checked and, when the build left it CREATED — a `build:` + project whose image was already cached, so DSM's implicit `up` never fired — + `SYNO.Docker.Project/start` (confirmed to map to `docker compose up`) is + issued to bring the containers to RUNNING. Previously such projects were left + stuck in CREATED, with `start_project` returning DSM 2104 and + `redeploy_project` refusing with "unexpected status". +- `redeploy_project`: now recovers a project that is already in CREATED + (stop → build → compose up) instead of refusing it. +- `start_project`: supports CREATED (compose up) and STOPPED. On a DSM + rejection from CREATED (observed as code 2104 on some firmware) it returns an + actionable recovery hint (retry `redeploy_project`, or delete the base image + to force a cold rebuild) instead of a bare error code, and is a no-op with a + clear message when the project is already RUNNING. + +### Notes +- The post-build compose-up is best-effort: if DSM rejects `start`, the tools + surface a clear "containers are not running" hint instead of a silent stuck + state. Behaviour confirmed against live DSM: `start` brings STOPPED → RUNNING; + the cold-build path was already RUNNING and is unchanged. + ## [0.9.1] - 2026-06-22 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 16fc8a1..f5f3c38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-synology-container" -version = "0.9.1" +version = "0.10.0" 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 121f41c..d147756 100644 --- a/src/mcp_synology_container/modules/projects.py +++ b/src/mcp_synology_container/modules/projects.py @@ -66,6 +66,55 @@ def _parse_build_stream_log(log: str) -> tuple[list[str], list[str]]: return errors, info +async def _ensure_project_up( + client: DsmClient, + project_name: str, + project_id: str, + results: list[str], +) -> str: + """Bring a freshly built project to RUNNING via compose-up when needed. + + DSM's ``build_stream`` runs the build phase (``docker compose build``). + On a ``build:`` project whose image is already cached, the build is a + no-op and the implicit ``up`` does **not** fire — the project is left in + CREATED (containers created but never started) and is otherwise stuck. + This helper reads the post-build status and, when the project is not yet + RUNNING (and not in a terminal-failure state), issues + ``SYNO.Docker.Project/start`` — confirmed to map to ``docker compose up`` + — to start the containers, then re-reads and returns the final status. + + The ``start`` call is best-effort: if DSM rejects it (e.g. the firmware + refuses ``start`` from CREATED with code 2104), the rejection is recorded + in ``results`` and the unchanged status is returned so the caller can + surface an actionable hint instead of a silent stuck state. + + Args: + client: DsmClient instance. + project_name: Project name (for status lookups). + project_id: Project UUID (for the start call). + results: Running list of human-readable step lines, appended in place. + + Returns: + The final project status (uppercased), e.g. "RUNNING", "CREATED", + "BUILD_FAILED", or "UNKNOWN". + """ + final = await _find_project(client, project_name) + status = (final.get("status") or "UNKNOWN").upper() if final else "UNKNOWN" + if status in ("RUNNING", "UNKNOWN") or status in _TERMINAL_FAILURE_STATUSES: + return status + + # Built but not started (CREATED/STOPPED/…) → docker compose up. + results.append(f" Built but not running (status: {status}) — running compose up...") + try: + await client.request("SYNO.Docker.Project", "start", params={"id": project_id}) + except Exception as e: # noqa: BLE001 — any DSM error becomes an actionable hint + results.append(f" compose up (start) rejected by DSM: {e}") + return status + + final = await _find_project(client, project_name) + return (final.get("status") or "UNKNOWN").upper() if final else "UNKNOWN" + + def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None: """Register all project management tools with the MCP server.""" @@ -106,21 +155,47 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non @mcp.tool() async def start_project(project_name: str): - """Start a Container Manager project.""" + """Start (docker compose up) a Container Manager project. + + Works from STOPPED and from CREATED — the latter being a ``build:`` + project whose image was already cached, so the build's implicit ``up`` + never fired. On a DSM rejection from CREATED, returns an actionable + recovery hint instead of a bare error code. + """ project = await _find_project(client, project_name) if project is None: return f"Project '{project_name}' not found." project_id = project.get("id", "") + status = (project.get("status") or "").upper() + if status == "RUNNING": + return f"Project '{project_name}' is already RUNNING." + try: await client.request( "SYNO.Docker.Project", "start", params={"id": project_id}, ) - return f"Project '{project_name}' started successfully." except Exception as e: - return f"Error starting project '{project_name}': {e}" + hint = "" + if status == "CREATED": + hint = ( + "\nThe project is CREATED (built but never started). If DSM keeps " + "rejecting the start, recover with redeploy_project, or delete the base " + "image to force a cold rebuild, then create/redeploy again." + ) + return f"Error starting project '{project_name}' (status {status or '?'}): {e}{hint}" + + final = await _find_project(client, project_name) + final_status = (final.get("status") or "UNKNOWN").upper() if final else "UNKNOWN" + if final_status == "RUNNING": + return f"Project '{project_name}' started successfully (now RUNNING)." + return ( + f"Project '{project_name}' start issued but status is {final_status}, not RUNNING. " + f"Check get_project_status('{project_name}'); if it stays stuck, try " + f"redeploy_project or a cold rebuild (delete the base image)." + ) @mcp.tool() async def stop_project(project_name: str, confirmed: bool = False): @@ -163,7 +238,7 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non project_id = project.get("id", "") status = (project.get("status") or "").upper() - if status not in ("RUNNING", "STOPPED", "BUILD_FAILED", ""): + if status not in ("RUNNING", "STOPPED", "BUILD_FAILED", "CREATED", ""): return ( f"Cannot redeploy '{project_name}': unexpected status '{status}'.\n" "Wait for the current operation to finish, then retry redeploy_project. " @@ -181,8 +256,10 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non # ── Step 1: Stop ────────────────────────────────────────────────── if status == "STOPPED": results.append("Step 1/3: Project is STOPPED — skipping stop.") - elif status in ("BUILD_FAILED", ""): - results.append("Step 1/3: Stopping failed build...") + elif status in ("BUILD_FAILED", "CREATED", ""): + results.append( + f"Step 1/3: Project is {status or 'unbuilt'} — stopping to clear containers..." + ) with contextlib.suppress(Exception): await client.request("SYNO.Docker.Project", "stop", params={"id": project_id}) stop_was_issued = True @@ -233,11 +310,11 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non return "\n".join(results) # Stream closed within the early window → the build already - # finished server-side. Report the outcome from a single status - # read (no minutes-long polling loop). + # finished server-side. Read the status and, if the build left the + # project CREATED (cached build: image, implicit up skipped), run + # compose up to bring it to RUNNING — no minutes-long polling loop. results.append(" Build request accepted by DSM.") - final = await _find_project(client, project_name) - final_status = (final.get("status") or "UNKNOWN").upper() if final else "UNKNOWN" + final_status = await _ensure_project_up(client, project_name, project_id, results) if final_status == "RUNNING": results.append(" Project is RUNNING.") results.append(f"\nProject '{project_name}' redeployed successfully.") @@ -252,10 +329,11 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non f"\nProject '{project_name}' redeploy aborted (status: {final_status})." ) else: - results.append(f" Build finished — current status: {final_status}.") + results.append(f" compose up did not reach RUNNING — status: {final_status}.") results.append( - f"\nProject '{project_name}' redeploy started (status: {final_status}). " - f"Check get_project_status('{project_name}')." + f"\nProject '{project_name}' redeploy incomplete (status: {final_status}): " + f"containers are not running. Retry redeploy_project; if it stays stuck on a " + f"cached build, delete the base image to force a cold rebuild." ) except Exception as e: @@ -438,9 +516,10 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non return "\n".join(results) # Stream closed within the early window → the build already finished. + # If the build left the project CREATED (cached build: image, implicit + # up skipped), run compose up to bring it to RUNNING. results.append(" Build request accepted by DSM.") - final = await _find_project(client, project_name) - final_status = (final.get("status") or "UNKNOWN").upper() if final else "UNKNOWN" + final_status = await _ensure_project_up(client, project_name, project_id, results) if final_status == "RUNNING": results.append(" Project is RUNNING.") results.append(f"\nProject '{project_name}' created and started successfully.") @@ -456,10 +535,12 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non f"(status: {final_status})." ) else: - results.append(f" Build finished — current status: {final_status}.") + results.append(f" compose up did not reach RUNNING — status: {final_status}.") results.append( - f"\nProject '{project_name}' created (status: {final_status}). " - f"Check get_project_status('{project_name}')." + f"\nProject '{project_name}' is registered but its containers are not running " + f"(status: {final_status}). Try redeploy_project('{project_name}', " + f"confirmed=True); if it stays stuck on a cached build, delete the base image " + f"to force a cold rebuild, then create/redeploy again." ) return "\n".join(results) diff --git a/tests/test_modules/test_projects.py b/tests/test_modules/test_projects.py index 515aef5..c4c666d 100644 --- a/tests/test_modules/test_projects.py +++ b/tests/test_modules/test_projects.py @@ -585,33 +585,46 @@ def make_create_project_client( build_stream_log: str = "", project_id: str = "uuid-new", final_status: str = "RUNNING", + start_raises: Exception | None = None, + status_after_start: str = "RUNNING", ): """Build a stateful mock client for create_project tests. Tracks: - whether Docker.Project/create has been called (so post_create_calls to /list return the newly-registered project at `final_status`) + - whether Docker.Project/start has been called (so subsequent /list + reads return `status_after_start` — used to exercise the + compose-up-after-build recovery for a CREATED build) - which API/method/version each call used """ client = AsyncMock() calls: list[tuple[str, str, dict]] = [] project_created = False + started = False async def mock_request(api, method, version=None, params=None, **kwargs): + nonlocal started calls.append((api, method, dict(params or {}))) if api == "SYNO.Docker.Project" and method == "list": if project_created: + status = status_after_start if started else final_status return { project_id: { "id": project_id, "name": "newapp", - "status": final_status, + "status": status, "path": "/volume1/docker/newapp", "containerIds": [], "services": [], } } return existing_projects or {} + if api == "SYNO.Docker.Project" and method == "start": + if start_raises: + raise start_raises + started = True + return {} if api == "SYNO.FileStation.CreateFolder": if create_folder_raises: raise create_folder_raises @@ -953,3 +966,213 @@ async def test_delete_project_running_blocked_connector_side(): # The delete endpoint must NOT have been called — no orphaned containers. delete_calls = [m for _, m, _ in calls if m == "delete"] assert delete_calls == [] + + +# ────────────────────────────────────────────────────────────────────── +# compose-up-after-build: recover a CREATED build: project (Issue: cached +# build leaves the project CREATED, implicit `up` skipped) +# ────────────────────────────────────────────────────────────────────── + + +def make_created_recovery_mock( + *, + initial_status: str = "CREATED", + post_build_status: str = "CREATED", + start_raises=None, + status_after_start: str = "RUNNING", +): + """Stateful redeploy mock that simulates a cached build landing CREATED. + + Before build_stream, /list returns ``initial_status``. After build_stream, + /list returns ``post_build_status`` until Project/start is called, then + ``status_after_start``. If ``start_raises`` is set, the start call raises. + """ + client = AsyncMock() + calls = [] + build_done = False + started = False + + async def mock_request(api, method, **kwargs): + nonlocal started + calls.append((api, method)) + if method == "start": + if start_raises: + raise start_raises + started = True + return {} + if method == "list": + if not build_done: + return project_list(initial_status) + return project_list(status_after_start if started else post_build_status) + return {} + + async def mock_build_stream(project_id, **kwargs): + nonlocal build_done + calls.append(("SYNO.Docker.Project", "build_stream")) + build_done = True + return "" # clean, closed stream — outcome decided by the status read + + client.request.side_effect = mock_request + client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream) + return client, calls + + +@pytest.mark.asyncio +async def test_redeploy_compose_up_after_cached_build(): + """build_stream leaves the project CREATED → redeploy issues compose up + (Project/start) → status reads RUNNING → success.""" + client, calls = make_created_recovery_mock(initial_status="RUNNING") + tools = make_projects_tools(client) + + result = await tools["redeploy_project"]("myapp", confirmed=True) + + assert "redeployed successfully" in result + assert "compose up" in result + methods = [m for _, m in calls] + assert "start" in methods + assert methods.index("build_stream") < methods.index("start") + + +@pytest.mark.asyncio +async def test_redeploy_recovers_from_created_input_status(): + """A project that is already stuck in CREATED can be redeployed (no longer + refused as 'unexpected status').""" + client, calls = make_created_recovery_mock(initial_status="CREATED") + tools = make_projects_tools(client) + + result = await tools["redeploy_project"]("myapp", confirmed=True) + + assert "unexpected status" not in result + assert "redeployed successfully" in result + methods = [m for _, m in calls] + assert "build_stream" in methods + assert "start" in methods + + +@pytest.mark.asyncio +async def test_redeploy_created_start_rejected_shows_hint(): + """If compose up (start) is rejected from CREATED (e.g. DSM 2104), redeploy + reports an incomplete redeploy with the cold-rebuild workaround.""" + from mcp_synology_container.dsm_client import SynologyError + + client, calls = make_created_recovery_mock( + initial_status="RUNNING", + start_raises=SynologyError("DSM error code 2104", code=2104), + ) + tools = make_projects_tools(client) + + result = await tools["redeploy_project"]("myapp", confirmed=True) + + assert "redeployed successfully" not in result + assert "redeploy incomplete" in result + assert "CREATED" in result + assert "2104" in result + assert "cold rebuild" in result + + +@pytest.mark.asyncio +async def test_create_compose_up_after_cached_build(): + """create_project: a cached build lands CREATED → compose up (start) → + RUNNING → created-and-started success.""" + client, calls = make_create_project_client( + final_status="CREATED", + status_after_start="RUNNING", + ) + tools = make_projects_tools(client) + + result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True) + + assert "created and started successfully" in result + methods = [m for _, m, _ in calls] + assert "start" in methods + assert methods.index("build_stream") < methods.index("start") + + +@pytest.mark.asyncio +async def test_create_created_start_rejected_shows_hint(): + """create_project: cached build CREATED + start rejected → registered-but-not + -running message with the cold-rebuild workaround.""" + from mcp_synology_container.dsm_client import SynologyError + + client, calls = make_create_project_client( + final_status="CREATED", + start_raises=SynologyError("DSM error code 2104", code=2104), + ) + tools = make_projects_tools(client) + + result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True) + + assert "created and started successfully" not in result + assert "containers are not running" in result + assert "CREATED" in result + assert "cold rebuild" in result + + +# ────────────────────────────────────────────────────────────────────── +# start_project: STOPPED/CREATED → compose up, with graceful 2104 hint +# ────────────────────────────────────────────────────────────────────── + + +def make_start_project_mock(initial_status, *, start_raises=None, status_after_start="RUNNING"): + client = AsyncMock() + calls = [] + started = False + + async def mock_request(api, method, **kwargs): + nonlocal started + calls.append((api, method)) + if method == "start": + if start_raises: + raise start_raises + started = True + return {} + if method == "list": + return project_list(status_after_start if started else initial_status) + return {} + + client.request.side_effect = mock_request + return client, calls + + +@pytest.mark.asyncio +async def test_start_project_created_recovers(): + """start_project on a CREATED project issues compose up and reports RUNNING.""" + client, calls = make_start_project_mock("CREATED") + tools = make_projects_tools(client) + + result = await tools["start_project"]("myapp") + + assert "started successfully" in result + assert "RUNNING" in result + assert "start" in [m for _, m in calls] + + +@pytest.mark.asyncio +async def test_start_project_created_rejected_shows_hint(): + """start_project rejected from CREATED (DSM 2104) → actionable recovery hint.""" + from mcp_synology_container.dsm_client import SynologyError + + client, calls = make_start_project_mock( + "CREATED", start_raises=SynologyError("DSM error code 2104", code=2104) + ) + tools = make_projects_tools(client) + + result = await tools["start_project"]("myapp") + + assert "Error starting" in result + assert "CREATED" in result + assert "2104" in result + assert "redeploy_project" in result + + +@pytest.mark.asyncio +async def test_start_project_already_running_is_noop(): + """start_project on a RUNNING project returns 'already RUNNING' and does not + call Project/start.""" + client, calls = make_start_project_mock("RUNNING") + tools = make_projects_tools(client) + + result = await tools["start_project"]("myapp") + + assert "already RUNNING" in result + assert "start" not in [m for _, m in calls] diff --git a/uv.lock b/uv.lock index 5a63e20..a5082da 100644 --- a/uv.lock +++ b/uv.lock @@ -362,7 +362,7 @@ wheels = [ [[package]] name = "mcp-synology-container" -version = "0.9.1" +version = "0.10.0" source = { editable = "." } dependencies = [ { name = "click" }, @@ -894,4 +894,4 @@ dependencies = [ sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" }, -] +] \ No newline at end of file