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
+91 -28
View File
@@ -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 <name> Running`` on success or ``<svc> Error`` followed
by ``Error response from daemon: <cause>`` 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 ``<svc> 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
+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.
_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:
"""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)..."
)