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.
|
||||
|
||||
## [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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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 = [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user