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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 = [
|
||||||
|
|||||||
@@ -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:
|
||||||
|
# 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:
|
except httpx.ReadTimeout:
|
||||||
# Headers not received within 10 s, but the GET request was already
|
# Headers not received within the connect/read window, but the GET
|
||||||
# sent. DSM received it and started the build. Proceed to polling.
|
# was already sent. DSM received it and started the build; return
|
||||||
pass
|
# 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
|
||||||
|
|||||||
@@ -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
@@ -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 == ""
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user