feat: v0.3.1 — create_project tool

Adds `create_project` for registering a new Container Manager project
from a compose YAML string. Three-step flow that mirrors the DSM
"Create Project" wizard:

  1. SYNO.FileStation.CreateFolder with force_parent=true (idempotent
     — does not fail if the folder already exists, and creates missing
     intermediate directories). Without this step, Docker.Project/create
     fails with DSM error 2100.
  2. SYNO.Docker.Project/create (form-encoded POST; JSON-encoded string
     parameters per DSM convention) returns the new project UUID.
  3. trigger_build_stream + _wait_for_project_running, reusing the
     existing image-pull / start / poll machinery (including the
     BUILD_FAILED early-exit from welle 2).

Safety:
- Project-name validation (Welle-1 regex) runs before any I/O.
- Compose content is YAML-parsed and must contain a top-level
  `services` key before any side effects.
- A pre-flight list_projects check rejects duplicate names with a
  clear message rather than leaving an orphaned folder on the NAS.
- share_path defaults to compose_base_path + project_name (e.g.
  /volume1/docker + myapp → /docker/myapp); a caller-supplied value
  overrides it.
- Requires confirmed=True; the preview shows the resolved share path
  and the service count parsed from the compose content.
- DSM error 2100 surfaces as "target folder issue" with the attempted
  path. A build_stream failure after a successful Project/create tells
  the user the project is registered-but-not-started and points at
  redeploy_project for recovery.

Tests cover preview-only, already-exists, happy path (with parameter
JSON-encoding assertions), explicit share_path, malformed YAML,
missing services key, invalid project name, error 2100, and
build_stream failure after registration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 11:13:18 +02:00
parent 13e10fa52f
commit 801dbe15dc
6 changed files with 458 additions and 8 deletions
+257
View File
@@ -517,3 +517,260 @@ async def test_redeploy_surfaces_build_failed_with_hint():
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,
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
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