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:
@@ -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
|
||||
|
||||
@@ -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)..."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user