diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e0f39..99d8e71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,32 @@ All notable changes to this project will be documented in this file. -## [0.3.0] - 2026-05-18 +## [0.3.1] - 2026-05-18 + +### Added + +- `create_project` — register a new Container Manager project from a + compose YAML string. Three-step flow: + 1. Create the target folder via `SYNO.FileStation.CreateFolder` with + `force_parent=true` (idempotent — does not fail if the folder + already exists, and creates missing intermediate directories). + Without this step, `SYNO.Docker.Project/create` fails with DSM + error code 2100. + 2. `SYNO.Docker.Project/create` (form-encoded POST, JSON-encoded + string parameters per DSM convention) returns the new project's + UUID. + 3. `trigger_build_stream` + `_wait_for_project_running` — reuses the + existing image-pull / start / poll machinery (including the + `BUILD_FAILED` early-exit from welle 2). + Defaults: `share_path` is derived from `compose_base_path` (e.g. + `/volume1/docker` + `myapp` → `/docker/myapp`). The compose content + is validated as YAML 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. Requires + `confirmed=True`; the preview shows the resolved share path and the + service count parsed from the compose content. + + ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 02074ea..5b17cc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,11 +33,11 @@ Only a second consecutive failure is treated as a real auth problem. --- -## Implemented tools (23) +## Implemented tools (24) | Category | Tools | |---|---| -| Projects | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project` | +| Projects | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project`, `create_project` | | Containers | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats`, `delete_container` | | Compose | `read_compose`, `update_compose`, `update_image_tag`, `update_env_var` | | Images | `check_image_updates`, `list_images`, `delete_image` | @@ -69,8 +69,9 @@ Only a second consecutive failure is treated as a real auth problem. ## Implementation rules - Confirmation required before destructive operations: `stop_project`, - `redeploy_project`, `exec_in_container`, `update_image_tag`, - `update_env_var`, `update_compose`, `delete_container` + `redeploy_project`, `create_project`, `exec_in_container`, + `update_image_tag`, `update_env_var`, `update_compose`, + `delete_container` - After compose changes: suggest `redeploy_project` - DSM errors → human-readable message, no stack traces - No secrets in stderr output diff --git a/pyproject.toml b/pyproject.toml index a924c1e..30e91ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-synology-container" -version = "0.3.0" +version = "0.3.1" description = "MCP server for Synology Container Manager" requires-python = ">=3.12" dependencies = [ diff --git a/src/mcp_synology_container/modules/projects.py b/src/mcp_synology_container/modules/projects.py index 0499941..51c4f9e 100644 --- a/src/mcp_synology_container/modules/projects.py +++ b/src/mcp_synology_container/modules/projects.py @@ -1,12 +1,15 @@ -"""MCP tools for SYNO.Docker.Project: list, status, start, stop, redeploy.""" +"""MCP tools for SYNO.Docker.Project: list, status, start, stop, redeploy, create.""" from __future__ import annotations import asyncio import contextlib +import json import logging from typing import TYPE_CHECKING, Any +import yaml + if TYPE_CHECKING: from mcp.server.fastmcp import FastMCP @@ -205,6 +208,170 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non return "\n".join(results) + @mcp.tool() + async def create_project( + project_name: str, + compose_content: str, + share_path: str | None = None, + confirmed: bool = False, + ): + """Create a new Container Manager project from compose YAML. Requires confirmed=True.""" + # Lazy import avoids a circular dependency between projects.py and compose.py. + from mcp_synology_container.dsm_client import SynologyError + from mcp_synology_container.modules.compose import ( + _to_filestation_path, + _validate_project_name, + ) + + if (err := _validate_project_name(project_name)) is not None: + return err + + # Parse compose YAML up-front so a malformed input is rejected before + # any side effects (folder creation, project registration). + try: + parsed = yaml.safe_load(compose_content) + except yaml.YAMLError as e: + return f"Invalid YAML content: {e}" + if not isinstance(parsed, dict) or "services" not in parsed: + return "Invalid compose file: must be a YAML document with a 'services' key." + services = parsed.get("services") or {} + service_count = len(services) if isinstance(services, dict) else 0 + + # Resolve share_path. When the caller omits it, derive it from + # compose_base_path (e.g. "/volume1/docker" + "myapp" → "/docker/myapp"). + if share_path is None: + parent_share = _to_filestation_path(config.compose_base_path).rstrip("/") + resolved_share_path = f"{parent_share}/{project_name}" + folder_name = project_name + else: + resolved_share_path = share_path.rstrip("/") + parent_share, _, folder_name = resolved_share_path.rpartition("/") + if not parent_share: + parent_share = "/" + if not folder_name: + return f"Invalid share_path '{share_path}': missing folder name." + + # Check for an existing project with the same name BEFORE creating + # the folder — avoids leaving an orphaned directory on the NAS. + existing = await _find_project(client, project_name) + if existing is not None: + return ( + f"Project '{project_name}' already exists " + f"(status: {existing.get('status', '?')}, path: {existing.get('path', '?')})." + ) + + if not confirmed: + return ( + f"About to create new project '{project_name}':\n" + f" Share path: {resolved_share_path}\n" + f" Services: {service_count}\n\n" + f"Call this tool again with confirmed=True to apply." + ) + + results: list[str] = [] + + # ── Step 1: Create the target folder via FileStation ────────────────── + # force_parent=true makes the call idempotent: it does not fail if the + # folder already exists, and it creates any missing intermediate + # directories. Without this step Docker.Project/create fails with + # error code 2100 ("target folder issue"). + results.append("Step 1/3: Creating target folder...") + try: + await client.request( + "SYNO.FileStation.CreateFolder", + "create", + version=2, + params={ + "folder_path": json.dumps(parent_share), + "name": json.dumps(folder_name), + "force_parent": "true", + }, + ) + results.append(f" Folder ready: {resolved_share_path}") + except SynologyError as e: + return ( + f"Error creating folder for project '{project_name}': {e}\n" + f" Attempted path: {parent_share}/{folder_name}" + ) + + # ── Step 2: Register the project with Container Manager ─────────────── + results.append("Step 2/3: Registering project with Container Manager...") + try: + data = await client.post_request( + "SYNO.Docker.Project", + "create", + version=1, + params={ + "name": json.dumps(project_name), + "share_path": json.dumps(resolved_share_path), + "content": json.dumps(compose_content), + "enable_service_portal": json.dumps(False), + "service_portal_name": json.dumps(""), + "service_portal_port": 0, + "service_portal_protocol": json.dumps("http"), + }, + ) + except SynologyError as e: + if e.code == 2100: + return ( + f"Project creation failed — target folder issue (DSM error 2100).\n" + f" Share path: {resolved_share_path}\n" + f" Folder was created in step 1 but DSM rejected it. " + f"Verify the share exists and the user has write access." + ) + return f"Error registering project '{project_name}': {e}" + + project_id = (data.get("id") if isinstance(data, dict) else "") or "" + if not project_id: + return ( + f"Project registered but DSM returned no project ID. " + f"Check list_projects to confirm — response was: {data!r}" + ) + results.append(f" Registered (id={project_id}).") + + # ── Step 3: Trigger the build (pull images + start containers) ──────── + results.append("Step 3/3: Triggering build_stream (image pull and start)...") + try: + await client.trigger_build_stream(project_id) + results.append(" Build request accepted by DSM.") + except Exception as e: + results.append(f" Error triggering build: {e}") + results.append( + f"\nProject '{project_name}' is registered but was not started. " + f"Run redeploy_project('{project_name}', confirmed=True) to retry." + ) + return "\n".join(results) + + results.append( + f"Waiting for project to reach RUNNING state (up to {_BUILD_POLL_TIMEOUT}s)..." + ) + final_status = await _wait_for_project_running( + client, project_name, timeout=_BUILD_POLL_TIMEOUT + ) + if final_status == "RUNNING": + results.append(" Project is RUNNING.") + results.append(f"\nProject '{project_name}' created and started successfully.") + elif final_status in _TERMINAL_FAILURE_STATUSES: + results.append(f" Build failed — project status is '{final_status}'.") + if final_status == "BUILD_FAILED": + results.append( + " Check the image tag(s) in the compose content " + "(update_image_tag) and retry redeploy_project." + ) + results.append( + f"\nProject '{project_name}' is registered but failed to start " + f"(status: {final_status})." + ) + else: + results.append( + f" Warning: project status is '{final_status}' after " + f"{_BUILD_POLL_TIMEOUT}s. " + f"Containers may still be starting — check with get_project_status." + ) + results.append(f"\nProject '{project_name}' created (final status: {final_status}).") + + return "\n".join(results) + async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None: """Find a project by name from the list. diff --git a/tests/test_modules/test_projects.py b/tests/test_modules/test_projects.py index b77c241..8b7e73b 100644 --- a/tests/test_modules/test_projects.py +++ b/tests/test_modules/test_projects.py @@ -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 diff --git a/uv.lock b/uv.lock index 943a16e..4fce740 100644 --- a/uv.lock +++ b/uv.lock @@ -362,7 +362,7 @@ wheels = [ [[package]] name = "mcp-synology-container" -version = "0.3.0" +version = "0.3.1" source = { editable = "." } dependencies = [ { name = "click" },