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:
@@ -585,33 +585,46 @@ def make_create_project_client(
|
||||
build_stream_log: str = "",
|
||||
project_id: str = "uuid-new",
|
||||
final_status: str = "RUNNING",
|
||||
start_raises: Exception | None = None,
|
||||
status_after_start: str = "RUNNING",
|
||||
):
|
||||
"""Build a stateful mock client for create_project tests.
|
||||
|
||||
Tracks:
|
||||
- whether Docker.Project/create has been called (so post_create_calls
|
||||
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
|
||||
"""
|
||||
client = AsyncMock()
|
||||
calls: list[tuple[str, str, dict]] = []
|
||||
project_created = False
|
||||
started = False
|
||||
|
||||
async def mock_request(api, method, version=None, params=None, **kwargs):
|
||||
nonlocal started
|
||||
calls.append((api, method, dict(params or {})))
|
||||
if api == "SYNO.Docker.Project" and method == "list":
|
||||
if project_created:
|
||||
status = status_after_start if started else final_status
|
||||
return {
|
||||
project_id: {
|
||||
"id": project_id,
|
||||
"name": "newapp",
|
||||
"status": final_status,
|
||||
"status": status,
|
||||
"path": "/volume1/docker/newapp",
|
||||
"containerIds": [],
|
||||
"services": [],
|
||||
}
|
||||
}
|
||||
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 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.
|
||||
delete_calls = [m for _, m, _ in calls if m == "delete"]
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user