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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user