fix: v0.10.0 — compose up after cached build; recover CREATED projects
A build: project whose image was already cached landed in CREATED without its containers being started: build_stream runs `docker compose build`, and on a cache hit DSM's implicit `up` does not fire, leaving the project stuck (start_project -> DSM 2104, redeploy_project -> "unexpected status CREATED"). - create_project / redeploy_project: after build_stream, read status and, if not RUNNING, issue SYNO.Docker.Project/start (= docker compose up) via new _ensure_project_up helper, then re-read. Best-effort: a rejected start is surfaced as a clear "containers are not running" hint, not a silent stick. - redeploy_project: accept CREATED as an input status and recover it. - start_project: support CREATED/STOPPED; graceful 2104 hint with the cold-rebuild workaround; no-op message when already RUNNING. Confirmed against live DSM: start brings STOPPED -> RUNNING; the cold-build path was already RUNNING and is unchanged. 329 tests pass; ruff clean.
This commit is contained in:
@@ -2,6 +2,30 @@
|
|||||||
|
|
||||||
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.10.0] - 2026-06-23
|
||||||
|
|
||||||
|
### Fixed (cached `build:` projects no longer stick in CREATED)
|
||||||
|
- `create_project` / `redeploy_project`: after `build_stream` finishes, the
|
||||||
|
project status is checked and, when the build left it CREATED — a `build:`
|
||||||
|
project whose image was already cached, so DSM's implicit `up` never fired —
|
||||||
|
`SYNO.Docker.Project/start` (confirmed to map to `docker compose up`) is
|
||||||
|
issued to bring the containers to RUNNING. Previously such projects were left
|
||||||
|
stuck in CREATED, with `start_project` returning DSM 2104 and
|
||||||
|
`redeploy_project` refusing with "unexpected status".
|
||||||
|
- `redeploy_project`: now recovers a project that is already in CREATED
|
||||||
|
(stop → build → compose up) instead of refusing it.
|
||||||
|
- `start_project`: supports CREATED (compose up) and STOPPED. On a DSM
|
||||||
|
rejection from CREATED (observed as code 2104 on some firmware) it returns an
|
||||||
|
actionable recovery hint (retry `redeploy_project`, or delete the base image
|
||||||
|
to force a cold rebuild) instead of a bare error code, and is a no-op with a
|
||||||
|
clear message when the project is already RUNNING.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- The post-build compose-up is best-effort: if DSM rejects `start`, the tools
|
||||||
|
surface a clear "containers are not running" hint instead of a silent stuck
|
||||||
|
state. Behaviour confirmed against live DSM: `start` brings STOPPED → RUNNING;
|
||||||
|
the cold-build path was already RUNNING and is unchanged.
|
||||||
|
|
||||||
## [0.9.1] - 2026-06-22
|
## [0.9.1] - 2026-06-22
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.9.1"
|
version = "0.10.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 = [
|
||||||
|
|||||||
@@ -66,6 +66,55 @@ def _parse_build_stream_log(log: str) -> tuple[list[str], list[str]]:
|
|||||||
return errors, info
|
return errors, info
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_project_up(
|
||||||
|
client: DsmClient,
|
||||||
|
project_name: str,
|
||||||
|
project_id: str,
|
||||||
|
results: list[str],
|
||||||
|
) -> str:
|
||||||
|
"""Bring a freshly built project to RUNNING via compose-up when needed.
|
||||||
|
|
||||||
|
DSM's ``build_stream`` runs the build phase (``docker compose build``).
|
||||||
|
On a ``build:`` project whose image is already cached, the build is a
|
||||||
|
no-op and the implicit ``up`` does **not** fire — the project is left in
|
||||||
|
CREATED (containers created but never started) and is otherwise stuck.
|
||||||
|
This helper reads the post-build status and, when the project is not yet
|
||||||
|
RUNNING (and not in a terminal-failure state), issues
|
||||||
|
``SYNO.Docker.Project/start`` — confirmed to map to ``docker compose up``
|
||||||
|
— to start the containers, then re-reads and returns the final status.
|
||||||
|
|
||||||
|
The ``start`` call is best-effort: if DSM rejects it (e.g. the firmware
|
||||||
|
refuses ``start`` from CREATED with code 2104), the rejection is recorded
|
||||||
|
in ``results`` and the unchanged status is returned so the caller can
|
||||||
|
surface an actionable hint instead of a silent stuck state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: DsmClient instance.
|
||||||
|
project_name: Project name (for status lookups).
|
||||||
|
project_id: Project UUID (for the start call).
|
||||||
|
results: Running list of human-readable step lines, appended in place.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The final project status (uppercased), e.g. "RUNNING", "CREATED",
|
||||||
|
"BUILD_FAILED", or "UNKNOWN".
|
||||||
|
"""
|
||||||
|
final = await _find_project(client, project_name)
|
||||||
|
status = (final.get("status") or "UNKNOWN").upper() if final else "UNKNOWN"
|
||||||
|
if status in ("RUNNING", "UNKNOWN") or status in _TERMINAL_FAILURE_STATUSES:
|
||||||
|
return status
|
||||||
|
|
||||||
|
# Built but not started (CREATED/STOPPED/…) → docker compose up.
|
||||||
|
results.append(f" Built but not running (status: {status}) — running compose up...")
|
||||||
|
try:
|
||||||
|
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
|
||||||
|
except Exception as e: # noqa: BLE001 — any DSM error becomes an actionable hint
|
||||||
|
results.append(f" compose up (start) rejected by DSM: {e}")
|
||||||
|
return status
|
||||||
|
|
||||||
|
final = await _find_project(client, project_name)
|
||||||
|
return (final.get("status") or "UNKNOWN").upper() if final else "UNKNOWN"
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
|
|
||||||
@@ -106,21 +155,47 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def start_project(project_name: str):
|
async def start_project(project_name: str):
|
||||||
"""Start a Container Manager project."""
|
"""Start (docker compose up) a Container Manager project.
|
||||||
|
|
||||||
|
Works from STOPPED and from CREATED — the latter being a ``build:``
|
||||||
|
project whose image was already cached, so the build's implicit ``up``
|
||||||
|
never fired. On a DSM rejection from CREATED, returns an actionable
|
||||||
|
recovery hint instead of a bare error code.
|
||||||
|
"""
|
||||||
project = await _find_project(client, project_name)
|
project = await _find_project(client, project_name)
|
||||||
if project is None:
|
if project is None:
|
||||||
return f"Project '{project_name}' not found."
|
return f"Project '{project_name}' not found."
|
||||||
|
|
||||||
project_id = project.get("id", "")
|
project_id = project.get("id", "")
|
||||||
|
status = (project.get("status") or "").upper()
|
||||||
|
if status == "RUNNING":
|
||||||
|
return f"Project '{project_name}' is already RUNNING."
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await client.request(
|
await client.request(
|
||||||
"SYNO.Docker.Project",
|
"SYNO.Docker.Project",
|
||||||
"start",
|
"start",
|
||||||
params={"id": project_id},
|
params={"id": project_id},
|
||||||
)
|
)
|
||||||
return f"Project '{project_name}' started successfully."
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error starting project '{project_name}': {e}"
|
hint = ""
|
||||||
|
if status == "CREATED":
|
||||||
|
hint = (
|
||||||
|
"\nThe project is CREATED (built but never started). If DSM keeps "
|
||||||
|
"rejecting the start, recover with redeploy_project, or delete the base "
|
||||||
|
"image to force a cold rebuild, then create/redeploy again."
|
||||||
|
)
|
||||||
|
return f"Error starting project '{project_name}' (status {status or '?'}): {e}{hint}"
|
||||||
|
|
||||||
|
final = await _find_project(client, project_name)
|
||||||
|
final_status = (final.get("status") or "UNKNOWN").upper() if final else "UNKNOWN"
|
||||||
|
if final_status == "RUNNING":
|
||||||
|
return f"Project '{project_name}' started successfully (now RUNNING)."
|
||||||
|
return (
|
||||||
|
f"Project '{project_name}' start issued but status is {final_status}, not RUNNING. "
|
||||||
|
f"Check get_project_status('{project_name}'); if it stays stuck, try "
|
||||||
|
f"redeploy_project or a cold rebuild (delete the base image)."
|
||||||
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def stop_project(project_name: str, confirmed: bool = False):
|
async def stop_project(project_name: str, confirmed: bool = False):
|
||||||
@@ -163,7 +238,7 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
project_id = project.get("id", "")
|
project_id = project.get("id", "")
|
||||||
status = (project.get("status") or "").upper()
|
status = (project.get("status") or "").upper()
|
||||||
|
|
||||||
if status not in ("RUNNING", "STOPPED", "BUILD_FAILED", ""):
|
if status not in ("RUNNING", "STOPPED", "BUILD_FAILED", "CREATED", ""):
|
||||||
return (
|
return (
|
||||||
f"Cannot redeploy '{project_name}': unexpected status '{status}'.\n"
|
f"Cannot redeploy '{project_name}': unexpected status '{status}'.\n"
|
||||||
"Wait for the current operation to finish, then retry redeploy_project. "
|
"Wait for the current operation to finish, then retry redeploy_project. "
|
||||||
@@ -181,8 +256,10 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
# ── Step 1: Stop ──────────────────────────────────────────────────
|
# ── Step 1: Stop ──────────────────────────────────────────────────
|
||||||
if status == "STOPPED":
|
if status == "STOPPED":
|
||||||
results.append("Step 1/3: 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", "CREATED", ""):
|
||||||
results.append("Step 1/3: Stopping failed build...")
|
results.append(
|
||||||
|
f"Step 1/3: Project is {status or 'unbuilt'} — stopping to clear containers..."
|
||||||
|
)
|
||||||
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})
|
||||||
stop_was_issued = True
|
stop_was_issued = True
|
||||||
@@ -233,11 +310,11 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
return "\n".join(results)
|
return "\n".join(results)
|
||||||
|
|
||||||
# Stream closed within the early window → the build already
|
# Stream closed within the early window → the build already
|
||||||
# finished server-side. Report the outcome from a single status
|
# finished server-side. Read the status and, if the build left the
|
||||||
# read (no minutes-long polling loop).
|
# project CREATED (cached build: image, implicit up skipped), run
|
||||||
|
# compose up to bring it to RUNNING — no minutes-long polling loop.
|
||||||
results.append(" Build request accepted by DSM.")
|
results.append(" Build request accepted by DSM.")
|
||||||
final = await _find_project(client, project_name)
|
final_status = await _ensure_project_up(client, project_name, project_id, results)
|
||||||
final_status = (final.get("status") or "UNKNOWN").upper() if final else "UNKNOWN"
|
|
||||||
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.")
|
||||||
@@ -252,10 +329,11 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
f"\nProject '{project_name}' redeploy aborted (status: {final_status})."
|
f"\nProject '{project_name}' redeploy aborted (status: {final_status})."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
results.append(f" Build finished — current status: {final_status}.")
|
results.append(f" compose up did not reach RUNNING — status: {final_status}.")
|
||||||
results.append(
|
results.append(
|
||||||
f"\nProject '{project_name}' redeploy started (status: {final_status}). "
|
f"\nProject '{project_name}' redeploy incomplete (status: {final_status}): "
|
||||||
f"Check get_project_status('{project_name}')."
|
f"containers are not running. Retry redeploy_project; if it stays stuck on a "
|
||||||
|
f"cached build, delete the base image to force a cold rebuild."
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -438,9 +516,10 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
return "\n".join(results)
|
return "\n".join(results)
|
||||||
|
|
||||||
# Stream closed within the early window → the build already finished.
|
# Stream closed within the early window → the build already finished.
|
||||||
|
# If the build left the project CREATED (cached build: image, implicit
|
||||||
|
# up skipped), run compose up to bring it to RUNNING.
|
||||||
results.append(" Build request accepted by DSM.")
|
results.append(" Build request accepted by DSM.")
|
||||||
final = await _find_project(client, project_name)
|
final_status = await _ensure_project_up(client, project_name, project_id, results)
|
||||||
final_status = (final.get("status") or "UNKNOWN").upper() if final else "UNKNOWN"
|
|
||||||
if final_status == "RUNNING":
|
if final_status == "RUNNING":
|
||||||
results.append(" Project is RUNNING.")
|
results.append(" Project is RUNNING.")
|
||||||
results.append(f"\nProject '{project_name}' created and started successfully.")
|
results.append(f"\nProject '{project_name}' created and started successfully.")
|
||||||
@@ -456,10 +535,12 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
f"(status: {final_status})."
|
f"(status: {final_status})."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
results.append(f" Build finished — current status: {final_status}.")
|
results.append(f" compose up did not reach RUNNING — status: {final_status}.")
|
||||||
results.append(
|
results.append(
|
||||||
f"\nProject '{project_name}' created (status: {final_status}). "
|
f"\nProject '{project_name}' is registered but its containers are not running "
|
||||||
f"Check get_project_status('{project_name}')."
|
f"(status: {final_status}). Try redeploy_project('{project_name}', "
|
||||||
|
f"confirmed=True); if it stays stuck on a cached build, delete the base image "
|
||||||
|
f"to force a cold rebuild, then create/redeploy again."
|
||||||
)
|
)
|
||||||
|
|
||||||
return "\n".join(results)
|
return "\n".join(results)
|
||||||
|
|||||||
@@ -585,33 +585,46 @@ def make_create_project_client(
|
|||||||
build_stream_log: str = "",
|
build_stream_log: str = "",
|
||||||
project_id: str = "uuid-new",
|
project_id: str = "uuid-new",
|
||||||
final_status: str = "RUNNING",
|
final_status: str = "RUNNING",
|
||||||
|
start_raises: Exception | None = None,
|
||||||
|
status_after_start: str = "RUNNING",
|
||||||
):
|
):
|
||||||
"""Build a stateful mock client for create_project tests.
|
"""Build a stateful mock client for create_project tests.
|
||||||
|
|
||||||
Tracks:
|
Tracks:
|
||||||
- whether Docker.Project/create has been called (so post_create_calls
|
- whether Docker.Project/create has been called (so post_create_calls
|
||||||
to /list return the newly-registered project at `final_status`)
|
to /list return the newly-registered project at `final_status`)
|
||||||
|
- whether Docker.Project/start has been called (so subsequent /list
|
||||||
|
reads return `status_after_start` — used to exercise the
|
||||||
|
compose-up-after-build recovery for a CREATED build)
|
||||||
- which API/method/version each call used
|
- which API/method/version each call used
|
||||||
"""
|
"""
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
calls: list[tuple[str, str, dict]] = []
|
calls: list[tuple[str, str, dict]] = []
|
||||||
project_created = False
|
project_created = False
|
||||||
|
started = False
|
||||||
|
|
||||||
async def mock_request(api, method, version=None, params=None, **kwargs):
|
async def mock_request(api, method, version=None, params=None, **kwargs):
|
||||||
|
nonlocal started
|
||||||
calls.append((api, method, dict(params or {})))
|
calls.append((api, method, dict(params or {})))
|
||||||
if api == "SYNO.Docker.Project" and method == "list":
|
if api == "SYNO.Docker.Project" and method == "list":
|
||||||
if project_created:
|
if project_created:
|
||||||
|
status = status_after_start if started else final_status
|
||||||
return {
|
return {
|
||||||
project_id: {
|
project_id: {
|
||||||
"id": project_id,
|
"id": project_id,
|
||||||
"name": "newapp",
|
"name": "newapp",
|
||||||
"status": final_status,
|
"status": status,
|
||||||
"path": "/volume1/docker/newapp",
|
"path": "/volume1/docker/newapp",
|
||||||
"containerIds": [],
|
"containerIds": [],
|
||||||
"services": [],
|
"services": [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return existing_projects or {}
|
return existing_projects or {}
|
||||||
|
if api == "SYNO.Docker.Project" and method == "start":
|
||||||
|
if start_raises:
|
||||||
|
raise start_raises
|
||||||
|
started = True
|
||||||
|
return {}
|
||||||
if api == "SYNO.FileStation.CreateFolder":
|
if api == "SYNO.FileStation.CreateFolder":
|
||||||
if create_folder_raises:
|
if create_folder_raises:
|
||||||
raise create_folder_raises
|
raise create_folder_raises
|
||||||
@@ -953,3 +966,213 @@ async def test_delete_project_running_blocked_connector_side():
|
|||||||
# The delete endpoint must NOT have been called — no orphaned containers.
|
# The delete endpoint must NOT have been called — no orphaned containers.
|
||||||
delete_calls = [m for _, m, _ in calls if m == "delete"]
|
delete_calls = [m for _, m, _ in calls if m == "delete"]
|
||||||
assert delete_calls == []
|
assert delete_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# compose-up-after-build: recover a CREATED build: project (Issue: cached
|
||||||
|
# build leaves the project CREATED, implicit `up` skipped)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def make_created_recovery_mock(
|
||||||
|
*,
|
||||||
|
initial_status: str = "CREATED",
|
||||||
|
post_build_status: str = "CREATED",
|
||||||
|
start_raises=None,
|
||||||
|
status_after_start: str = "RUNNING",
|
||||||
|
):
|
||||||
|
"""Stateful redeploy mock that simulates a cached build landing CREATED.
|
||||||
|
|
||||||
|
Before build_stream, /list returns ``initial_status``. After build_stream,
|
||||||
|
/list returns ``post_build_status`` until Project/start is called, then
|
||||||
|
``status_after_start``. If ``start_raises`` is set, the start call raises.
|
||||||
|
"""
|
||||||
|
client = AsyncMock()
|
||||||
|
calls = []
|
||||||
|
build_done = False
|
||||||
|
started = False
|
||||||
|
|
||||||
|
async def mock_request(api, method, **kwargs):
|
||||||
|
nonlocal started
|
||||||
|
calls.append((api, method))
|
||||||
|
if method == "start":
|
||||||
|
if start_raises:
|
||||||
|
raise start_raises
|
||||||
|
started = True
|
||||||
|
return {}
|
||||||
|
if method == "list":
|
||||||
|
if not build_done:
|
||||||
|
return project_list(initial_status)
|
||||||
|
return project_list(status_after_start if started else post_build_status)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def mock_build_stream(project_id, **kwargs):
|
||||||
|
nonlocal build_done
|
||||||
|
calls.append(("SYNO.Docker.Project", "build_stream"))
|
||||||
|
build_done = True
|
||||||
|
return "" # clean, closed stream — outcome decided by the status read
|
||||||
|
|
||||||
|
client.request.side_effect = mock_request
|
||||||
|
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
|
||||||
|
return client, calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redeploy_compose_up_after_cached_build():
|
||||||
|
"""build_stream leaves the project CREATED → redeploy issues compose up
|
||||||
|
(Project/start) → status reads RUNNING → success."""
|
||||||
|
client, calls = make_created_recovery_mock(initial_status="RUNNING")
|
||||||
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||||
|
|
||||||
|
assert "redeployed successfully" in result
|
||||||
|
assert "compose up" in result
|
||||||
|
methods = [m for _, m in calls]
|
||||||
|
assert "start" in methods
|
||||||
|
assert methods.index("build_stream") < methods.index("start")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redeploy_recovers_from_created_input_status():
|
||||||
|
"""A project that is already stuck in CREATED can be redeployed (no longer
|
||||||
|
refused as 'unexpected status')."""
|
||||||
|
client, calls = make_created_recovery_mock(initial_status="CREATED")
|
||||||
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||||
|
|
||||||
|
assert "unexpected status" not in result
|
||||||
|
assert "redeployed successfully" in result
|
||||||
|
methods = [m for _, m in calls]
|
||||||
|
assert "build_stream" in methods
|
||||||
|
assert "start" in methods
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redeploy_created_start_rejected_shows_hint():
|
||||||
|
"""If compose up (start) is rejected from CREATED (e.g. DSM 2104), redeploy
|
||||||
|
reports an incomplete redeploy with the cold-rebuild workaround."""
|
||||||
|
from mcp_synology_container.dsm_client import SynologyError
|
||||||
|
|
||||||
|
client, calls = make_created_recovery_mock(
|
||||||
|
initial_status="RUNNING",
|
||||||
|
start_raises=SynologyError("DSM error code 2104", code=2104),
|
||||||
|
)
|
||||||
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||||
|
|
||||||
|
assert "redeployed successfully" not in result
|
||||||
|
assert "redeploy incomplete" in result
|
||||||
|
assert "CREATED" in result
|
||||||
|
assert "2104" in result
|
||||||
|
assert "cold rebuild" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_compose_up_after_cached_build():
|
||||||
|
"""create_project: a cached build lands CREATED → compose up (start) →
|
||||||
|
RUNNING → created-and-started success."""
|
||||||
|
client, calls = make_create_project_client(
|
||||||
|
final_status="CREATED",
|
||||||
|
status_after_start="RUNNING",
|
||||||
|
)
|
||||||
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
|
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
||||||
|
|
||||||
|
assert "created and started successfully" in result
|
||||||
|
methods = [m for _, m, _ in calls]
|
||||||
|
assert "start" in methods
|
||||||
|
assert methods.index("build_stream") < methods.index("start")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_created_start_rejected_shows_hint():
|
||||||
|
"""create_project: cached build CREATED + start rejected → registered-but-not
|
||||||
|
-running message with the cold-rebuild workaround."""
|
||||||
|
from mcp_synology_container.dsm_client import SynologyError
|
||||||
|
|
||||||
|
client, calls = make_create_project_client(
|
||||||
|
final_status="CREATED",
|
||||||
|
start_raises=SynologyError("DSM error code 2104", code=2104),
|
||||||
|
)
|
||||||
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
|
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
||||||
|
|
||||||
|
assert "created and started successfully" not in result
|
||||||
|
assert "containers are not running" in result
|
||||||
|
assert "CREATED" in result
|
||||||
|
assert "cold rebuild" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# start_project: STOPPED/CREATED → compose up, with graceful 2104 hint
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def make_start_project_mock(initial_status, *, start_raises=None, status_after_start="RUNNING"):
|
||||||
|
client = AsyncMock()
|
||||||
|
calls = []
|
||||||
|
started = False
|
||||||
|
|
||||||
|
async def mock_request(api, method, **kwargs):
|
||||||
|
nonlocal started
|
||||||
|
calls.append((api, method))
|
||||||
|
if method == "start":
|
||||||
|
if start_raises:
|
||||||
|
raise start_raises
|
||||||
|
started = True
|
||||||
|
return {}
|
||||||
|
if method == "list":
|
||||||
|
return project_list(status_after_start if started else initial_status)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.request.side_effect = mock_request
|
||||||
|
return client, calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_project_created_recovers():
|
||||||
|
"""start_project on a CREATED project issues compose up and reports RUNNING."""
|
||||||
|
client, calls = make_start_project_mock("CREATED")
|
||||||
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
|
result = await tools["start_project"]("myapp")
|
||||||
|
|
||||||
|
assert "started successfully" in result
|
||||||
|
assert "RUNNING" in result
|
||||||
|
assert "start" in [m for _, m in calls]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_project_created_rejected_shows_hint():
|
||||||
|
"""start_project rejected from CREATED (DSM 2104) → actionable recovery hint."""
|
||||||
|
from mcp_synology_container.dsm_client import SynologyError
|
||||||
|
|
||||||
|
client, calls = make_start_project_mock(
|
||||||
|
"CREATED", start_raises=SynologyError("DSM error code 2104", code=2104)
|
||||||
|
)
|
||||||
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
|
result = await tools["start_project"]("myapp")
|
||||||
|
|
||||||
|
assert "Error starting" in result
|
||||||
|
assert "CREATED" in result
|
||||||
|
assert "2104" in result
|
||||||
|
assert "redeploy_project" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_project_already_running_is_noop():
|
||||||
|
"""start_project on a RUNNING project returns 'already RUNNING' and does not
|
||||||
|
call Project/start."""
|
||||||
|
client, calls = make_start_project_mock("RUNNING")
|
||||||
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
|
result = await tools["start_project"]("myapp")
|
||||||
|
|
||||||
|
assert "already RUNNING" in result
|
||||||
|
assert "start" not in [m for _, m in calls]
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.9.1"
|
version = "0.10.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user