Fix container hash-prefix + status-aware redeploy

Bug 1: Container name hash-prefix (e.g. f93cb8b504f7_jenkins)
- _strip_hash_prefix(): strips 12-char hex prefix and leading slash
- _resolve_container_name(): looks up actual DSM name from container list
- Applied in list_containers (display), container_stats (matching),
  get_container_status/get_container_logs/exec_in_container (lookup)

Bug 2: redeploy_project DSM 2101/1202 on wrong project state
- Fetch project status before acting
- RUNNING     → stop then start
- STOPPED     → start directly (nothing to stop)
- BUILD_FAILED → suppress stop error, then start
- Other       → return error with workaround hint

36 tests all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 21:43:02 +02:00
parent 6fa35e1b48
commit c8cda5ef2b
5 changed files with 419 additions and 51 deletions
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import re
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
@@ -13,6 +14,51 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Matches DSM hash-prefixed container names like "f93cb8b504f7_jenkins"
_HASH_PREFIX_RE = re.compile(r"^[a-f0-9]{12}_(.+)$")
def _strip_hash_prefix(name: str) -> str:
"""Strip 12-char hex hash prefix from container names.
DSM sometimes returns names like 'f93cb8b504f7_jenkins' when the
service name in compose.yaml differs from container_name. Returns the
clean name (also strips a leading slash if present).
"""
clean = name.lstrip("/")
match = _HASH_PREFIX_RE.match(clean)
return match.group(1) if match else clean
async def _resolve_container_name(client: DsmClient, user_name: str) -> str:
"""Resolve a user-supplied name to the actual DSM container name.
Needed because DSM may store the container as 'f93cb8b504f7_jenkins'
while the user passes 'jenkins'. Falls back to user_name unchanged if
the list cannot be fetched or no match is found.
Args:
client: DsmClient instance.
user_name: Name as provided by the user (may or may not have prefix).
Returns:
Actual container name as registered in DSM.
"""
clean_user = _strip_hash_prefix(user_name)
try:
data = await client.request(
"SYNO.Docker.Container",
"list",
params={"limit": "-1", "offset": "0", "type": "all"},
)
for c in data.get("containers", []):
actual = c.get("name", "")
if actual == user_name or _strip_hash_prefix(actual) == clean_user:
return actual
except Exception:
pass
return user_name
def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
"""Register all container management tools with the MCP server."""
@@ -50,7 +96,7 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
lines = [f"Containers ({len(containers)} total):", ""]
for container in sorted(containers, key=lambda c: c.get("name", "")):
name = container.get("name", "?")
name = _strip_hash_prefix(container.get("name", "?"))
state = container.get("status", container.get("state", "?"))
image = container.get("image", "?")
lines.append(f" {name}")
@@ -67,11 +113,12 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
Args:
container_name: Name of the container to inspect.
"""
resolved_name = await _resolve_container_name(client, container_name)
try:
data = await client.request(
"SYNO.Docker.Container",
"get",
params={"name": container_name},
params={"name": resolved_name},
)
except Exception as e:
return f"Error getting container '{container_name}': {e}"
@@ -79,7 +126,8 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
if not data:
return f"Container '{container_name}' not found."
return _format_container_detail(container_name, data)
display_name = _strip_hash_prefix(resolved_name)
return _format_container_detail(display_name, data)
@mcp.tool()
async def get_container_logs(
@@ -94,8 +142,9 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
tail: Number of recent log lines to return (default 100).
keyword: Optional keyword to filter log lines.
"""
resolved_name = await _resolve_container_name(client, container_name)
params: dict[str, Any] = {
"name": container_name,
"name": resolved_name,
"limit": tail,
"offset": 0,
"sort_dir": "DESC",
@@ -117,7 +166,8 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
return f"No logs found for container '{container_name}'."
total = data.get("total", len(logs))
header = f"Logs for {container_name} (showing {len(logs)} of {total}):\n"
display_name = _strip_hash_prefix(container_name)
header = f"Logs for {display_name} (showing {len(logs)} of {total}):\n"
# Logs are returned in DESC order, reverse for chronological display
lines = []
@@ -149,19 +199,18 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
return "No stats data returned."
# Response is a dict keyed by container ID hash; each entry has "name"
# with a leading slash (e.g. "/jenkins").
# with a leading slash (e.g. "/jenkins") and may have a hash prefix.
clean_query = _strip_hash_prefix(container_name)
target: dict[str, Any] | None = None
for entry in data.values():
entry_name = entry.get("name", "").lstrip("/")
if entry_name == container_name.lstrip("/"):
entry_name = _strip_hash_prefix(entry.get("name", ""))
if entry_name == clean_query:
target = entry
break
if target is None:
return (
f"Container '{container_name}' not found in stats. "
f"Available: {', '.join(v.get('name', '?').lstrip('/') for v in data.values())}"
)
available = ", ".join(_strip_hash_prefix(v.get("name", "?")) for v in data.values())
return f"Container '{container_name}' not found in stats. Available: {available}"
# ── CPU % ────────────────────────────────────────────────────────────
cpu_stats = target.get("cpu_stats", {})
@@ -239,12 +288,13 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
f"Call this tool again with confirmed=True to proceed."
)
resolved_name = await _resolve_container_name(client, container_name)
try:
data = await client.request(
"SYNO.Docker.Container",
"exec",
params={
"name": container_name,
"name": resolved_name,
"command": command,
},
)
+40 -34
View File
@@ -2,11 +2,13 @@
from __future__ import annotations
import contextlib
import logging
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP
from mcp_synology_container.config import AppConfig
from mcp_synology_container.dsm_client import DsmClient
@@ -33,7 +35,7 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
return "No projects found."
lines = ["Projects:", ""]
for project_id, proj in sorted(projects.items(), key=lambda x: x[1].get("name", "")):
for _project_id, proj in sorted(projects.items(), key=lambda x: x[1].get("name", "")):
name = proj.get("name", "?")
status = proj.get("status", "?")
path = proj.get("path", "?")
@@ -115,7 +117,12 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
@mcp.tool()
async def redeploy_project(project_name: str, confirmed: bool = False) -> str:
"""Redeploy a project: pull latest images, stop, and restart.
"""Redeploy a project by stopping and restarting it.
Checks the current project status to determine the correct action:
- RUNNING → stop, then start
- STOPPED → start directly (nothing to stop)
- BUILD_FAILED → force-stop, then start
This operation will briefly take the project offline.
Requires confirmation before executing.
@@ -126,10 +133,8 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
"""
if not confirmed:
return (
f"Redeploying project '{project_name}' will:\n"
f" 1. Pull latest images\n"
f" 2. Stop all containers\n"
f" 3. Restart with new images\n\n"
f"Redeploying project '{project_name}' will stop and restart all its "
f"containers (auto-detects current state).\n\n"
f"Call this tool again with confirmed=True to proceed."
)
@@ -138,43 +143,44 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
return f"Project '{project_name}' not found."
project_id = project.get("id", "")
status = (project.get("status") or "").upper()
results = []
try:
# Step 1: Pull latest images via build (triggers compose pull)
results.append("Step 1/3: Pulling latest images...")
try:
await client.request(
"SYNO.Docker.Project",
"build",
params={"id": project_id, "force": "true"},
if status == "STOPPED":
results.append("Project is STOPPED — starting directly.")
results.append("Step 1/1: Starting project...")
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
results.append(" Project started.")
elif status in ("RUNNING", "BUILD_FAILED", ""):
if status == "RUNNING":
results.append("Step 1/2: Stopping project...")
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
results.append(" Project stopped.")
elif status == "BUILD_FAILED":
results.append("Step 1/2: Stopping failed build...")
with contextlib.suppress(Exception):
await client.request(
"SYNO.Docker.Project", "stop", params={"id": project_id}
)
results.append(" Build stopped.")
results.append("Step 2/2: Starting project...")
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
results.append(" Project started.")
else:
return (
f"Cannot redeploy '{project_name}': unexpected status '{status}'.\n"
f"Workaround: use stop_project + start_project separately."
)
results.append(" Images pulled.")
except Exception as e:
results.append(f" Warning: pull step failed ({e}), continuing with restart.")
# Step 2: Stop the project
results.append("Step 2/3: Stopping project...")
await client.request(
"SYNO.Docker.Project",
"stop",
params={"id": project_id},
)
results.append(" Project stopped.")
# Step 3: Start the project
results.append("Step 3/3: Starting project...")
await client.request(
"SYNO.Docker.Project",
"start",
params={"id": project_id},
)
results.append(" Project started.")
results.append(f"\nProject '{project_name}' redeployed successfully.")
except Exception as e:
results.append(f"Error during redeploy: {e}")
results.append("Workaround: use stop_project + start_project separately.")
return "\n".join(results)