036429e9bf
DSM emits a readable plaintext build log over the build_stream HTTP body (one short status line per step) and closes the connection when the build is done. The 0.2.5 implementation sent the request and dropped the body unread, leaving users with nothing more than a BUILD_FAILED polling status and no actionable diagnostic. DsmClient.trigger_build_stream now consumes the body line-by-line and returns the collected log as a string. Wall-clock budget of 210 s (under the Claude Desktop ~4 min ceiling); on timeout the partial log is returned with a "[build_stream: timeout — stream still open server-side]" marker so callers know the build continues server-side. Per-chunk ReadTimeout is treated the same way. JSON error envelope, transport-error mapping (M-4), and SID-scrubbed HTTP-error formatting are unchanged. redeploy_project and create_project now parse the returned log via _parse_build_stream_log (any line containing "Error response from daemon:" or ending in " Error" counts as a failure). On a failed log the tools abort immediately, surface the daemon line(s) in the result (e.g. "Error response from daemon: manifest for nginx:9.9.9 not found: manifest unknown"), and skip the polling step. The BUILD_FAILED polling guard (M-5) stays as a second safety net for late failures where the stream was clean but the container exited after start. No new MCP tool: the build log is a live stream and cannot be re-fetched after the build ends, so it is surfaced during redeploy_project / create_project rather than exposed as a standalone get_project_build_log call. Minor version bump because redeploy_project and create_project return materially different strings on a failed build and exit earlier in the failure path. Signatures unchanged. Tests: streamed-log collection, daemon-error log, header ReadTimeout marker, per-chunk ReadTimeout partial log, wall-clock budget truncation, _parse_build_stream_log unit tests, redeploy/create end- to-end behavior with a failing log. Closes #2 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1026 lines
37 KiB
Python
1026 lines
37 KiB
Python
"""Tests for modules/projects.py."""
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from mcp_synology_container.modules.projects import _find_project, _format_project_detail
|
|
|
|
SAMPLE_PROJECTS = {
|
|
"uuid-1": {
|
|
"id": "uuid-1",
|
|
"name": "myapp",
|
|
"status": "RUNNING",
|
|
"path": "/volume1/docker/myapp",
|
|
"share_path": "/docker/myapp",
|
|
"created_at": "2025-01-01T00:00:00Z",
|
|
"updated_at": "2025-01-02T00:00:00Z",
|
|
"containerIds": ["abc123def456"],
|
|
"services": [{"display_name": "myapp (project)"}],
|
|
},
|
|
"uuid-2": {
|
|
"id": "uuid-2",
|
|
"name": "database",
|
|
"status": "STOPPED",
|
|
"path": "/volume1/docker/database",
|
|
"share_path": "/docker/database",
|
|
"created_at": "2025-01-01T00:00:00Z",
|
|
"updated_at": "2025-01-01T00:00:00Z",
|
|
"containerIds": [],
|
|
"services": [],
|
|
},
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_project_found():
|
|
client = AsyncMock()
|
|
client.request.return_value = SAMPLE_PROJECTS
|
|
|
|
result = await _find_project(client, "myapp")
|
|
assert result is not None
|
|
assert result["name"] == "myapp"
|
|
assert result["status"] == "RUNNING"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_project_not_found():
|
|
client = AsyncMock()
|
|
client.request.return_value = SAMPLE_PROJECTS
|
|
|
|
result = await _find_project(client, "nonexistent")
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_project_api_error():
|
|
client = AsyncMock()
|
|
client.request.side_effect = Exception("API error")
|
|
|
|
result = await _find_project(client, "myapp")
|
|
assert result is None
|
|
|
|
|
|
def test_format_project_detail():
|
|
project = SAMPLE_PROJECTS["uuid-1"]
|
|
output = _format_project_detail(project)
|
|
|
|
assert "myapp" in output
|
|
assert "RUNNING" in output
|
|
assert "/volume1/docker/myapp" in output
|
|
assert "uuid-1" in output
|
|
|
|
|
|
def test_format_project_detail_no_containers():
|
|
project = SAMPLE_PROJECTS["uuid-2"]
|
|
output = _format_project_detail(project)
|
|
|
|
assert "database" in output
|
|
assert "STOPPED" in output
|
|
assert "Containers: 0" in output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_projects_tool():
|
|
"""Test list_projects tool via function registration."""
|
|
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
|
from mcp_synology_container.modules.projects import register_projects
|
|
|
|
config = AppConfig(
|
|
schema_version=1,
|
|
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
|
)
|
|
client = AsyncMock()
|
|
client.request.return_value = SAMPLE_PROJECTS
|
|
|
|
tools: dict = {}
|
|
|
|
class MockMCP:
|
|
def tool(self):
|
|
def decorator(fn):
|
|
tools[fn.__name__] = fn
|
|
return fn
|
|
|
|
return decorator
|
|
|
|
register_projects(MockMCP(), config, client)
|
|
assert "list_projects" in tools
|
|
|
|
result = await tools["list_projects"]()
|
|
assert "myapp" in result
|
|
assert "database" in result
|
|
assert "RUNNING" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_project_requires_confirmation():
|
|
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
|
from mcp_synology_container.modules.projects import register_projects
|
|
|
|
config = AppConfig(
|
|
schema_version=1,
|
|
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
|
)
|
|
client = AsyncMock()
|
|
tools: dict = {}
|
|
|
|
class MockMCP:
|
|
def tool(self):
|
|
def decorator(fn):
|
|
tools[fn.__name__] = fn
|
|
return fn
|
|
|
|
return decorator
|
|
|
|
register_projects(MockMCP(), config, client)
|
|
|
|
result = await tools["stop_project"]("myapp", confirmed=False)
|
|
assert "confirmed=True" in result
|
|
client.request.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_project_requires_confirmation():
|
|
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
|
from mcp_synology_container.modules.projects import register_projects
|
|
|
|
config = AppConfig(
|
|
schema_version=1,
|
|
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
|
)
|
|
client = AsyncMock()
|
|
tools: dict = {}
|
|
|
|
class MockMCP:
|
|
def tool(self):
|
|
def decorator(fn):
|
|
tools[fn.__name__] = fn
|
|
return fn
|
|
|
|
return decorator
|
|
|
|
register_projects(MockMCP(), config, client)
|
|
|
|
result = await tools["redeploy_project"]("myapp", confirmed=False)
|
|
assert "confirmed=True" in result
|
|
client.request.assert_not_called()
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Bug 2: status-aware redeploy
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def make_projects_tools(client):
|
|
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
|
from mcp_synology_container.modules.projects import register_projects
|
|
|
|
config = AppConfig(
|
|
schema_version=1,
|
|
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
|
)
|
|
tools: dict = {}
|
|
|
|
class MockMCP:
|
|
def tool(self):
|
|
def decorator(fn):
|
|
tools[fn.__name__] = fn
|
|
return fn
|
|
|
|
return decorator
|
|
|
|
register_projects(MockMCP(), config, client)
|
|
return tools
|
|
|
|
|
|
def project_list(status: str) -> dict:
|
|
return {
|
|
"uuid-1": {
|
|
"id": "uuid-1",
|
|
"name": "myapp",
|
|
"status": status,
|
|
"path": "/volume1/docker/myapp",
|
|
"containerIds": ["abc123"],
|
|
"services": [],
|
|
}
|
|
}
|
|
|
|
|
|
def make_stateful_redeploy_mock(
|
|
initial_status: str,
|
|
stop_raises=None,
|
|
build_stream_raises=None,
|
|
build_stream_log: str = "",
|
|
):
|
|
"""Create a stateful client mock for redeploy tests.
|
|
|
|
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 = []
|
|
build_done = False
|
|
|
|
async def mock_request(api, method, **kwargs):
|
|
calls.append((api, method))
|
|
if method == "stop" and stop_raises:
|
|
raise stop_raises
|
|
if method == "list":
|
|
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
|
|
return build_stream_log
|
|
|
|
client.request.side_effect = mock_request
|
|
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 → build_stream → poll until RUNNING."""
|
|
client, calls = make_stateful_redeploy_mock("RUNNING")
|
|
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
|
|
methods = [m for _, m in calls]
|
|
assert "stop" in methods
|
|
assert "build_stream" in methods
|
|
assert methods.index("stop") < methods.index("build_stream")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
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)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
|
|
|
assert "redeployed successfully" in result
|
|
methods = [m for _, m in calls]
|
|
assert "stop" not in methods
|
|
assert "build_stream" in methods
|
|
assert "STOPPED" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_build_failed_project():
|
|
"""BUILD_FAILED project: stop (suppressed) → build_stream → poll until RUNNING."""
|
|
client, calls = make_stateful_redeploy_mock("BUILD_FAILED")
|
|
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
|
|
methods = [m for _, m in calls]
|
|
assert "stop" in methods
|
|
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 — build_stream must still be called."""
|
|
from mcp_synology_container.dsm_client import SynologyError
|
|
|
|
client, calls = make_stateful_redeploy_mock(
|
|
"BUILD_FAILED",
|
|
stop_raises=SynologyError("already stopped", code=2101),
|
|
)
|
|
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
|
|
methods = [m for _, m in calls]
|
|
assert "build_stream" in methods
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
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" 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 build_stream, a warning is emitted."""
|
|
client = AsyncMock()
|
|
build_done = False
|
|
|
|
async def mock_request(api, method, **kwargs):
|
|
if method == "list":
|
|
# 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
|
|
return ""
|
|
|
|
client.request.side_effect = mock_request
|
|
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._BUILD_POLL_TIMEOUT", 1),
|
|
patch("mcp_synology_container.modules.projects._POLL_INTERVAL", 1),
|
|
):
|
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
|
|
|
assert "Warning" in result
|
|
assert "redeployed successfully" not in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_unknown_status_returns_error():
|
|
"""Unknown status must return a clear error with a workaround hint."""
|
|
client = AsyncMock()
|
|
|
|
async def mock_request(api, method, **kwargs):
|
|
if method == "list":
|
|
return project_list("UPDATING")
|
|
return {}
|
|
|
|
client.request.side_effect = mock_request
|
|
client.trigger_build_stream = AsyncMock(return_value="")
|
|
tools = make_projects_tools(client)
|
|
|
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
|
|
|
assert "UPDATING" in result
|
|
assert "Workaround" in result or "stop_project" in result
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# M-4: clear recovery hint when build_stream fails after stop succeeded
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_build_stream_transport_error_shows_stopped_recovery_hint():
|
|
"""M-4: build_stream transport error after RUNNING-stop must tell the user the
|
|
project is now STOPPED and recommend start_project / retry."""
|
|
from mcp_synology_container.dsm_client import SynologyError
|
|
|
|
client, calls = make_stateful_redeploy_mock(
|
|
"RUNNING",
|
|
build_stream_raises=SynologyError(
|
|
"build_stream transport error: ConnectError: nas offline", code=0
|
|
),
|
|
)
|
|
tools = make_projects_tools(client)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
|
|
|
# No raw stack trace — clean message
|
|
assert "transport error" in result
|
|
assert "ConnectError" in result
|
|
# The recovery hint must point at the actual situation
|
|
assert "STOPPED" in result
|
|
assert "start_project" in result
|
|
# Old misleading workaround text must NOT appear
|
|
assert "stop_project + start_project separately" not in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_build_stream_error_on_stopped_project_keeps_old_workaround():
|
|
"""If the project was STOPPED to begin with, no stop was issued, so the
|
|
'STOPPED recovery' hint is NOT appropriate — keep the original workaround."""
|
|
from mcp_synology_container.dsm_client import SynologyError
|
|
|
|
client, calls = make_stateful_redeploy_mock(
|
|
"STOPPED",
|
|
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 "build failed" in result or "Error during redeploy" in result
|
|
# Stop was never issued; new recovery hint should not appear
|
|
assert "was stopped before this error" not in result
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# Issue #2: surface build_stream daemon errors directly
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_parse_build_stream_log_extracts_errors_and_info():
|
|
"""The helper splits a streamed build log into error and info lines."""
|
|
from mcp_synology_container.modules.projects import _parse_build_stream_log
|
|
|
|
log = (
|
|
"Container vault Pulling\n"
|
|
"nginx Error\n"
|
|
"Error response from daemon: manifest for nginx:9.9.9 not found: "
|
|
"manifest unknown\n"
|
|
"\n"
|
|
"Container vault Running\n"
|
|
)
|
|
|
|
errors, info = _parse_build_stream_log(log)
|
|
|
|
assert errors == [
|
|
"nginx Error",
|
|
("Error response from daemon: manifest for nginx:9.9.9 not found: manifest unknown"),
|
|
]
|
|
assert info == ["Container vault Pulling", "Container vault Running"]
|
|
|
|
|
|
def test_parse_build_stream_log_clean_log_no_errors():
|
|
from mcp_synology_container.modules.projects import _parse_build_stream_log
|
|
|
|
log = "Container vault Running\nContainer vault-db Running\n"
|
|
errors, info = _parse_build_stream_log(log)
|
|
|
|
assert errors == []
|
|
assert info == ["Container vault Running", "Container vault-db Running"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_surfaces_build_stream_daemon_error():
|
|
"""build_stream log containing 'Error response from daemon:' aborts redeploy
|
|
with that line visible — and skips the polling step entirely."""
|
|
client, calls = make_stateful_redeploy_mock(
|
|
"RUNNING",
|
|
build_stream_log=(
|
|
"nginx Error\n"
|
|
"Error response from daemon: manifest for nginx:9.9.9-nonexistent "
|
|
"not found: manifest unknown\n"
|
|
),
|
|
)
|
|
tools = make_projects_tools(client)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
|
|
|
assert "Build failed" in result
|
|
assert "Error response from daemon: manifest for nginx:9.9.9-nonexistent" in result
|
|
assert "redeploy aborted" in result
|
|
assert "redeployed successfully" not in result
|
|
|
|
# Recovery hint must appear because the project was stopped first.
|
|
assert "was stopped before this error" in result
|
|
|
|
# The polling step must NOT run — build_stream errors short-circuit.
|
|
methods = [m for _, m in calls]
|
|
list_calls = [m for m in methods if m == "list"]
|
|
assert len(list_calls) == 1 # only the initial _find_project lookup
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_clean_log_proceeds_to_polling():
|
|
"""A clean build_stream log keeps the original happy-path behavior."""
|
|
client, calls = make_stateful_redeploy_mock(
|
|
"RUNNING",
|
|
build_stream_log="Container myapp Pulling\nContainer myapp Running\n",
|
|
)
|
|
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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_project_surfaces_build_stream_daemon_error():
|
|
"""build_stream log with daemon error → registered-but-failed-to-build hint."""
|
|
client, calls = make_create_project_client(
|
|
build_stream_log=(
|
|
"web Error\n"
|
|
"Error response from daemon: manifest for nginx:0.0.0-bad not found: "
|
|
"manifest unknown\n"
|
|
),
|
|
)
|
|
tools = make_projects_tools(client)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
|
|
|
assert "Build failed" in result
|
|
assert "Error response from daemon: manifest for nginx:0.0.0-bad" in result
|
|
assert "is registered but failed to build" in result
|
|
assert "redeploy_project" in result
|
|
assert "created and started successfully" not in result
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# M-5: polling exits early on BUILD_FAILED / ERROR
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_project_running_returns_early_on_build_failed():
|
|
"""_wait_for_project_running must exit as soon as DSM reports BUILD_FAILED,
|
|
not wait the full timeout."""
|
|
from mcp_synology_container.modules.projects import _wait_for_project_running
|
|
|
|
client = AsyncMock()
|
|
|
|
async def mock_request(api, method, **kwargs):
|
|
if method == "list":
|
|
return project_list("BUILD_FAILED")
|
|
return {}
|
|
|
|
client.request.side_effect = mock_request
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
# 100s timeout, 2s interval — if the early-exit isn't there the test
|
|
# would still terminate quickly because sleep is mocked, but the call
|
|
# count assertion below catches a non-exiting loop.
|
|
result = await _wait_for_project_running(client, "myapp", timeout=100, interval=2)
|
|
|
|
assert result == "BUILD_FAILED"
|
|
# Only a few list() calls — exit was on the first poll iteration.
|
|
list_calls = [c for c in client.request.call_args_list if c.args[1] == "list"]
|
|
assert len(list_calls) <= 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_project_running_returns_early_on_error():
|
|
from mcp_synology_container.modules.projects import _wait_for_project_running
|
|
|
|
client = AsyncMock()
|
|
|
|
async def mock_request(api, method, **kwargs):
|
|
if method == "list":
|
|
return project_list("ERROR")
|
|
return {}
|
|
|
|
client.request.side_effect = mock_request
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
result = await _wait_for_project_running(client, "myapp", timeout=100, interval=2)
|
|
|
|
assert result == "ERROR"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_surfaces_build_failed_with_hint():
|
|
"""When polling reports BUILD_FAILED, redeploy_project must include a clear
|
|
hint to inspect the image tag and retry."""
|
|
client = AsyncMock()
|
|
build_done = False
|
|
|
|
async def mock_request(api, method, **kwargs):
|
|
if method == "list":
|
|
return project_list("BUILD_FAILED") if build_done else project_list("RUNNING")
|
|
return {}
|
|
|
|
async def mock_build_stream(project_id):
|
|
nonlocal build_done
|
|
build_done = True
|
|
return "" # Clean stream log — failure surfaces via polling.
|
|
|
|
client.request.side_effect = mock_request
|
|
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
|
|
tools = make_projects_tools(client)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
|
|
|
assert "Redeploy failed" in result
|
|
assert "BUILD_FAILED" in result
|
|
assert "update_image_tag" in result
|
|
assert "redeployed successfully" not in result
|
|
# Polling must have exited early, not run to the full timeout.
|
|
list_calls = [c for c in client.request.call_args_list if c.args[1] == "list"]
|
|
# Generous upper bound — early exit means handful of polls, not hundreds.
|
|
assert len(list_calls) <= 5
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# create_project
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
SIMPLE_COMPOSE = """
|
|
services:
|
|
web:
|
|
image: nginx:1.25
|
|
worker:
|
|
image: redis:7
|
|
"""
|
|
|
|
|
|
def make_create_project_client(
|
|
*,
|
|
existing_projects: dict | None = None,
|
|
create_folder_raises: Exception | None = None,
|
|
create_project_raises: Exception | None = None,
|
|
build_stream_raises: Exception | None = None,
|
|
build_stream_log: str = "",
|
|
project_id: str = "uuid-new",
|
|
final_status: 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`)
|
|
- which API/method/version each call used
|
|
"""
|
|
client = AsyncMock()
|
|
calls: list[tuple[str, str, dict]] = []
|
|
project_created = False
|
|
|
|
async def mock_request(api, method, version=None, params=None, **kwargs):
|
|
calls.append((api, method, dict(params or {})))
|
|
if api == "SYNO.Docker.Project" and method == "list":
|
|
if project_created:
|
|
return {
|
|
project_id: {
|
|
"id": project_id,
|
|
"name": "newapp",
|
|
"status": final_status,
|
|
"path": "/volume1/docker/newapp",
|
|
"containerIds": [],
|
|
"services": [],
|
|
}
|
|
}
|
|
return existing_projects or {}
|
|
if api == "SYNO.FileStation.CreateFolder":
|
|
if create_folder_raises:
|
|
raise create_folder_raises
|
|
return {}
|
|
return {}
|
|
|
|
async def mock_post_request(api, method, version=None, params=None, **kwargs):
|
|
nonlocal project_created
|
|
calls.append((api, f"POST:{method}", dict(params or {})))
|
|
if api == "SYNO.Docker.Project" and method == "create":
|
|
if create_project_raises:
|
|
raise create_project_raises
|
|
project_created = True
|
|
return {"id": project_id}
|
|
return {}
|
|
|
|
async def mock_build_stream(pid):
|
|
calls.append(("SYNO.Docker.Project", "build_stream", {"id": pid}))
|
|
if build_stream_raises:
|
|
raise build_stream_raises
|
|
return build_stream_log
|
|
|
|
client.request.side_effect = mock_request
|
|
client.post_request.side_effect = mock_post_request
|
|
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
|
|
return client, calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_project_preview_only():
|
|
"""Without confirmed=True, no side effects — return a preview with service count."""
|
|
client, calls = make_create_project_client()
|
|
tools = make_projects_tools(client)
|
|
|
|
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE)
|
|
|
|
assert "confirmed=True" in result
|
|
assert "newapp" in result
|
|
assert "Services: 2" in result
|
|
assert "/docker/newapp" in result
|
|
# No CreateFolder, no Project/create, no build_stream
|
|
methods = [m for _, m, _ in calls]
|
|
assert "create" not in methods # FileStation.CreateFolder
|
|
assert "POST:create" not in methods
|
|
assert "build_stream" not in methods
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_project_rejects_invalid_name():
|
|
"""Path-traversal-style names are rejected before any I/O."""
|
|
client, calls = make_create_project_client()
|
|
tools = make_projects_tools(client)
|
|
|
|
result = await tools["create_project"]("../escape", SIMPLE_COMPOSE, confirmed=True)
|
|
|
|
assert "invalid project name" in result.lower()
|
|
# No API calls at all
|
|
assert calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_project_rejects_invalid_yaml():
|
|
"""Malformed compose content is rejected before any I/O."""
|
|
client, calls = make_create_project_client()
|
|
tools = make_projects_tools(client)
|
|
|
|
result = await tools["create_project"]("newapp", "this: is: not: yaml: [", confirmed=True)
|
|
|
|
assert "Invalid YAML" in result or "Invalid compose" in result
|
|
assert calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_project_rejects_compose_without_services():
|
|
client, calls = make_create_project_client()
|
|
tools = make_projects_tools(client)
|
|
|
|
result = await tools["create_project"]("newapp", "version: '3'\n", confirmed=True)
|
|
|
|
assert "services" in result.lower()
|
|
assert calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_project_already_exists():
|
|
"""If a project with the given name already exists, abort without creating anything."""
|
|
existing = {
|
|
"uuid-1": {
|
|
"id": "uuid-1",
|
|
"name": "newapp",
|
|
"status": "RUNNING",
|
|
"path": "/volume1/docker/newapp",
|
|
"containerIds": [],
|
|
"services": [],
|
|
}
|
|
}
|
|
client, calls = make_create_project_client(existing_projects=existing)
|
|
tools = make_projects_tools(client)
|
|
|
|
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
|
|
|
assert "already exists" in result
|
|
assert "RUNNING" in result
|
|
# Only the list call should have happened
|
|
methods = [m for _, m, _ in calls]
|
|
assert "create" not in methods
|
|
assert "POST:create" not in methods
|
|
client.trigger_build_stream.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_project_happy_path():
|
|
"""confirmed=True with no existing project: folder → create → build_stream → RUNNING."""
|
|
client, calls = make_create_project_client()
|
|
tools = make_projects_tools(client)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
|
|
|
assert "created and started successfully" in result
|
|
|
|
# Verify all three steps fired in the correct order
|
|
summarised = [(api, method) for api, method, _ in calls]
|
|
assert ("SYNO.FileStation.CreateFolder", "create") in summarised
|
|
assert ("SYNO.Docker.Project", "POST:create") in summarised
|
|
assert ("SYNO.Docker.Project", "build_stream") in summarised
|
|
cf_idx = summarised.index(("SYNO.FileStation.CreateFolder", "create"))
|
|
cp_idx = summarised.index(("SYNO.Docker.Project", "POST:create"))
|
|
bs_idx = summarised.index(("SYNO.Docker.Project", "build_stream"))
|
|
assert cf_idx < cp_idx < bs_idx
|
|
|
|
# Verify JSON-encoding of CreateFolder params
|
|
cf_params = next(p for api, m, p in calls if api == "SYNO.FileStation.CreateFolder")
|
|
assert cf_params["folder_path"] == '"/docker"'
|
|
assert cf_params["name"] == '"newapp"'
|
|
assert cf_params["force_parent"] == "true"
|
|
|
|
# Verify JSON-encoding of Docker.Project/create params
|
|
cp_params = next(
|
|
p for api, m, p in calls if api == "SYNO.Docker.Project" and m == "POST:create"
|
|
)
|
|
assert cp_params["name"] == '"newapp"'
|
|
assert cp_params["share_path"] == '"/docker/newapp"'
|
|
assert cp_params["enable_service_portal"] == "false"
|
|
assert cp_params["service_portal_port"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_project_explicit_share_path():
|
|
"""Caller-supplied share_path overrides the derived default."""
|
|
client, calls = make_create_project_client()
|
|
tools = make_projects_tools(client)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
result = await tools["create_project"](
|
|
"newapp",
|
|
SIMPLE_COMPOSE,
|
|
share_path="/projects/custom/newapp",
|
|
confirmed=True,
|
|
)
|
|
|
|
assert "created and started successfully" in result
|
|
cf_params = next(p for api, m, p in calls if api == "SYNO.FileStation.CreateFolder")
|
|
assert cf_params["folder_path"] == '"/projects/custom"'
|
|
assert cf_params["name"] == '"newapp"'
|
|
cp_params = next(
|
|
p for api, m, p in calls if api == "SYNO.Docker.Project" and m == "POST:create"
|
|
)
|
|
assert cp_params["share_path"] == '"/projects/custom/newapp"'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_project_error_2100_surfaces_hint():
|
|
"""DSM error 2100 on Project/create returns a clear 'target folder' message."""
|
|
from mcp_synology_container.dsm_client import SynologyError
|
|
|
|
client, calls = make_create_project_client(
|
|
create_project_raises=SynologyError("Folder issue", code=2100),
|
|
)
|
|
tools = make_projects_tools(client)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
|
|
|
assert "2100" in result
|
|
assert "target folder" in result.lower()
|
|
# build_stream must NOT have been called after a failed Project/create
|
|
client.trigger_build_stream.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_project_build_stream_failure_keeps_registration():
|
|
"""If build_stream fails AFTER successful Project/create, the user is told the
|
|
project is registered-but-not-started and pointed at redeploy_project."""
|
|
from mcp_synology_container.dsm_client import SynologyError
|
|
|
|
client, calls = make_create_project_client(
|
|
build_stream_raises=SynologyError("transport error", code=0),
|
|
)
|
|
tools = make_projects_tools(client)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
|
|
|
assert "registered but was not started" in result
|
|
assert "redeploy_project" in result
|
|
assert "created and started successfully" not in result
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# delete_project
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def make_delete_project_client(
|
|
*,
|
|
project: dict | None = None,
|
|
delete_raises: Exception | None = None,
|
|
):
|
|
"""Stateful mock client for delete_project tests.
|
|
|
|
- `project`: the project dict returned by Project/list. None → no
|
|
project registered (simulates the "not found" case).
|
|
- `delete_raises`: optional exception raised when Project/delete is
|
|
called (used to simulate DSM refusing to delete a running project).
|
|
"""
|
|
client = AsyncMock()
|
|
calls: list[tuple[str, str, dict]] = []
|
|
project_deleted = False
|
|
|
|
async def mock_request(api, method, version=None, params=None, **kwargs):
|
|
nonlocal project_deleted
|
|
calls.append((api, method, dict(params or {})))
|
|
if api == "SYNO.Docker.Project" and method == "list":
|
|
if project is None or project_deleted:
|
|
return {}
|
|
return {project["id"]: project}
|
|
if api == "SYNO.Docker.Project" and method == "delete":
|
|
if delete_raises:
|
|
raise delete_raises
|
|
project_deleted = True
|
|
return {}
|
|
return {}
|
|
|
|
client.request.side_effect = mock_request
|
|
return client, calls
|
|
|
|
|
|
SAMPLE_PROJECT_RUNNING = {
|
|
"id": "uuid-abc",
|
|
"name": "myapp",
|
|
"status": "RUNNING",
|
|
"path": "/volume1/docker/myapp",
|
|
"share_path": "/docker/myapp",
|
|
"containerIds": ["c1"],
|
|
"services": [],
|
|
}
|
|
|
|
SAMPLE_PROJECT_STOPPED = {
|
|
**SAMPLE_PROJECT_RUNNING,
|
|
"status": "STOPPED",
|
|
"containerIds": [],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_project_preview_only():
|
|
"""confirmed=False: no Project/delete call; preview shows UUID and warns about folder."""
|
|
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED)
|
|
tools = make_projects_tools(client)
|
|
|
|
result = await tools["delete_project"]("myapp")
|
|
|
|
assert "confirmed=True" in result
|
|
assert "uuid-abc" in result
|
|
assert "myapp" in result
|
|
assert "/docker/myapp" in result
|
|
# No delete call
|
|
methods = [m for _, m, _ in calls]
|
|
assert "delete" not in methods
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_project_not_found():
|
|
"""If the project isn't registered, return a clear 'not found' message — no delete."""
|
|
client, calls = make_delete_project_client(project=None)
|
|
tools = make_projects_tools(client)
|
|
|
|
result = await tools["delete_project"]("ghost", confirmed=True)
|
|
|
|
assert "not found" in result
|
|
assert "ghost" in result
|
|
methods = [m for _, m, _ in calls]
|
|
assert "delete" not in methods
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_project_rejects_invalid_name():
|
|
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED)
|
|
tools = make_projects_tools(client)
|
|
|
|
result = await tools["delete_project"]("../escape", confirmed=True)
|
|
|
|
assert "invalid project name" in result.lower()
|
|
# Not even a list call
|
|
assert calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_project_happy_path():
|
|
"""confirmed=True with a stopped project: UUID is json.dumps'd; success message
|
|
mentions both 'deleted' and the surviving folder path."""
|
|
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED)
|
|
tools = make_projects_tools(client)
|
|
|
|
result = await tools["delete_project"]("myapp", confirmed=True)
|
|
|
|
assert "deleted" in result
|
|
assert "registration removed" in result
|
|
assert "/docker/myapp" in result
|
|
assert "NOT deleted" in result
|
|
|
|
delete_call = next(
|
|
(a, m, p) for a, m, p in calls if a == "SYNO.Docker.Project" and m == "delete"
|
|
)
|
|
_api, _method, params = delete_call
|
|
# The UUID must arrive JSON-encoded per the reverse-engineered DSM convention.
|
|
assert params["id"] == '"uuid-abc"'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_project_running_blocked_connector_side():
|
|
"""Live test showed that DSM does NOT reject Project/delete on a running project —
|
|
it silently orphans the containers. The connector must therefore block the call
|
|
itself when the project is RUNNING, without ever calling client.request(delete)."""
|
|
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_RUNNING)
|
|
tools = make_projects_tools(client)
|
|
|
|
result = await tools["delete_project"]("myapp", confirmed=True)
|
|
|
|
assert "RUNNING" in result
|
|
assert "stop_project" in result
|
|
# The delete endpoint must NOT have been called — no orphaned containers.
|
|
delete_calls = [m for _, m, _ in calls if m == "delete"]
|
|
assert delete_calls == []
|