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:
@@ -2,6 +2,27 @@
|
|||||||
|
|
||||||
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.2.5] - 2026-04-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `redeploy_project`: Replaced the delete-before-start image workaround with
|
||||||
|
`SYNO.Docker.Project/build_stream` — the programmatic equivalent of the DSM
|
||||||
|
"Erstellen" (Build) button, confirmed via browser DevTools capture.
|
||||||
|
New 3-step flow for all project states:
|
||||||
|
1. Stop (skipped for STOPPED; error-suppressed for BUILD_FAILED)
|
||||||
|
2. `build_stream` — DSM pulls updated images and starts the project via SSE
|
||||||
|
3. Poll for RUNNING (timeout raised from 30 s to 5 min to accommodate image pulls)
|
||||||
|
`build_stream` errors are now fatal (abort the redeploy with a clear message).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `DsmClient.trigger_build_stream(project_id)` — fires a streaming GET to
|
||||||
|
`SYNO.Docker.Project/build_stream`, reads the first SSE chunk to confirm DSM
|
||||||
|
accepted the request, then closes the connection. The build continues
|
||||||
|
server-side. Handles immediate JSON error responses; swallows `ReadTimeout`
|
||||||
|
(stream still open = build running).
|
||||||
|
|
||||||
## [0.2.4] - 2026-04-21
|
## [0.2.4] - 2026-04-21
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.2.4"
|
version = "0.2.5"
|
||||||
description = "MCP server for Synology Container Manager"
|
description = "MCP server for Synology Container Manager"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Thin async client wrapping Synology DSM Web API conventions:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json as _json
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from typing import TYPE_CHECKING, Any
|
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)
|
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:
|
||||||
|
"""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(
|
async def upload_text(
|
||||||
self,
|
self,
|
||||||
dest_folder: str,
|
dest_folder: str,
|
||||||
|
|||||||
@@ -4,13 +4,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
@@ -20,16 +16,8 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_POLL_INTERVAL = 2 # seconds between status checks
|
_POLL_INTERVAL = 2 # seconds between status checks
|
||||||
_POLL_TIMEOUT = 30 # maximum seconds to wait for RUNNING
|
_POLL_TIMEOUT = 30 # seconds for ordinary start polling
|
||||||
|
_BUILD_POLL_TIMEOUT = 300 # seconds for build_stream polling (image pull can be slow)
|
||||||
# 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+")
|
|
||||||
|
|
||||||
|
|
||||||
def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
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()
|
@mcp.tool()
|
||||||
async def redeploy_project(project_name: str, confirmed: bool = False) -> str:
|
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 1 — Stop (skipped for STOPPED; error-suppressed for BUILD_FAILED)
|
||||||
Step 2 — Delete cached images from compose.yaml so that DSM/Docker
|
Step 2 — Trigger build_stream: DSM pulls updated images and starts the
|
||||||
pulls the latest version when the project starts.
|
project. This is a Server-Sent Events endpoint; the call
|
||||||
Image deletion is non-fatal: if it fails the project still
|
returns once DSM confirms it accepted the request.
|
||||||
starts (possibly using the cached image).
|
Step 3 — Poll SYNO.Docker.Project/list every 2 s for up to 5 min
|
||||||
Step 3 — Start project (DSM auto-pulls missing images)
|
until the project reaches RUNNING. A warning is emitted on
|
||||||
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
|
|
||||||
timeout instead of returning an error.
|
timeout instead of returning an error.
|
||||||
|
|
||||||
Requires confirmation before executing.
|
Requires confirmation before executing.
|
||||||
@@ -157,7 +144,7 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
if not confirmed:
|
if not confirmed:
|
||||||
return (
|
return (
|
||||||
f"Redeploying project '{project_name}' will stop and restart all its "
|
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."
|
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] = []
|
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:
|
try:
|
||||||
# ── Step 1: Stop ──────────────────────────────────────────────────
|
# ── Step 1: Stop ──────────────────────────────────────────────────
|
||||||
if status == "STOPPED":
|
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", ""):
|
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):
|
with contextlib.suppress(Exception):
|
||||||
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
|
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
|
||||||
results.append(" Stopped.")
|
results.append(" Stopped.")
|
||||||
else: # RUNNING
|
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})
|
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
|
||||||
results.append(" Stopped.")
|
results.append(" Stopped.")
|
||||||
|
|
||||||
# ── Step 2: Delete cached images ──────────────────────────────────
|
# ── Step 2: build_stream (pull images + start) ────────────────────
|
||||||
results.append("Step 2/4: Removing cached images to force re-pull...")
|
results.append("Step 2/3: Triggering image pull and project start (build_stream)...")
|
||||||
if images_to_delete:
|
await client.trigger_build_stream(project_id)
|
||||||
for img_ref in images_to_delete:
|
results.append(" Build request accepted by DSM.")
|
||||||
results.append(f" {img_ref}:")
|
|
||||||
await _try_delete_image(client, img_ref, results)
|
|
||||||
else:
|
|
||||||
results.append(" Compose file not readable — skipping image removal.")
|
|
||||||
|
|
||||||
# ── Step 3: Start ─────────────────────────────────────────────────
|
# ── Step 3: Poll ──────────────────────────────────────────────────
|
||||||
results.append("Step 3/4: Starting project...")
|
results.append(
|
||||||
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
|
f"Step 3/3: Waiting for project to reach RUNNING state "
|
||||||
results.append(" Start issued.")
|
f"(up to {_BUILD_POLL_TIMEOUT}s)..."
|
||||||
|
)
|
||||||
# ── Step 4: Poll ──────────────────────────────────────────────────
|
final_status = await _wait_for_project_running(
|
||||||
results.append("Step 4/4: Waiting for project to reach RUNNING state...")
|
client, project_name, timeout=_BUILD_POLL_TIMEOUT
|
||||||
final_status = await _wait_for_project_running(client, project_name)
|
)
|
||||||
if final_status == "RUNNING":
|
if final_status == "RUNNING":
|
||||||
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.")
|
||||||
else:
|
else:
|
||||||
results.append(
|
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."
|
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:
|
except Exception as e:
|
||||||
results.append(f"Error during redeploy: {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
|
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(
|
async def _wait_for_project_running(
|
||||||
client: DsmClient,
|
client: DsmClient,
|
||||||
name: str,
|
name: str,
|
||||||
|
|||||||
@@ -206,41 +206,44 @@ def project_list(status: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def make_stateful_redeploy_mock(initial_status: str, stop_raises=None):
|
def make_stateful_redeploy_mock(
|
||||||
|
initial_status: str,
|
||||||
|
stop_raises=None,
|
||||||
|
build_stream_raises=None,
|
||||||
|
):
|
||||||
"""Create a stateful client mock for redeploy tests.
|
"""Create a stateful client mock for redeploy tests.
|
||||||
|
|
||||||
Returns (client, calls_list). After ``start`` is called, subsequent
|
Returns (client, calls_list). After ``trigger_build_stream`` is called,
|
||||||
``list`` calls return RUNNING so the polling loop terminates immediately.
|
subsequent ``list`` calls return RUNNING so the polling loop terminates
|
||||||
asyncio.sleep is NOT patched here — patch it at call-site.
|
immediately. asyncio.sleep is NOT patched here — patch it at call-site.
|
||||||
|
|
||||||
FileStation.List returns an empty file list so compose image detection is
|
|
||||||
skipped (image deletion is tested separately).
|
|
||||||
"""
|
"""
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
calls = []
|
calls = []
|
||||||
start_called = False
|
build_done = False
|
||||||
|
|
||||||
async def mock_request(api, method, **kwargs):
|
async def mock_request(api, method, **kwargs):
|
||||||
nonlocal start_called
|
|
||||||
calls.append((api, method))
|
calls.append((api, method))
|
||||||
if api == "SYNO.FileStation.List":
|
|
||||||
return {"files": []} # No compose file → skip image deletion
|
|
||||||
if method == "start":
|
|
||||||
start_called = True
|
|
||||||
if method == "stop" and stop_raises:
|
if method == "stop" and stop_raises:
|
||||||
raise stop_raises
|
raise stop_raises
|
||||||
if method == "list":
|
if method == "list":
|
||||||
return project_list("RUNNING") if start_called else project_list(initial_status)
|
return project_list("RUNNING") if build_done else project_list(initial_status)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
async def mock_trigger_build_stream(project_id):
|
||||||
|
nonlocal build_done
|
||||||
|
calls.append(("SYNO.Docker.Project", "build_stream"))
|
||||||
|
if build_stream_raises:
|
||||||
|
raise build_stream_raises
|
||||||
|
build_done = True # After build_stream, polling returns RUNNING
|
||||||
|
|
||||||
client.request.side_effect = mock_request
|
client.request.side_effect = mock_request
|
||||||
client.post_request = AsyncMock()
|
client.trigger_build_stream = AsyncMock(side_effect=mock_trigger_build_stream)
|
||||||
return client, calls
|
return client, calls
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_redeploy_running_project():
|
async def test_redeploy_running_project():
|
||||||
"""RUNNING project: stop then start; polls until RUNNING."""
|
"""RUNNING project: stop → build_stream → poll until RUNNING."""
|
||||||
client, calls = make_stateful_redeploy_mock("RUNNING")
|
client, calls = make_stateful_redeploy_mock("RUNNING")
|
||||||
tools = make_projects_tools(client)
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
@@ -250,13 +253,13 @@ async def test_redeploy_running_project():
|
|||||||
assert "redeployed successfully" in result
|
assert "redeployed successfully" in result
|
||||||
methods = [m for _, m in calls]
|
methods = [m for _, m in calls]
|
||||||
assert "stop" in methods
|
assert "stop" in methods
|
||||||
assert "start" in methods
|
assert "build_stream" in methods
|
||||||
assert methods.index("stop") < methods.index("start")
|
assert methods.index("stop") < methods.index("build_stream")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_redeploy_stopped_project_starts_directly():
|
async def test_redeploy_stopped_project_skips_stop():
|
||||||
"""STOPPED project: skip stop, just start; polls until RUNNING."""
|
"""STOPPED project: skip stop, call build_stream directly; polls until RUNNING."""
|
||||||
client, calls = make_stateful_redeploy_mock("STOPPED")
|
client, calls = make_stateful_redeploy_mock("STOPPED")
|
||||||
tools = make_projects_tools(client)
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
@@ -266,13 +269,13 @@ async def test_redeploy_stopped_project_starts_directly():
|
|||||||
assert "redeployed successfully" in result
|
assert "redeployed successfully" in result
|
||||||
methods = [m for _, m in calls]
|
methods = [m for _, m in calls]
|
||||||
assert "stop" not in methods
|
assert "stop" not in methods
|
||||||
assert "start" in methods
|
assert "build_stream" in methods
|
||||||
assert "STOPPED" in result or "starting directly" in result.lower()
|
assert "STOPPED" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_redeploy_build_failed_project():
|
async def test_redeploy_build_failed_project():
|
||||||
"""BUILD_FAILED project: stop → (delete images) → start; polls until RUNNING."""
|
"""BUILD_FAILED project: stop (suppressed) → build_stream → poll until RUNNING."""
|
||||||
client, calls = make_stateful_redeploy_mock("BUILD_FAILED")
|
client, calls = make_stateful_redeploy_mock("BUILD_FAILED")
|
||||||
tools = make_projects_tools(client)
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
@@ -282,16 +285,16 @@ async def test_redeploy_build_failed_project():
|
|||||||
assert "redeployed successfully" in result
|
assert "redeployed successfully" in result
|
||||||
methods = [m for _, m in calls]
|
methods = [m for _, m in calls]
|
||||||
assert "stop" in methods
|
assert "stop" in methods
|
||||||
assert "start" in methods
|
assert "build_stream" in methods
|
||||||
assert methods.index("stop") < methods.index("start")
|
assert methods.index("stop") < methods.index("build_stream")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_redeploy_build_failed_stop_error_nonfatal():
|
async def test_redeploy_build_failed_stop_error_nonfatal():
|
||||||
"""BUILD_FAILED: stop failure is non-fatal and must not abort the redeploy."""
|
"""BUILD_FAILED: stop failure is non-fatal — build_stream must still be called."""
|
||||||
from mcp_synology_container.dsm_client import SynologyError
|
from mcp_synology_container.dsm_client import SynologyError
|
||||||
|
|
||||||
client, _ = make_stateful_redeploy_mock(
|
client, calls = make_stateful_redeploy_mock(
|
||||||
"BUILD_FAILED",
|
"BUILD_FAILED",
|
||||||
stop_raises=SynologyError("already stopped", code=2101),
|
stop_raises=SynologyError("already stopped", code=2101),
|
||||||
)
|
)
|
||||||
@@ -301,80 +304,57 @@ async def test_redeploy_build_failed_stop_error_nonfatal():
|
|||||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||||
|
|
||||||
assert "redeployed successfully" in result
|
assert "redeployed successfully" in result
|
||||||
|
methods = [m for _, m in calls]
|
||||||
|
assert "build_stream" in methods
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_redeploy_image_delete_failure_nonfatal():
|
async def test_redeploy_build_stream_error_aborts():
|
||||||
"""Image deletion failure must be non-fatal: start must still be called."""
|
"""If build_stream raises, redeploy must abort with a clear error message."""
|
||||||
client = AsyncMock()
|
from mcp_synology_container.dsm_client import SynologyError
|
||||||
start_called = False
|
|
||||||
|
|
||||||
async def mock_request(api, method, **kwargs):
|
|
||||||
nonlocal start_called
|
|
||||||
if api == "SYNO.FileStation.List":
|
|
||||||
# Return a compose file so that image listing is attempted
|
|
||||||
return {"files": [{"name": "docker-compose.yml"}]}
|
|
||||||
if api == "SYNO.Docker.Image" and method == "list":
|
|
||||||
# Return one image matching the compose service
|
|
||||||
return {
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"id": "sha256:abc123",
|
|
||||||
"repository": "nginx",
|
|
||||||
"tags": ["1.24"],
|
|
||||||
"size": 50000000,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
if api == "SYNO.Docker.Container" and method == "list":
|
|
||||||
return {"containers": []}
|
|
||||||
if method == "start":
|
|
||||||
start_called = True
|
|
||||||
if method == "list":
|
|
||||||
return project_list("RUNNING") if start_called else project_list("RUNNING")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
client.request.side_effect = mock_request
|
|
||||||
# Simulate FileStation download of compose.yaml
|
|
||||||
client.download_text = AsyncMock(return_value="services:\n web:\n image: nginx:1.24\n")
|
|
||||||
# post_request (image delete) raises an error — must be non-fatal
|
|
||||||
client.post_request = AsyncMock(side_effect=Exception("delete failed"))
|
|
||||||
|
|
||||||
|
client, calls = make_stateful_redeploy_mock(
|
||||||
|
"RUNNING",
|
||||||
|
build_stream_raises=SynologyError("build failed", code=114),
|
||||||
|
)
|
||||||
tools = make_projects_tools(client)
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||||
|
|
||||||
assert "redeployed successfully" in result
|
assert "redeployed successfully" not in result
|
||||||
assert start_called, "start must be called even when image deletion fails"
|
assert "build failed" in result or "Error during redeploy" in result
|
||||||
|
# Polling must not have been called after build_stream failure
|
||||||
|
methods = [m for _, m in calls]
|
||||||
|
list_calls = [m for m in methods if m == "list"]
|
||||||
|
assert len(list_calls) <= 1 # at most the initial find_project call
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_redeploy_poll_timeout():
|
async def test_redeploy_poll_timeout():
|
||||||
"""If project never reaches RUNNING after start, a warning is emitted."""
|
"""If project never reaches RUNNING after build_stream, a warning is emitted."""
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
start_called = False
|
build_done = False
|
||||||
|
|
||||||
async def mock_request(api, method, **kwargs):
|
async def mock_request(api, method, **kwargs):
|
||||||
nonlocal start_called
|
|
||||||
if api == "SYNO.FileStation.List":
|
|
||||||
return {"files": []}
|
|
||||||
if method == "start":
|
|
||||||
start_called = True
|
|
||||||
if method == "list":
|
if method == "list":
|
||||||
# Before start: return RUNNING so initial status check picks a valid path.
|
# Before build: RUNNING (so initial status check is valid)
|
||||||
# After start: return STARTING to simulate a stuck container — triggers timeout.
|
# After build: STARTING (simulate stuck containers)
|
||||||
return project_list("STARTING") if start_called else project_list("RUNNING")
|
return project_list("STARTING") if build_done else project_list("RUNNING")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
async def mock_build_stream(project_id):
|
||||||
|
nonlocal build_done
|
||||||
|
build_done = True
|
||||||
|
|
||||||
client.request.side_effect = mock_request
|
client.request.side_effect = mock_request
|
||||||
client.post_request = AsyncMock()
|
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
|
||||||
tools = make_projects_tools(client)
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
# Use tiny timeout so the test is instant (interval=1, timeout=1 → 1 poll)
|
# Use tiny timeout so the test is instant (interval=1, timeout=1 → 1 poll)
|
||||||
with (
|
with (
|
||||||
patch("mcp_synology_container.modules.projects.asyncio.sleep"),
|
patch("mcp_synology_container.modules.projects.asyncio.sleep"),
|
||||||
patch("mcp_synology_container.modules.projects._POLL_TIMEOUT", 1),
|
patch("mcp_synology_container.modules.projects._BUILD_POLL_TIMEOUT", 1),
|
||||||
patch("mcp_synology_container.modules.projects._POLL_INTERVAL", 1),
|
patch("mcp_synology_container.modules.projects._POLL_INTERVAL", 1),
|
||||||
):
|
):
|
||||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||||
@@ -389,14 +369,12 @@ async def test_redeploy_unknown_status_returns_error():
|
|||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
|
|
||||||
async def mock_request(api, method, **kwargs):
|
async def mock_request(api, method, **kwargs):
|
||||||
if api == "SYNO.FileStation.List":
|
|
||||||
return {"files": []}
|
|
||||||
if method == "list":
|
if method == "list":
|
||||||
return project_list("UPDATING")
|
return project_list("UPDATING")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
client.request.side_effect = mock_request
|
client.request.side_effect = mock_request
|
||||||
client.post_request = AsyncMock()
|
client.trigger_build_stream = AsyncMock()
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user