feat: v0.6.0 — read build_stream log instead of dropping it (#2)

DSM emits a readable plaintext build log over the build_stream 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, leaving 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. Wall-clock budget of 210 s
(under the Claude Desktop ~4 min ceiling); on timeout the partial log
is returned with a "[build_stream: timeout — stream still open
server-side]" marker so callers know the build continues server-side.
Per-chunk ReadTimeout is treated the same way. JSON error envelope,
transport-error mapping (M-4), and SID-scrubbed HTTP-error formatting
are unchanged.

redeploy_project and create_project now parse the returned log via
_parse_build_stream_log (any line containing "Error response from
daemon:" or ending in " Error" counts as a failure). On a failed log
the tools abort immediately, surface the daemon line(s) in the result
(e.g. "Error response from daemon: manifest for nginx:9.9.9 not
found: manifest unknown"), and skip the polling step. The BUILD_FAILED
polling guard (M-5) stays as a second safety net for late failures
where the stream 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.

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.

Tests: streamed-log collection, daemon-error log, header ReadTimeout
marker, per-chunk ReadTimeout partial log, wall-clock budget
truncation, _parse_build_stream_log unit tests, redeploy/create end-
to-end behavior with a failing log.

Closes #2

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 13:58:55 +02:00
parent 18fe063691
commit 036429e9bf
8 changed files with 441 additions and 54 deletions
+42
View File
@@ -2,6 +2,48 @@
All notable changes to this project will be documented in this file. 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 ## [0.5.1] - 2026-05-18
### Fixed ### Fixed
+14
View File
@@ -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 - **Async project start** — `SYNO.Docker.Project/start` returns immediately
while containers are still initialising. `redeploy_project` polls 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/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 <name> Running` on success, `<svc> Error` followed by
`Error response from daemon: <cause>` 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 - **Image delete** — requires a form-encoded POST with a JSON `images` array
(confirmed via browser DevTools); uses `DsmClient.post_request()`. (confirmed via browser DevTools); uses `DsmClient.post_request()`.
- **`SYNO.Docker.Image/pull` vs. `pull_start`** — the legacy `pull` method - **`SYNO.Docker.Image/pull` vs. `pull_start`** — the legacy `pull` method
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "mcp-synology-container" name = "mcp-synology-container"
version = "0.5.1" version = "0.6.0"
description = "MCP server for Synology Container Manager" description = "MCP server for Synology Container Manager"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
+91 -28
View File
@@ -419,27 +419,53 @@ class DsmClient:
logger.debug("DSM POST response: %s/%s — error code %d", api, method, code) logger.debug("DSM POST response: %s/%s — error code %d", api, method, code)
raise SynologyError(_error_message(code, api), code=code) raise SynologyError(_error_message(code, api), code=code)
async def trigger_build_stream(self, project_id: str) -> None: # Wall-clock budget for consuming the build_stream body. DSM keeps the
"""Trigger SYNO.Docker.Project/build_stream — the "Erstellen" button equivalent. # 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 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 Container Manager (confirmed via browser DevTools). The endpoint
Server-Sent Events (SSE) stream on success; we send the request, check returns a streamed plaintext response (one short status line per step,
the response headers, and close without consuming the SSE body. DSM e.g. ``Container <name> Running`` on success or ``<svc> Error`` followed
starts the build upon receiving the request and continues server-side by ``Error response from daemon: <cause>`` on failure). DSM closes the
regardless of whether the HTTP connection stays open. Callers should stream when the build is done.
poll SYNO.Docker.Project/list for the resulting RUNNING status.
Error detection: DSM signals application-level rejection (e.g. project The body is consumed line-by-line and returned as a single string so
locked, invalid id) as an HTTP-200 JSON body `{"success": false, ...}` callers (redeploy_project, create_project) can surface the real cause
rather than as an SSE stream. We inspect the `Content-Type` header and, of a failed build instead of waiting for the polling step to report
when it is `application/json`, read a small capped prefix of the body ``BUILD_FAILED`` with no context.
to surface the DSM error code immediately instead of forcing the caller
into a multi-minute polling timeout. SSE responses are not read. 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 ``<svc> Error``).
Args: Args:
project_id: Project UUID from SYNO.Docker.Project/list. 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: Raises:
SynologyError: If DSM rejects the build with a JSON error body, or SynologyError: If DSM rejects the build with a JSON error body, or
if the HTTP response status indicates a transport-level error. if the HTTP response status indicates a transport-level error.
@@ -466,16 +492,23 @@ class DsmClient:
sys.stderr.flush() sys.stderr.flush()
logger.debug("build_stream: project_id=%s", project_id) logger.debug("build_stream: project_id=%s", project_id)
# Fire-and-forget for the SSE body, but detect immediate JSON errors. # Wall-clock deadline for the streamed body. Individual chunks get a
# The read timeout only applies to waiting for response *headers* and # generous per-read timeout because DSM may pause between status lines
# for the (small, capped) JSON error body we read; we never consume SSE # during a slow image pull.
# events, so DSM's streaming cannot block this call indefinitely. loop = asyncio.get_event_loop()
deadline = loop.time() + self.BUILD_STREAM_BUDGET
try: try:
async with http.stream( async with http.stream(
"GET", "GET",
url, url,
params=params, 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: ) as resp:
try: try:
resp.raise_for_status() resp.raise_for_status()
@@ -488,8 +521,8 @@ class DsmClient:
content_type = resp.headers.get("content-type", "") content_type = resp.headers.get("content-type", "")
if "application/json" in content_type: if "application/json" in content_type:
# DSM rejected the build — read the JSON error body (capped # DSM rejected the build before streaming — read the JSON
# at ~4 KB; DSM error envelopes are tiny). # error body (capped at ~4 KB; DSM error envelopes are tiny).
body = b"" body = b""
async for chunk in resp.aiter_bytes(): async for chunk in resp.aiter_bytes():
body += chunk body += chunk
@@ -500,17 +533,47 @@ class DsmClient:
except json.JSONDecodeError: except json.JSONDecodeError:
# Malformed response — treat as accepted and let the # Malformed response — treat as accepted and let the
# caller's polling surface any real failure. # caller's polling surface any real failure.
return return ""
if not parsed.get("success", True): if not parsed.get("success", True):
code = parsed.get("error", {}).get("code", 0) code = parsed.get("error", {}).get("code", 0)
raise SynologyError(_error_message(code, api), code=code) raise SynologyError(_error_message(code, api), code=code)
# success=true with JSON content-type: odd, treat as accepted. # success=true with JSON content-type: no streamed log.
return return ""
# SSE or anything else → fire-and-forget, close without reading.
# 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: except httpx.ReadTimeout:
# Headers not received within 10 s, but the GET request was already # A chunk didn't arrive within the per-read window. DSM is
# sent. DSM received it and started the build. Proceed to polling. # still building server-side; surface what we have.
pass 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 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: except httpx.HTTPError as e:
# Other transport-level failures (ConnectError, ConnectTimeout, # Other transport-level failures (ConnectError, ConnectTimeout,
# WriteError, RemoteProtocolError, …) mean DSM never received the # WriteError, RemoteProtocolError, …) mean DSM never received the
+70 -6
View File
@@ -28,6 +28,40 @@ _BUILD_POLL_TIMEOUT = 300 # seconds for build_stream polling (image pull can be
# the full _BUILD_POLL_TIMEOUT for nothing. # the full _BUILD_POLL_TIMEOUT for nothing.
_TERMINAL_FAILURE_STATUSES = frozenset({"BUILD_FAILED", "ERROR"}) _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 "<svc> Error" status line OR a more verbose "Error response from
# daemon: <cause>" 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 <name> 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: def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
"""Register all project management tools with the MCP server.""" """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) ──────────────────── # ── Step 2: build_stream (pull images + start) ────────────────────
results.append("Step 2/3: Triggering image pull and project start (build_stream)...") 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.") results.append(" Build request accepted by DSM.")
# ── Step 3: Poll ────────────────────────────────────────────────── # ── Step 3: Poll ──────────────────────────────────────────────────
@@ -171,9 +224,9 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
results.append(" Project is RUNNING.") results.append(" Project is RUNNING.")
results.append(f"\nProject '{project_name}' redeployed successfully.") results.append(f"\nProject '{project_name}' redeployed successfully.")
elif final_status in _TERMINAL_FAILURE_STATUSES: elif final_status in _TERMINAL_FAILURE_STATUSES:
# M-5: DSM signalled a hard failure during polling (e.g. # M-5: DSM signalled a hard failure during polling. The
# image pull failed). Surface it immediately rather than # build_stream log above was clean, so this is a late
# waiting for the full timeout. # failure (e.g. container exited after start).
results.append(f" Redeploy failed — project status is '{final_status}'.") results.append(f" Redeploy failed — project status is '{final_status}'.")
if final_status == "BUILD_FAILED": if final_status == "BUILD_FAILED":
results.append( 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) ──────── # ── Step 3: Trigger the build (pull images + start containers) ────────
results.append("Step 3/3: Triggering build_stream (image pull and start)...") results.append("Step 3/3: Triggering build_stream (image pull and start)...")
try: try:
await client.trigger_build_stream(project_id) build_log = await client.trigger_build_stream(project_id)
results.append(" Build request accepted by DSM.")
except Exception as e: except Exception as e:
results.append(f" Error triggering build: {e}") results.append(f" Error triggering build: {e}")
results.append( results.append(
@@ -342,6 +394,18 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
) )
return "\n".join(results) 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( results.append(
f"Waiting for project to reach RUNNING state (up to {_BUILD_POLL_TIMEOUT}s)..." f"Waiting for project to reach RUNNING state (up to {_BUILD_POLL_TIMEOUT}s)..."
) )
+110 -17
View File
@@ -6,8 +6,8 @@ Covers the critical paths of DsmClient:
sensitive-param log masking. sensitive-param log masking.
- Session re-auth retry: single-retry semantics, thundering-herd, - Session re-auth retry: single-retry semantics, thundering-herd,
auth-manager-absent, re-auth failure. auth-manager-absent, re-auth failure.
- trigger_build_stream: SSE fire-and-forget, JSON error detection, - trigger_build_stream: streamed log collection, JSON error detection,
ReadTimeout swallowing, HTTP-error scrubbing. ReadTimeout marker, HTTP-error scrubbing.
- upload_text / download_text happy-path + error-response. - upload_text / download_text happy-path + error-response.
- _ensure_initialized double-checked-locking and M4 negative-cache cooldown. - _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 @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: async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client) mark_initialized(client)
client._http = AsyncMock() client._http = AsyncMock()
resp = make_response( resp = make_response(
{"success": True}, {"placeholder": True},
content_type="text/event-stream", 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)) client._http.stream = MagicMock(return_value=make_stream_ctx(resp))
result = await client.trigger_build_stream("proj-1") 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 @pytest.mark.asyncio
@@ -532,6 +570,7 @@ async def test_build_stream_json_error_raises_synology_error() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_build_stream_json_success_accepted() -> None: 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: async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client) mark_initialized(client)
client._http = AsyncMock() client._http = AsyncMock()
@@ -543,11 +582,12 @@ async def test_build_stream_json_success_accepted() -> None:
result = await client.trigger_build_stream("proj-1") result = await client.trigger_build_stream("proj-1")
assert result is None assert result == ""
@pytest.mark.asyncio @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: async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client) mark_initialized(client)
client._http = AsyncMock() client._http = AsyncMock()
@@ -560,7 +600,60 @@ async def test_build_stream_read_timeout_swallowed() -> None:
result = await client.trigger_build_stream("proj-1") 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 @pytest.mark.asyncio
@@ -628,7 +721,7 @@ async def test_build_stream_http_500_scrubs_sid() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_build_stream_malformed_json_body_treated_as_accepted() -> None: 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: async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client) mark_initialized(client)
client._http = AsyncMock() 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") result = await client.trigger_build_stream("proj-1")
assert result is None assert result == ""
# ────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────
+112 -1
View File
@@ -210,6 +210,7 @@ def make_stateful_redeploy_mock(
initial_status: str, initial_status: str,
stop_raises=None, stop_raises=None,
build_stream_raises=None, build_stream_raises=None,
build_stream_log: str = "",
): ):
"""Create a stateful client mock for redeploy tests. """Create a stateful client mock for redeploy tests.
@@ -235,6 +236,7 @@ def make_stateful_redeploy_mock(
if build_stream_raises: if build_stream_raises:
raise build_stream_raises raise build_stream_raises
build_done = True # After build_stream, polling returns RUNNING build_done = True # After build_stream, polling returns RUNNING
return build_stream_log
client.request.side_effect = mock_request client.request.side_effect = mock_request
client.trigger_build_stream = AsyncMock(side_effect=mock_trigger_build_stream) 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): async def mock_build_stream(project_id):
nonlocal build_done nonlocal build_done
build_done = True build_done = True
return ""
client.request.side_effect = mock_request client.request.side_effect = mock_request
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream) client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
@@ -374,7 +377,7 @@ async def test_redeploy_unknown_status_returns_error():
return {} return {}
client.request.side_effect = mock_request client.request.side_effect = mock_request
client.trigger_build_stream = AsyncMock() client.trigger_build_stream = AsyncMock(return_value="")
tools = make_projects_tools(client) tools = make_projects_tools(client)
result = await tools["redeploy_project"]("myapp", confirmed=True) 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 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 # 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): async def mock_build_stream(project_id):
nonlocal build_done nonlocal build_done
build_done = True build_done = True
return "" # Clean stream log — failure surfaces via polling.
client.request.side_effect = mock_request client.request.side_effect = mock_request
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream) 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_folder_raises: Exception | None = None,
create_project_raises: Exception | None = None, create_project_raises: Exception | None = None,
build_stream_raises: Exception | None = None, build_stream_raises: Exception | None = None,
build_stream_log: str = "",
project_id: str = "uuid-new", project_id: str = "uuid-new",
final_status: str = "RUNNING", final_status: str = "RUNNING",
): ):
@@ -588,6 +698,7 @@ def make_create_project_client(
calls.append(("SYNO.Docker.Project", "build_stream", {"id": pid})) calls.append(("SYNO.Docker.Project", "build_stream", {"id": pid}))
if build_stream_raises: if build_stream_raises:
raise build_stream_raises raise build_stream_raises
return build_stream_log
client.request.side_effect = mock_request client.request.side_effect = mock_request
client.post_request.side_effect = mock_post_request client.post_request.side_effect = mock_post_request
Generated
+1 -1
View File
@@ -362,7 +362,7 @@ wheels = [
[[package]] [[package]]
name = "mcp-synology-container" name = "mcp-synology-container"
version = "0.5.1" version = "0.6.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },