Fix container hash-prefix + status-aware redeploy
Bug 1: Container name hash-prefix (e.g. f93cb8b504f7_jenkins) - _strip_hash_prefix(): strips 12-char hex prefix and leading slash - _resolve_container_name(): looks up actual DSM name from container list - Applied in list_containers (display), container_stats (matching), get_container_status/get_container_logs/exec_in_container (lookup) Bug 2: redeploy_project DSM 2101/1202 on wrong project state - Fetch project status before acting - RUNNING → stop then start - STOPPED → start directly (nothing to stop) - BUILD_FAILED → suppress stop error, then start - Other → return error with workaround hint 36 tests all passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -161,3 +161,150 @@ async def test_redeploy_project_requires_confirmation():
|
||||
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.modules.projects import register_projects
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
|
||||
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": [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_running_project():
|
||||
"""RUNNING project: stop then start (2 steps)."""
|
||||
client = AsyncMock()
|
||||
calls = []
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
calls.append((api, method))
|
||||
return project_list("RUNNING") if method == "list" else {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "redeployed successfully" in result
|
||||
methods = [m for _, m in calls]
|
||||
assert "stop" in methods
|
||||
assert "start" in methods
|
||||
# stop must come before start
|
||||
assert methods.index("stop") < methods.index("start")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_stopped_project_starts_directly():
|
||||
"""STOPPED project: skip stop, just start."""
|
||||
client = AsyncMock()
|
||||
calls = []
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
calls.append((api, method))
|
||||
return project_list("STOPPED") if method == "list" else {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
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 "start" in methods
|
||||
assert "STOPPED" in result or "starting directly" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_build_failed_project():
|
||||
"""BUILD_FAILED project: force-stop (non-fatal), then start."""
|
||||
client = AsyncMock()
|
||||
calls = []
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
calls.append((api, method))
|
||||
return project_list("BUILD_FAILED") if method == "list" else {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_build_failed_stop_error_nonfatal():
|
||||
"""BUILD_FAILED: stop failure must not abort the redeploy."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if method == "list":
|
||||
return project_list("BUILD_FAILED")
|
||||
if method == "stop":
|
||||
raise SynologyError("already stopped", code=2101)
|
||||
return {} # start succeeds
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "redeployed successfully" 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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user