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
+168 -1
View File
@@ -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.