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:
@@ -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.
|
||||
|
||||
Returns (client, calls_list). After ``start`` is called, subsequent
|
||||
``list`` calls return RUNNING so the polling loop terminates 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).
|
||||
Returns (client, calls_list). After ``trigger_build_stream`` is called,
|
||||
subsequent ``list`` calls return RUNNING so the polling loop terminates
|
||||
immediately. asyncio.sleep is NOT patched here — patch it at call-site.
|
||||
"""
|
||||
client = AsyncMock()
|
||||
calls = []
|
||||
start_called = False
|
||||
build_done = False
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
nonlocal start_called
|
||||
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:
|
||||
raise stop_raises
|
||||
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 {}
|
||||
|
||||
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.post_request = AsyncMock()
|
||||
client.trigger_build_stream = AsyncMock(side_effect=mock_trigger_build_stream)
|
||||
return client, calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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")
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
@@ -250,13 +253,13 @@ async def test_redeploy_running_project():
|
||||
assert "redeployed successfully" in result
|
||||
methods = [m for _, m in calls]
|
||||
assert "stop" in methods
|
||||
assert "start" in methods
|
||||
assert methods.index("stop") < methods.index("start")
|
||||
assert "build_stream" in methods
|
||||
assert methods.index("stop") < methods.index("build_stream")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_stopped_project_starts_directly():
|
||||
"""STOPPED project: skip stop, just start; polls until RUNNING."""
|
||||
async def test_redeploy_stopped_project_skips_stop():
|
||||
"""STOPPED project: skip stop, call build_stream directly; polls until RUNNING."""
|
||||
client, calls = make_stateful_redeploy_mock("STOPPED")
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
@@ -266,13 +269,13 @@ async def test_redeploy_stopped_project_starts_directly():
|
||||
assert "redeployed successfully" in result
|
||||
methods = [m for _, m in calls]
|
||||
assert "stop" not in methods
|
||||
assert "start" in methods
|
||||
assert "STOPPED" in result or "starting directly" in result.lower()
|
||||
assert "build_stream" in methods
|
||||
assert "STOPPED" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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")
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
@@ -282,16 +285,16 @@ async def test_redeploy_build_failed_project():
|
||||
assert "redeployed successfully" in result
|
||||
methods = [m for _, m in calls]
|
||||
assert "stop" in methods
|
||||
assert "start" in methods
|
||||
assert methods.index("stop") < methods.index("start")
|
||||
assert "build_stream" in methods
|
||||
assert methods.index("stop") < methods.index("build_stream")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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
|
||||
|
||||
client, _ = make_stateful_redeploy_mock(
|
||||
client, calls = make_stateful_redeploy_mock(
|
||||
"BUILD_FAILED",
|
||||
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)
|
||||
|
||||
assert "redeployed successfully" in result
|
||||
methods = [m for _, m in calls]
|
||||
assert "build_stream" in methods
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_image_delete_failure_nonfatal():
|
||||
"""Image deletion failure must be non-fatal: start must still be called."""
|
||||
client = AsyncMock()
|
||||
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"))
|
||||
async def test_redeploy_build_stream_error_aborts():
|
||||
"""If build_stream raises, redeploy must abort with a clear error message."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
|
||||
client, calls = make_stateful_redeploy_mock(
|
||||
"RUNNING",
|
||||
build_stream_raises=SynologyError("build failed", code=114),
|
||||
)
|
||||
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
|
||||
assert start_called, "start must be called even when image deletion fails"
|
||||
assert "redeployed successfully" not in result
|
||||
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
|
||||
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()
|
||||
start_called = False
|
||||
build_done = False
|
||||
|
||||
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":
|
||||
# Before start: return RUNNING so initial status check picks a valid path.
|
||||
# After start: return STARTING to simulate a stuck container — triggers timeout.
|
||||
return project_list("STARTING") if start_called else project_list("RUNNING")
|
||||
# Before build: RUNNING (so initial status check is valid)
|
||||
# After build: STARTING (simulate stuck containers)
|
||||
return project_list("STARTING") if build_done else project_list("RUNNING")
|
||||
return {}
|
||||
|
||||
async def mock_build_stream(project_id):
|
||||
nonlocal build_done
|
||||
build_done = True
|
||||
|
||||
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)
|
||||
|
||||
# Use tiny timeout so the test is instant (interval=1, timeout=1 → 1 poll)
|
||||
with (
|
||||
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),
|
||||
):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
@@ -389,14 +369,12 @@ async def test_redeploy_unknown_status_returns_error():
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.FileStation.List":
|
||||
return {"files": []}
|
||||
if method == "list":
|
||||
return project_list("UPDATING")
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
client.post_request = AsyncMock()
|
||||
client.trigger_build_stream = AsyncMock()
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
Reference in New Issue
Block a user