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