diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d7d90e..9b744c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,48 @@ All notable changes to this project will be documented in this file. +## [0.6.0] - 2026-05-18 + +### Changed + +**`build_stream` is no longer fire-and-forget (#2).** A live stream +capture confirmed that DSM emits a readable plaintext build log over +the HTTP body — one short status line per step — and closes the +connection when the build is done. The 0.2.5 implementation sent the +request and dropped the body unread, which meant a failed image pull +left users with nothing more than a `BUILD_FAILED` polling status and +no actionable diagnostic. + +- `DsmClient.trigger_build_stream` now consumes the body line by line + and returns the collected log as a string instead of `None`. A wall- + clock budget of 210 s (`BUILD_STREAM_BUDGET`, kept under the Claude + Desktop ~4 min tool-call ceiling) caps the read; on timeout the + partial log is returned with a `[build_stream: timeout — stream + still open server-side]` marker appended so the caller knows the + build is still going server-side. Per-chunk `ReadTimeout` is treated + the same way — return what we have plus the marker. +- `redeploy_project` and `create_project` now parse the returned log + via `_parse_build_stream_log`, which classifies any line containing + `Error response from daemon:` or ending in ` Error` as a failure. + When the log contains errors the tools abort immediately, surface + the daemon line(s) in the result (e.g. `Error response from daemon: + manifest for nginx:9.9.9-nonexistent not found: manifest unknown`), + and skip the polling step entirely — DSM has already told us the + build is dead. The existing `BUILD_FAILED` / `ERROR` polling guard + (M-5) stays as a second safety net for late failures where the + stream log was clean but the container exited after start. +- No new MCP tool: the build log is a live stream and cannot be + re-fetched after the build ends, so it is surfaced during + `redeploy_project` / `create_project` rather than exposed as a + standalone `get_project_build_log` call. + +JSON error envelope handling, transport-error mapping (M-4), and the +SID-scrubbed HTTP-error formatting are unchanged. Closes #2. + +Minor version bump because `redeploy_project` and `create_project` +return materially different strings on a failed build and exit +earlier in the failure path; signatures unchanged. + ## [0.5.1] - 2026-05-18 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 5d65dcf..e303757 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,20 @@ Only a second consecutive failure is treated as a real auth problem. - **Async project start** — `SYNO.Docker.Project/start` returns immediately while containers are still initialising. `redeploy_project` polls `SYNO.Docker.Project/list` every 2 s for up to 30 s after issuing start. +- **`SYNO.Docker.Project/build_stream`** — returns a streamed plaintext + build log (content-type `text/html`), one short line per step: + `Container Running` on success, ` Error` followed by + `Error response from daemon: ` on failure. The stream closes + when the build is done. `DsmClient.trigger_build_stream` consumes the + body line-by-line with a 210 s wall-clock budget (under the Claude + Desktop ~4 min ceiling) and returns the log as a string; on timeout + the partial log is returned with a marker appended so callers know + the build is still running server-side. `redeploy_project` and + `create_project` grep the returned log for daemon errors and abort + early — these errors are much more actionable than the eventual + `BUILD_FAILED` polling status. The log is **live-only**: it cannot + be re-fetched after the build ends, which is why no standalone + `get_project_build_log` tool exists. - **Image delete** — requires a form-encoded POST with a JSON `images` array (confirmed via browser DevTools); uses `DsmClient.post_request()`. - **`SYNO.Docker.Image/pull` vs. `pull_start`** — the legacy `pull` method diff --git a/pyproject.toml b/pyproject.toml index 10d6eac..a1b8c19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-synology-container" -version = "0.5.1" +version = "0.6.0" 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 9f8aab9..0a5903d 100644 --- a/src/mcp_synology_container/dsm_client.py +++ b/src/mcp_synology_container/dsm_client.py @@ -419,27 +419,53 @@ 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. + # Wall-clock budget for consuming the build_stream body. DSM keeps the + # connection open until the build is done (success or failure), so we + # need to give it enough time for an image pull while staying under the + # Claude Desktop ~4 min tool-call ceiling. When the budget is exhausted + # the partial log is returned with a "still running" marker appended. + BUILD_STREAM_BUDGET = 210.0 + + # Marker appended to the returned log when the stream did not finish + # within BUILD_STREAM_BUDGET. Callers can grep for this to decide + # whether the log they got is complete. + BUILD_STREAM_TIMEOUT_MARKER = "[build_stream: timeout — stream still open server-side]" + + async def trigger_build_stream(self, project_id: str) -> str: + """Trigger SYNO.Docker.Project/build_stream and return the full log text. 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 on success; we send the request, check - the response headers, and close without consuming the SSE body. DSM - starts the build upon receiving the request and continues server-side - regardless of whether the HTTP connection stays open. Callers should - poll SYNO.Docker.Project/list for the resulting RUNNING status. + Container Manager (confirmed via browser DevTools). The endpoint + returns a streamed plaintext response (one short status line per step, + e.g. ``Container Running`` on success or `` Error`` followed + by ``Error response from daemon: `` on failure). DSM closes the + stream when the build is done. - Error detection: DSM signals application-level rejection (e.g. project - locked, invalid id) as an HTTP-200 JSON body `{"success": false, ...}` - rather than as an SSE stream. We inspect the `Content-Type` header and, - when it is `application/json`, read a small capped prefix of the body - to surface the DSM error code immediately instead of forcing the caller - into a multi-minute polling timeout. SSE responses are not read. + The body is consumed line-by-line and returned as a single string so + callers (redeploy_project, create_project) can surface the real cause + of a failed build instead of waiting for the polling step to report + ``BUILD_FAILED`` with no context. + + Error detection precedence: + 1. HTTP status (4xx/5xx) → SynologyError, body not consumed. + 2. JSON content-type → DSM rejected the request before streaming + (e.g. project locked, invalid id); a ``success: false`` envelope is + raised as SynologyError. Malformed JSON is treated as accepted. + 3. Streamed plaintext → returned verbatim; per-line failure parsing + is the caller's responsibility (look for ``Error response from + daemon:`` or `` Error``). Args: project_id: Project UUID from SYNO.Docker.Project/list. + Returns: + The collected build log as a single string (may be empty if DSM + returned a non-JSON empty body). Appended with + :attr:`BUILD_STREAM_TIMEOUT_MARKER` when the wall-clock budget + ran out before DSM closed the stream — in that case the build is + still running server-side and the caller's polling step is the + authoritative status source. + Raises: SynologyError: If DSM rejects the build with a JSON error body, or if the HTTP response status indicates a transport-level error. @@ -466,16 +492,23 @@ class DsmClient: sys.stderr.flush() logger.debug("build_stream: project_id=%s", project_id) - # Fire-and-forget for the SSE body, but detect immediate JSON errors. - # The read timeout only applies to waiting for response *headers* and - # for the (small, capped) JSON error body we read; we never consume SSE - # events, so DSM's streaming cannot block this call indefinitely. + # Wall-clock deadline for the streamed body. Individual chunks get a + # generous per-read timeout because DSM may pause between status lines + # during a slow image pull. + loop = asyncio.get_event_loop() + deadline = loop.time() + self.BUILD_STREAM_BUDGET + try: async with http.stream( "GET", url, params=params, - timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=5.0), + timeout=httpx.Timeout( + connect=10.0, + read=60.0, + write=10.0, + pool=5.0, + ), ) as resp: try: resp.raise_for_status() @@ -488,8 +521,8 @@ class DsmClient: content_type = resp.headers.get("content-type", "") if "application/json" in content_type: - # DSM rejected the build — read the JSON error body (capped - # at ~4 KB; DSM error envelopes are tiny). + # DSM rejected the build before streaming — read the JSON + # error body (capped at ~4 KB; DSM error envelopes are tiny). body = b"" async for chunk in resp.aiter_bytes(): body += chunk @@ -500,17 +533,47 @@ class DsmClient: except json.JSONDecodeError: # Malformed response — treat as accepted and let the # caller's polling surface any real failure. - return + return "" if not parsed.get("success", True): code = parsed.get("error", {}).get("code", 0) raise SynologyError(_error_message(code, api), code=code) - # success=true with JSON content-type: odd, treat as accepted. - return - # SSE or anything else → fire-and-forget, close without reading. + # success=true with JSON content-type: no streamed log. + return "" + + # Streamed plaintext (DSM uses text/html for build_stream). + # Collect line-by-line until DSM closes the stream or the + # wall-clock budget runs out. + lines: list[str] = [] + timed_out = False + try: + async for raw_line in resp.aiter_lines(): + # Strip Windows line endings; aiter_lines already + # splits on \n but preserves trailing \r on some httpx + # versions. + line = raw_line.rstrip("\r\n") + if line: + lines.append(line) + if loop.time() >= deadline: + timed_out = True + break + except httpx.ReadTimeout: + # A chunk didn't arrive within the per-read window. DSM is + # still building server-side; surface what we have. + timed_out = True + + log_text = "\n".join(lines) + if timed_out: + log_text = ( + f"{log_text}\n{self.BUILD_STREAM_TIMEOUT_MARKER}" + if log_text + else self.BUILD_STREAM_TIMEOUT_MARKER + ) + return log_text except httpx.ReadTimeout: - # Headers not received within 10 s, but the GET request was already - # sent. DSM received it and started the build. Proceed to polling. - pass + # Headers not received within the connect/read window, but the GET + # was already sent. DSM received it and started the build; return + # the timeout marker so the caller knows there's no log yet. + return self.BUILD_STREAM_TIMEOUT_MARKER except httpx.HTTPError as e: # Other transport-level failures (ConnectError, ConnectTimeout, # WriteError, RemoteProtocolError, …) mean DSM never received the diff --git a/src/mcp_synology_container/modules/projects.py b/src/mcp_synology_container/modules/projects.py index 1334ca9..68de9ac 100644 --- a/src/mcp_synology_container/modules/projects.py +++ b/src/mcp_synology_container/modules/projects.py @@ -28,6 +28,40 @@ _BUILD_POLL_TIMEOUT = 300 # seconds for build_stream polling (image pull can be # the full _BUILD_POLL_TIMEOUT for nothing. _TERMINAL_FAILURE_STATUSES = frozenset({"BUILD_FAILED", "ERROR"}) +# Substrings that mark a line in the build_stream log as a failure. Live +# DSM capture: image-pull or container-start errors surface as either a +# bare " Error" status line OR a more verbose "Error response from +# daemon: " line emitted right after it. Both forms are treated as +# build failures by `_parse_build_stream_log`. +_BUILD_DAEMON_ERROR = "Error response from daemon:" + + +def _parse_build_stream_log(log: str) -> tuple[list[str], list[str]]: + """Split a build_stream log into (error_lines, info_lines). + + A line is classified as an error when it contains "Error response from + daemon:" or ends with " Error" (the per-service status DSM emits when + a container fails to start). Everything else — including success + markers like "Container Running" — is treated as info. + + Args: + log: Raw log text returned by ``DsmClient.trigger_build_stream``. + + Returns: + Tuple of (errors, info) lines with empty lines stripped. + """ + errors: list[str] = [] + info: list[str] = [] + for raw in log.splitlines(): + line = raw.strip() + if not line: + continue + if _BUILD_DAEMON_ERROR in line or line.endswith(" Error"): + errors.append(line) + else: + info.append(line) + return errors, info + def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None: """Register all project management tools with the MCP server.""" @@ -156,7 +190,26 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non # ── 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) + build_log = await client.trigger_build_stream(project_id) + build_errors, _ = _parse_build_stream_log(build_log) + if build_errors: + # Live build_stream reported a daemon error (e.g. manifest + # not found, container exited). Surface the cause now — + # no need to wait for polling to flip the project to + # BUILD_FAILED, and the daemon line is much more + # actionable than the bare status. + results.append(" Build failed — DSM reported:") + results.extend(f" {line}" for line in build_errors) + results.append( + f"\nProject '{project_name}' redeploy aborted (build_stream errors)." + ) + if stop_was_issued: + results.append( + f"Note: project '{project_name}' was stopped before this error and is " + f"now in STOPPED state. Run start_project('{project_name}') or retry " + f"redeploy_project to recover." + ) + return "\n".join(results) results.append(" Build request accepted by DSM.") # ── Step 3: Poll ────────────────────────────────────────────────── @@ -171,9 +224,9 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non results.append(" Project is RUNNING.") results.append(f"\nProject '{project_name}' redeployed successfully.") elif final_status in _TERMINAL_FAILURE_STATUSES: - # M-5: DSM signalled a hard failure during polling (e.g. - # image pull failed). Surface it immediately rather than - # waiting for the full timeout. + # M-5: DSM signalled a hard failure during polling. The + # build_stream log above was clean, so this is a late + # failure (e.g. container exited after start). results.append(f" Redeploy failed — project status is '{final_status}'.") if final_status == "BUILD_FAILED": results.append( @@ -332,8 +385,7 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non # ── Step 3: Trigger the build (pull images + start containers) ──────── results.append("Step 3/3: Triggering build_stream (image pull and start)...") try: - await client.trigger_build_stream(project_id) - results.append(" Build request accepted by DSM.") + build_log = await client.trigger_build_stream(project_id) except Exception as e: results.append(f" Error triggering build: {e}") results.append( @@ -342,6 +394,18 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non ) return "\n".join(results) + build_errors, _ = _parse_build_stream_log(build_log) + if build_errors: + results.append(" Build failed — DSM reported:") + results.extend(f" {line}" for line in build_errors) + results.append( + f"\nProject '{project_name}' is registered but failed to build. " + f"Fix the compose content (e.g. update_image_tag) and run " + f"redeploy_project('{project_name}', confirmed=True) to retry." + ) + return "\n".join(results) + results.append(" Build request accepted by DSM.") + results.append( f"Waiting for project to reach RUNNING state (up to {_BUILD_POLL_TIMEOUT}s)..." ) diff --git a/tests/test_dsm_client.py b/tests/test_dsm_client.py index e20b9c2..492c2fa 100644 --- a/tests/test_dsm_client.py +++ b/tests/test_dsm_client.py @@ -6,8 +6,8 @@ Covers the critical paths of DsmClient: sensitive-param log masking. - Session re-auth retry: single-retry semantics, thundering-herd, auth-manager-absent, re-auth failure. -- trigger_build_stream: SSE fire-and-forget, JSON error detection, - ReadTimeout swallowing, HTTP-error scrubbing. +- trigger_build_stream: streamed log collection, JSON error detection, + ReadTimeout marker, HTTP-error scrubbing. - upload_text / download_text happy-path + error-response. - _ensure_initialized double-checked-locking and M4 negative-cache cooldown. @@ -490,27 +490,65 @@ async def test_request_reauth_thundering_herd_login_called_once() -> None: # ────────────────────────────────────────────────────────────────────── +async def _aiter_from_lines(lines: list[str]): + """Async iterator that yields the given strings (httpx aiter_lines shape).""" + for line in lines: + yield line + + @pytest.mark.asyncio -async def test_build_stream_sse_fire_and_forget_does_not_read_body() -> None: +async def test_build_stream_collects_streamed_log_lines() -> None: + """The streamed plaintext body is consumed line-by-line and returned.""" async with DsmClient(base_url="https://nas.local:443") as client: mark_initialized(client) client._http = AsyncMock() resp = make_response( - {"success": True}, - content_type="text/event-stream", + {"placeholder": True}, + content_type="text/html", + ) + resp.aiter_lines = lambda: _aiter_from_lines( + [ + "Container vault Running", + "Container vault-db Running", + "", + ] ) - - # If the code ever tries to read the SSE body, blow up the test. - def _boom(*_a: Any, **_k: Any): - raise AssertionError("SSE body must not be read") - - resp.aiter_bytes = MagicMock(side_effect=_boom) client._http.stream = MagicMock(return_value=make_stream_ctx(resp)) result = await client.trigger_build_stream("proj-1") - assert result is None + assert isinstance(result, str) + assert "Container vault Running" in result + assert "Container vault-db Running" in result + # Empty lines are dropped. + assert "\n\n" not in result + + +@pytest.mark.asyncio +async def test_build_stream_collects_failure_log_with_daemon_error() -> None: + """Failure log (svc Error + daemon-error line) is returned verbatim.""" + async with DsmClient(base_url="https://nas.local:443") as client: + mark_initialized(client) + client._http = AsyncMock() + + resp = make_response({"placeholder": True}, content_type="text/html") + resp.aiter_lines = lambda: _aiter_from_lines( + [ + "nginx Error", + ( + "Error response from daemon: manifest for nginx:9.9.9-nonexistent " + "not found: manifest unknown" + ), + ] + ) + client._http.stream = MagicMock(return_value=make_stream_ctx(resp)) + + result = await client.trigger_build_stream("proj-1") + + assert "nginx Error" in result + assert "Error response from daemon:" in result + assert "manifest unknown" in result @pytest.mark.asyncio @@ -532,6 +570,7 @@ async def test_build_stream_json_error_raises_synology_error() -> None: @pytest.mark.asyncio async def test_build_stream_json_success_accepted() -> None: + """JSON envelope with success=true → no streamed log, returns empty string.""" async with DsmClient(base_url="https://nas.local:443") as client: mark_initialized(client) client._http = AsyncMock() @@ -543,11 +582,12 @@ async def test_build_stream_json_success_accepted() -> None: result = await client.trigger_build_stream("proj-1") - assert result is None + assert result == "" @pytest.mark.asyncio -async def test_build_stream_read_timeout_swallowed() -> None: +async def test_build_stream_read_timeout_returns_marker() -> None: + """Header-arrival ReadTimeout returns the timeout marker (build still running).""" async with DsmClient(base_url="https://nas.local:443") as client: mark_initialized(client) client._http = AsyncMock() @@ -560,7 +600,60 @@ async def test_build_stream_read_timeout_swallowed() -> None: result = await client.trigger_build_stream("proj-1") - assert result is None + assert result == DsmClient.BUILD_STREAM_TIMEOUT_MARKER + + +@pytest.mark.asyncio +async def test_build_stream_per_chunk_read_timeout_returns_partial_log() -> None: + """If a streamed chunk times out mid-build, return the partial log + marker.""" + + async def aiter_partial_then_timeout(): + yield "Container vault Pulling" + raise httpx.ReadTimeout("chunk timed out") + + async with DsmClient(base_url="https://nas.local:443") as client: + mark_initialized(client) + client._http = AsyncMock() + + resp = make_response({"placeholder": True}, content_type="text/html") + resp.aiter_lines = aiter_partial_then_timeout + client._http.stream = MagicMock(return_value=make_stream_ctx(resp)) + + result = await client.trigger_build_stream("proj-1") + + assert "Container vault Pulling" in result + assert DsmClient.BUILD_STREAM_TIMEOUT_MARKER in result + + +@pytest.mark.asyncio +async def test_build_stream_wallclock_budget_breaks_loop(monkeypatch) -> None: + """When the wall-clock budget is exhausted, the loop returns partial log + marker.""" + import mcp_synology_container.dsm_client as dsm_mod + + # Force the budget to a value just below the initial loop.time() so the + # check after the first emitted line trips immediately. + monkeypatch.setattr(DsmClient, "BUILD_STREAM_BUDGET", -1.0) + + async def aiter_many(): + yield "Container vault Pulling" + yield "Container vault Running" # Should NOT appear in the result. + + async with DsmClient(base_url="https://nas.local:443") as client: + mark_initialized(client) + client._http = AsyncMock() + + resp = make_response({"placeholder": True}, content_type="text/html") + resp.aiter_lines = aiter_many + client._http.stream = MagicMock(return_value=make_stream_ctx(resp)) + + result = await client.trigger_build_stream("proj-1") + + assert "Container vault Pulling" in result + assert "Container vault Running" not in result + assert DsmClient.BUILD_STREAM_TIMEOUT_MARKER in result + + # Keep dsm_mod referenced so the test doesn't trip an unused-import warning. + assert dsm_mod.DsmClient is DsmClient @pytest.mark.asyncio @@ -628,7 +721,7 @@ async def test_build_stream_http_500_scrubs_sid() -> None: @pytest.mark.asyncio async def test_build_stream_malformed_json_body_treated_as_accepted() -> None: - """Defensive branch: JSON content-type but body fails to parse → return None.""" + """Defensive branch: JSON content-type but body fails to parse → empty string.""" async with DsmClient(base_url="https://nas.local:443") as client: mark_initialized(client) client._http = AsyncMock() @@ -639,7 +732,7 @@ async def test_build_stream_malformed_json_body_treated_as_accepted() -> None: result = await client.trigger_build_stream("proj-1") - assert result is None + assert result == "" # ────────────────────────────────────────────────────────────────────── diff --git a/tests/test_modules/test_projects.py b/tests/test_modules/test_projects.py index 93b8592..5abb23b 100644 --- a/tests/test_modules/test_projects.py +++ b/tests/test_modules/test_projects.py @@ -210,6 +210,7 @@ def make_stateful_redeploy_mock( initial_status: str, stop_raises=None, build_stream_raises=None, + build_stream_log: str = "", ): """Create a stateful client mock for redeploy tests. @@ -235,6 +236,7 @@ def make_stateful_redeploy_mock( if build_stream_raises: raise build_stream_raises build_done = True # After build_stream, polling returns RUNNING + return build_stream_log client.request.side_effect = mock_request client.trigger_build_stream = AsyncMock(side_effect=mock_trigger_build_stream) @@ -346,6 +348,7 @@ async def test_redeploy_poll_timeout(): async def mock_build_stream(project_id): nonlocal build_done build_done = True + return "" client.request.side_effect = mock_request client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream) @@ -374,7 +377,7 @@ async def test_redeploy_unknown_status_returns_error(): return {} client.request.side_effect = mock_request - client.trigger_build_stream = AsyncMock() + client.trigger_build_stream = AsyncMock(return_value="") tools = make_projects_tools(client) result = await tools["redeploy_project"]("myapp", confirmed=True) @@ -435,6 +438,111 @@ async def test_redeploy_build_stream_error_on_stopped_project_keeps_old_workarou assert "was stopped before this error" not in result +# ────────────────────────────────────────────────────────────────────── +# Issue #2: surface build_stream daemon errors directly +# ────────────────────────────────────────────────────────────────────── + + +def test_parse_build_stream_log_extracts_errors_and_info(): + """The helper splits a streamed build log into error and info lines.""" + from mcp_synology_container.modules.projects import _parse_build_stream_log + + log = ( + "Container vault Pulling\n" + "nginx Error\n" + "Error response from daemon: manifest for nginx:9.9.9 not found: " + "manifest unknown\n" + "\n" + "Container vault Running\n" + ) + + errors, info = _parse_build_stream_log(log) + + assert errors == [ + "nginx Error", + ("Error response from daemon: manifest for nginx:9.9.9 not found: manifest unknown"), + ] + assert info == ["Container vault Pulling", "Container vault Running"] + + +def test_parse_build_stream_log_clean_log_no_errors(): + from mcp_synology_container.modules.projects import _parse_build_stream_log + + log = "Container vault Running\nContainer vault-db Running\n" + errors, info = _parse_build_stream_log(log) + + assert errors == [] + assert info == ["Container vault Running", "Container vault-db Running"] + + +@pytest.mark.asyncio +async def test_redeploy_surfaces_build_stream_daemon_error(): + """build_stream log containing 'Error response from daemon:' aborts redeploy + with that line visible — and skips the polling step entirely.""" + client, calls = make_stateful_redeploy_mock( + "RUNNING", + build_stream_log=( + "nginx Error\n" + "Error response from daemon: manifest for nginx:9.9.9-nonexistent " + "not found: manifest unknown\n" + ), + ) + tools = make_projects_tools(client) + + with patch("mcp_synology_container.modules.projects.asyncio.sleep"): + result = await tools["redeploy_project"]("myapp", confirmed=True) + + assert "Build failed" in result + assert "Error response from daemon: manifest for nginx:9.9.9-nonexistent" in result + assert "redeploy aborted" in result + assert "redeployed successfully" not in result + + # Recovery hint must appear because the project was stopped first. + assert "was stopped before this error" in result + + # The polling step must NOT run — build_stream errors short-circuit. + methods = [m for _, m in calls] + list_calls = [m for m in methods if m == "list"] + assert len(list_calls) == 1 # only the initial _find_project lookup + + +@pytest.mark.asyncio +async def test_redeploy_clean_log_proceeds_to_polling(): + """A clean build_stream log keeps the original happy-path behavior.""" + client, calls = make_stateful_redeploy_mock( + "RUNNING", + build_stream_log="Container myapp Pulling\nContainer myapp Running\n", + ) + 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_create_project_surfaces_build_stream_daemon_error(): + """build_stream log with daemon error → registered-but-failed-to-build hint.""" + client, calls = make_create_project_client( + build_stream_log=( + "web Error\n" + "Error response from daemon: manifest for nginx:0.0.0-bad not found: " + "manifest unknown\n" + ), + ) + tools = make_projects_tools(client) + + with patch("mcp_synology_container.modules.projects.asyncio.sleep"): + result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True) + + assert "Build failed" in result + assert "Error response from daemon: manifest for nginx:0.0.0-bad" in result + assert "is registered but failed to build" in result + assert "redeploy_project" in result + assert "created and started successfully" not in result + + # ────────────────────────────────────────────────────────────────────── # M-5: polling exits early on BUILD_FAILED / ERROR # ────────────────────────────────────────────────────────────────────── @@ -501,6 +609,7 @@ async def test_redeploy_surfaces_build_failed_with_hint(): async def mock_build_stream(project_id): nonlocal build_done build_done = True + return "" # Clean stream log — failure surfaces via polling. client.request.side_effect = mock_request client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream) @@ -539,6 +648,7 @@ def make_create_project_client( create_folder_raises: Exception | None = None, create_project_raises: Exception | None = None, build_stream_raises: Exception | None = None, + build_stream_log: str = "", project_id: str = "uuid-new", final_status: str = "RUNNING", ): @@ -588,6 +698,7 @@ def make_create_project_client( calls.append(("SYNO.Docker.Project", "build_stream", {"id": pid})) if build_stream_raises: raise build_stream_raises + return build_stream_log client.request.side_effect = mock_request client.post_request.side_effect = mock_post_request diff --git a/uv.lock b/uv.lock index 6549658..e651239 100644 --- a/uv.lock +++ b/uv.lock @@ -362,7 +362,7 @@ wheels = [ [[package]] name = "mcp-synology-container" -version = "0.5.1" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "click" },