Fix Jenkins-update flow: redeploy_project pull + delete_image safety + delete_container

Bug fixes from product test:
1. redeploy_project: BUILD_FAILED now includes explicit image pull (stop → pull → start)
2. delete_image: Distinguishes running vs stopped containers, suggests system_prune for stopped refs
3. New tool delete_container: Verify stopped state before deletion, confirmation required

Tests added for all three paths plus stopped-container edge cases.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 07:09:21 +02:00
parent 81d5acd83e
commit 223075e602
7 changed files with 207 additions and 26 deletions
+74 -1
View File
@@ -247,6 +247,76 @@ async def test_container_stats_found():
assert "Block I/O" in result
# ──────────────────────────────────────────────────────────────────────────────
# delete_container
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_delete_container_preview():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["delete_container"]("myapp_web", confirmed=False)
assert "Preview" in result
assert "myapp_web" in result
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_delete_container_not_found():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = None
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["delete_container"]("nonexistent", confirmed=True)
assert "not found" in result
@pytest.mark.asyncio
async def test_delete_container_running_blocked():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = {
"details": {"State": {"Running": True, "Status": "running"}},
"profile": {"image": "nginx:latest"},
}
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["delete_container"]("myapp_web", confirmed=True)
assert "running" in result.lower()
assert "Cannot delete" in result
@pytest.mark.asyncio
async def test_delete_container_stopped_confirmed():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = {
"details": {"State": {"Running": False, "Status": "exited"}},
"profile": {"image": "nginx:latest"},
}
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["delete_container"]("myapp_web", confirmed=True)
assert "Deleted" in result
assert "myapp_web" in result
@pytest.mark.asyncio
async def test_container_stats_cpu_calculation():
"""CPU% is computed via the standard Docker formula."""
@@ -537,7 +607,10 @@ async def test_get_container_logs_resolves_hash_prefix():
return HASH_PREFIXED_CONTAINERS_DATA
if api == "SYNO.Docker.Container.Log" and method == "get":
assert kwargs["params"]["name"] == "f93cb8b504f7_jenkins"
return {"logs": [{"created": "2025-01-01", "stream": "stdout", "text": "started"}], "total": 1}
return {
"logs": [{"created": "2025-01-01", "stream": "stdout", "text": "started"}],
"total": 1,
}
return {}
client = AsyncMock()
+33 -2
View File
@@ -255,7 +255,7 @@ async def test_delete_image_not_found():
@pytest.mark.asyncio
async def test_delete_image_in_use_blocked():
async def test_delete_image_in_use_by_running_blocked():
from mcp_synology_container.modules.images import register_images
client = AsyncMock()
@@ -265,7 +265,7 @@ async def test_delete_image_in_use_blocked():
if api == "SYNO.Docker.Image" and method == "list":
return SAMPLE_IMAGES
if api == "SYNO.Docker.Container":
return SAMPLE_CONTAINERS # nginx is in use
return SAMPLE_CONTAINERS # nginx is in use (running)
return {}
client.request.side_effect = mock_request
@@ -274,10 +274,41 @@ async def test_delete_image_in_use_blocked():
result = await tools["delete_image"](image_id="nginx:1.24", confirmed=True)
assert "Cannot delete" in result
assert "running" in result.lower()
assert "my-nginx" in result
client.post_request.assert_not_called()
@pytest.mark.asyncio
async def test_delete_image_in_use_by_stopped_blocked():
from mcp_synology_container.modules.images import register_images
client = AsyncMock()
client.post_request = AsyncMock(return_value={})
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
return SAMPLE_IMAGES
if api == "SYNO.Docker.Container":
return {
"containers": [
{"name": "stopped-nginx", "image_id": "sha256:aaaa", "status": "exited"}
]
}
return {}
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["delete_image"](image_id="nginx:1.24", confirmed=True)
assert "Cannot delete" in result
assert "stopped" in result.lower()
assert "stopped-nginx" in result
assert "system_prune" in result
client.post_request.assert_not_called()
@pytest.mark.asyncio
async def test_delete_image_by_hash():
import json
+12 -3
View File
@@ -100,6 +100,7 @@ async def test_list_projects_tool():
def decorator(fn):
tools[fn.__name__] = fn
return fn
return decorator
register_projects(MockMCP(), config, client)
@@ -128,6 +129,7 @@ async def test_stop_project_requires_confirmation():
def decorator(fn):
tools[fn.__name__] = fn
return fn
return decorator
register_projects(MockMCP(), config, client)
@@ -154,6 +156,7 @@ async def test_redeploy_project_requires_confirmation():
def decorator(fn):
tools[fn.__name__] = fn
return fn
return decorator
register_projects(MockMCP(), config, client)
@@ -183,6 +186,7 @@ def make_projects_tools(client):
def decorator(fn):
tools[fn.__name__] = fn
return fn
return decorator
register_projects(MockMCP(), config, client)
@@ -249,7 +253,7 @@ async def test_redeploy_stopped_project_starts_directly():
@pytest.mark.asyncio
async def test_redeploy_build_failed_project():
"""BUILD_FAILED project: force-stop (non-fatal), then start."""
"""BUILD_FAILED project: stop, pull images, then start (3 steps)."""
client = AsyncMock()
calls = []
@@ -265,13 +269,16 @@ async def test_redeploy_build_failed_project():
assert "redeployed successfully" in result
methods = [m for _, m in calls]
assert "stop" in methods
assert "pull" in methods # New: pull step
assert "start" in methods
assert methods.index("stop") < methods.index("start")
# Order: stop → pull → start
assert methods.index("stop") < methods.index("pull")
assert methods.index("pull") < methods.index("start")
@pytest.mark.asyncio
async def test_redeploy_build_failed_stop_error_nonfatal():
"""BUILD_FAILED: stop failure must not abort the redeploy."""
"""BUILD_FAILED: stop/pull failure must not abort the redeploy."""
from mcp_synology_container.dsm_client import SynologyError
client = AsyncMock()
@@ -281,6 +288,8 @@ async def test_redeploy_build_failed_stop_error_nonfatal():
return project_list("BUILD_FAILED")
if method == "stop":
raise SynologyError("already stopped", code=2101)
if method == "pull":
raise SynologyError("pull failed", code=2102)
return {} # start succeeds
client.request.side_effect = mock_request