feat: v0.2.5 — redeploy via build_stream (proper DSM image pull)

Replace the delete-before-start workaround with the real mechanism:
SYNO.Docker.Project/build_stream is what the DSM "Erstellen" button calls
(confirmed via DevTools). It pulls updated images and starts the project.

DsmClient.trigger_build_stream(project_id): fires a streaming GET to
build_stream, reads the first SSE chunk to confirm DSM accepted the
request, then closes. ReadTimeout is swallowed (build running server-side).
Immediate JSON error responses are parsed and raised as SynologyError.

redeploy_project simplified from 4 steps to 3:
  1. Stop (skip for STOPPED, suppress for BUILD_FAILED)
  2. trigger_build_stream — DSM pulls images + starts project
  3. Poll for RUNNING (timeout raised from 30s → 5min for large pulls)
build_stream errors are now fatal (abort with clear message).

Removes _read_compose_images_for_project, _try_delete_image and their
json/yaml/re imports — no longer needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 08:32:03 +02:00
parent bafa327412
commit ebe3baba78
5 changed files with 175 additions and 284 deletions
+67
View File
@@ -11,6 +11,7 @@ Thin async client wrapping Synology DSM Web API conventions:
from __future__ import annotations
import asyncio
import json as _json
import logging
import sys
from typing import TYPE_CHECKING, Any
@@ -358,6 +359,72 @@ 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.
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 that reports build/pull progress; we read
enough of it to confirm DSM accepted the request, then close the HTTP
connection. The build (image pull + container start) continues server-side
regardless of whether the connection stays open. Callers should poll
SYNO.Docker.Project/list for the resulting RUNNING status.
Args:
project_id: Project UUID from SYNO.Docker.Project/list.
Raises:
SynologyError: If DSM returns an immediate JSON error response.
httpx.HTTPStatusError: If the HTTP request itself fails.
"""
await self._ensure_initialized()
http = self._get_http()
api = "SYNO.Docker.Project"
if api not in self._api_cache:
raise SynologyError(f"API '{api}' not found. Call query_api_info() first.", code=102)
info = self._api_cache[api]
url = f"{self._base_url}/webapi/{info['path']}"
params: dict[str, Any] = {
"api": api,
"version": "1",
"method": "build_stream",
"id": project_id,
}
if self._sid:
params["_sid"] = self._sid
sys.stderr.write(f"[dsm] trigger_build_stream: project={project_id}\n")
sys.stderr.flush()
logger.debug("build_stream: project_id=%s", project_id)
# Short read timeout so we return quickly once DSM starts streaming.
# The build continues server-side after this connection closes.
try:
async with http.stream(
"GET",
url,
params=params,
timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=5.0),
) as resp:
resp.raise_for_status()
content_type = resp.headers.get("content-type", "")
if "application/json" in content_type:
# Immediate JSON error (e.g. bad project id, permission denied)
raw = await resp.aread()
data = _json.loads(raw)
if not data.get("success"):
code = data.get("error", {}).get("code", 0)
raise SynologyError(_error_message(code, api), code=code)
return
# SSE stream: read until first event chunk to confirm DSM started.
async for _chunk in resp.aiter_bytes():
break # Got first byte — build is underway on the NAS
except httpx.ReadTimeout:
# Stream is still open on DSM side; the build is running. Expected.
pass
async def upload_text(
self,
dest_folder: str,
+29 -204
View File
@@ -4,13 +4,9 @@ from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import re
from typing import TYPE_CHECKING, Any
import yaml
if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP
@@ -20,16 +16,8 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
_POLL_INTERVAL = 2 # seconds between status checks
_POLL_TIMEOUT = 30 # maximum seconds to wait for RUNNING
# Compose file names probed in priority order (mirrors compose.py)
_COMPOSE_FILENAMES = [
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
]
_VOLUME_PREFIX_RE = re.compile(r"^/volume\d+")
_POLL_TIMEOUT = 30 # seconds for ordinary start polling
_BUILD_POLL_TIMEOUT = 300 # seconds for build_stream polling (image pull can be slow)
def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
@@ -134,18 +122,17 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
@mcp.tool()
async def redeploy_project(project_name: str, confirmed: bool = False) -> str:
"""Redeploy a project, forcing a fresh image pull via delete-before-start.
"""Redeploy a project, pulling the latest images via SYNO.Docker.Project/build_stream.
Unified 4-step flow for all project states:
This is the programmatic equivalent of the DSM "Erstellen" (Build) button.
Unified 3-step flow for all project states:
Step 1 — Stop (skipped for STOPPED; error-suppressed for BUILD_FAILED)
Step 2 — Delete cached images from compose.yaml so that DSM/Docker
pulls the latest version when the project starts.
Image deletion is non-fatal: if it fails the project still
starts (possibly using the cached image).
Step 3 — Start project (DSM auto-pulls missing images)
Step 4 — Poll SYNO.Docker.Project/list every 2 s for up to 30 s
until the project reaches RUNNING. A warning is emitted on
Step 2 — Trigger build_stream: DSM pulls updated images and starts the
project. This is a Server-Sent Events endpoint; the call
returns once DSM confirms it accepted the request.
Step 3 — Poll SYNO.Docker.Project/list every 2 s for up to 5 min
until the project reaches RUNNING. A warning is emitted on
timeout instead of returning an error.
Requires confirmation before executing.
@@ -157,7 +144,7 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
if not confirmed:
return (
f"Redeploying project '{project_name}' will stop and restart all its "
f"containers, deleting cached images to force a fresh pull.\n\n"
f"containers, pulling the latest images.\n\n"
f"Call this tool again with confirmed=True to proceed."
)
@@ -176,49 +163,43 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
results: list[str] = []
# Read compose images before stopping (best-effort; empty list on failure)
images_to_delete = await _read_compose_images_for_project(client, config, project_name)
try:
# ── Step 1: Stop ──────────────────────────────────────────────────
if status == "STOPPED":
results.append("Step 1/4: Project is STOPPED — skipping stop.")
results.append("Step 1/3: Project is STOPPED — skipping stop.")
elif status in ("BUILD_FAILED", ""):
results.append("Step 1/4: Stopping failed build...")
results.append("Step 1/3: Stopping failed build...")
with contextlib.suppress(Exception):
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
results.append(" Stopped.")
else: # RUNNING
results.append("Step 1/4: Stopping project...")
results.append("Step 1/3: Stopping project...")
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
results.append(" Stopped.")
# ── Step 2: Delete cached images ──────────────────────────────────
results.append("Step 2/4: Removing cached images to force re-pull...")
if images_to_delete:
for img_ref in images_to_delete:
results.append(f" {img_ref}:")
await _try_delete_image(client, img_ref, results)
else:
results.append(" Compose file not readable — skipping image removal.")
# ── 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)
results.append(" Build request accepted by DSM.")
# ── Step 3: Start ─────────────────────────────────────────────────
results.append("Step 3/4: Starting project...")
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
results.append(" Start issued.")
# ── Step 4: Poll ──────────────────────────────────────────────────
results.append("Step 4/4: Waiting for project to reach RUNNING state...")
final_status = await _wait_for_project_running(client, project_name)
# ── Step 3: Poll ──────────────────────────────────────────────────
results.append(
f"Step 3/3: Waiting for project to reach RUNNING state "
f"(up to {_BUILD_POLL_TIMEOUT}s)..."
)
final_status = await _wait_for_project_running(
client, project_name, timeout=_BUILD_POLL_TIMEOUT
)
if final_status == "RUNNING":
results.append(" Project is RUNNING.")
results.append(f"\nProject '{project_name}' redeployed successfully.")
else:
results.append(
f" Warning: project status is '{final_status}' after {_POLL_TIMEOUT}s. "
f" Warning: project status is '{final_status}' after "
f"{_BUILD_POLL_TIMEOUT}s. "
f"Containers may still be starting — check with get_project_status."
)
results.append(f"\nProject '{project_name}' start issued (status: {final_status}).")
results.append(f"\nProject '{project_name}' build issued (status: {final_status}).")
except Exception as e:
results.append(f"Error during redeploy: {e}")
@@ -249,162 +230,6 @@ async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None:
return None
async def _read_compose_images_for_project(
client: DsmClient,
config: AppConfig,
project_name: str,
) -> list[str]:
"""Read all image references from a project's compose file.
Uses the project's filesystem path from the DSM API, then lists the
directory via FileStation to find the compose file, downloads and
parses it.
Args:
client: DsmClient instance.
config: AppConfig (provides compose_base_path fallback).
project_name: Name of the project.
Returns:
Deduplicated list of image ref strings (e.g. ["nginx:1.24"]).
Returns an empty list on any error.
"""
project = await _find_project(client, project_name)
if project is None:
return []
raw_path = project.get("path", "").rstrip("/")
if not raw_path:
raw_path = f"{config.compose_base_path}/{project_name}"
fs_base = _VOLUME_PREFIX_RE.sub("", raw_path)
try:
data = await client.request(
"SYNO.FileStation.List",
"list",
params={"folder_path": fs_base, "additional": "[]"},
)
names_present = {f.get("name", "") for f in data.get("files", [])}
except Exception as e:
logger.debug("Could not list compose directory '%s': %s", fs_base, e)
return []
compose_path: str | None = None
for fname in _COMPOSE_FILENAMES:
if fname in names_present:
compose_path = f"{fs_base}/{fname}"
break
if compose_path is None:
logger.debug("No compose file found in '%s'", fs_base)
return []
try:
content = await client.download_text(compose_path)
parsed = yaml.safe_load(content)
except Exception as e:
logger.debug("Could not read/parse compose file '%s': %s", compose_path, e)
return []
if not isinstance(parsed, dict):
return []
images: set[str] = set()
for service in (parsed.get("services") or {}).values():
img = (service or {}).get("image", "")
if img:
images.add(img)
return list(images)
async def _try_delete_image(
client: DsmClient,
image_ref: str,
results: list[str],
) -> None:
"""Best-effort delete of a local Docker image to force re-pull on start.
Only blocks deletion if a *running* container (not from this project)
uses the image. Stopped-container references are ignored so that the
just-stopped project containers do not prevent deletion.
All DSM errors are non-fatal.
Args:
client: DsmClient instance.
image_ref: Image reference as "name:tag".
results: Output list; status lines are appended here.
"""
name, sep, tag = image_ref.rpartition(":")
if not sep:
name = image_ref
tag = "latest"
# Locate the image in the local registry
try:
img_data = await client.request(
"SYNO.Docker.Image",
"list",
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
)
except Exception as e:
results.append(f" Could not list images: {e}")
return
images: list[dict[str, Any]] = img_data.get("images", [])
target: dict[str, Any] | None = None
for img in images:
if img.get("repository", "") == name and tag in (img.get("tags") or []):
target = img
break
if target is None:
results.append(f" '{image_ref}' not in local cache — DSM will pull on start.")
return
img_hash = target.get("id", "")
hash_prefix = img_hash[:12] if img_hash else ""
# Block only on running containers (stopped containers are non-blocking)
try:
ctr_data = await client.request(
"SYNO.Docker.Container",
"list",
params={"limit": "-1", "offset": "0", "type": "all"},
)
for ctr in ctr_data.get("containers", []):
ctr_img_id = ctr.get("image_id") or ctr.get("ImageID") or ctr.get("Image", "")
if (
img_hash
and (ctr_img_id == img_hash or (hash_prefix and ctr_img_id.startswith(hash_prefix)))
and ctr.get("status", ctr.get("state", "")).lower() == "running"
):
ctr_name = ctr.get("name", "?")
results.append(
f" Skipped: '{image_ref}' is used by running container '{ctr_name}'."
)
return
except Exception:
pass # Best-effort check; proceed with delete attempt
# Attempt deletion
repo = target.get("repository", name)
img_tags = target.get("tags") or [tag]
images_param = json.dumps([{"repository": repo, "tags": [img_tags[0]]}])
try:
await client.post_request(
"SYNO.Docker.Image",
"delete",
version=1,
params={"images": images_param},
)
results.append(f" Removed '{image_ref}' from local cache.")
except Exception as e:
results.append(f" Could not remove '{image_ref}' (non-fatal): {e}")
async def _wait_for_project_running(
client: DsmClient,
name: str,