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:
+26
-1
@@ -2,7 +2,32 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ Only a second consecutive failure is treated as a real auth problem.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implemented tools (23)
|
## Implemented tools (24)
|
||||||
|
|
||||||
| Category | Tools |
|
| 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` |
|
| 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` |
|
| Compose | `read_compose`, `update_compose`, `update_image_tag`, `update_env_var` |
|
||||||
| Images | `check_image_updates`, `list_images`, `delete_image` |
|
| 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
|
## Implementation rules
|
||||||
|
|
||||||
- Confirmation required before destructive operations: `stop_project`,
|
- Confirmation required before destructive operations: `stop_project`,
|
||||||
`redeploy_project`, `exec_in_container`, `update_image_tag`,
|
`redeploy_project`, `create_project`, `exec_in_container`,
|
||||||
`update_env_var`, `update_compose`, `delete_container`
|
`update_image_tag`, `update_env_var`, `update_compose`,
|
||||||
|
`delete_container`
|
||||||
- After compose changes: suggest `redeploy_project`
|
- After compose changes: suggest `redeploy_project`
|
||||||
- DSM errors → human-readable message, no stack traces
|
- DSM errors → human-readable message, no stack traces
|
||||||
- No secrets in stderr output
|
- No secrets in stderr output
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.3.0"
|
version = "0.3.1"
|
||||||
description = "MCP server for Synology Container Manager"
|
description = "MCP server for Synology Container Manager"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
@@ -205,6 +208,170 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
|
|
||||||
return "\n".join(results)
|
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:
|
async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None:
|
||||||
"""Find a project by name from the list.
|
"""Find a project by name from the list.
|
||||||
|
|||||||
@@ -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"]
|
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.
|
# Generous upper bound — early exit means handful of polls, not hundreds.
|
||||||
assert len(list_calls) <= 5
|
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
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.3.0"
|
version = "0.3.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user