v0.2.1: redeploy_project post-start polling (30s timeout)
DSM starts containers asynchronously - start_project returns immediately while containers are still initialising. Adds _wait_for_project_running: polls SYNO.Docker.Project/list every 2s up to 30s after issuing start. Reports RUNNING on success; emits a warning instead of failure on timeout so callers can still verify with get_project_status. Applies to all three redeploy paths (RUNNING, STOPPED, BUILD_FAILED). Also bumps version 0.2.0 → 0.2.1 and adds CHANGELOG entry. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -14,6 +15,9 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_POLL_INTERVAL = 2 # seconds between status checks
|
||||
_POLL_TIMEOUT = 30 # maximum seconds to wait for RUNNING
|
||||
|
||||
|
||||
def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
"""Register all project management tools with the MCP server."""
|
||||
@@ -124,6 +128,10 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
- STOPPED → start directly (nothing to stop)
|
||||
- BUILD_FAILED → stop, pull images, then start
|
||||
|
||||
After issuing start, polls the project status every 2 seconds for up
|
||||
to 30 seconds until the project reaches RUNNING. Reports the final
|
||||
status; emits a warning on timeout instead of failing.
|
||||
|
||||
This operation will briefly take the project offline.
|
||||
Requires confirmation before executing.
|
||||
|
||||
@@ -149,30 +157,30 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
try:
|
||||
if status == "STOPPED":
|
||||
results.append("Project is STOPPED — starting directly.")
|
||||
results.append("Step 1/1: Starting project...")
|
||||
results.append("Step 1/2: Starting project...")
|
||||
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
|
||||
results.append(" Project started.")
|
||||
results.append(" Start issued.")
|
||||
|
||||
elif status == "BUILD_FAILED":
|
||||
results.append("Step 1/3: Stopping failed build...")
|
||||
results.append("Step 1/4: 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/3: Pulling updated images...")
|
||||
results.append("Step 2/4: Pulling updated images...")
|
||||
with contextlib.suppress(Exception):
|
||||
await client.request("SYNO.Docker.Image", "pull", params={"id": project_id})
|
||||
results.append(" Images pulled.")
|
||||
results.append("Step 3/3: Starting project...")
|
||||
results.append("Step 3/4: Starting project...")
|
||||
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
|
||||
results.append(" Project started.")
|
||||
results.append(" Start issued.")
|
||||
|
||||
elif status in ("RUNNING", ""):
|
||||
results.append("Step 1/2: Stopping project...")
|
||||
results.append("Step 1/3: Stopping project...")
|
||||
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
|
||||
results.append(" Project stopped.")
|
||||
results.append("Step 2/2: Starting project...")
|
||||
results.append("Step 2/3: Starting project...")
|
||||
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
|
||||
results.append(" Project started.")
|
||||
results.append(" Start issued.")
|
||||
|
||||
else:
|
||||
return (
|
||||
@@ -180,7 +188,21 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
f"Workaround: use stop_project + start_project separately."
|
||||
)
|
||||
|
||||
results.append(f"\nProject '{project_name}' redeployed successfully.")
|
||||
# Poll until RUNNING or timeout
|
||||
poll_step = (
|
||||
"2/2" if status == "STOPPED" else ("4/4" if status == "BUILD_FAILED" else "3/3")
|
||||
)
|
||||
results.append(f"Step {poll_step}: Waiting for project to reach RUNNING state...")
|
||||
final_status = await _wait_for_project_running(client, project_name)
|
||||
if final_status == "RUNNING":
|
||||
results.append(" Project is RUNNING.")
|
||||
results.append(f"\nProject '{project_name}' redeployed successfully.")
|
||||
else:
|
||||
results.append(
|
||||
f" Warning: project status is '{final_status}' after {_POLL_TIMEOUT}s. "
|
||||
f"Containers may still be starting — check with get_project_status."
|
||||
)
|
||||
results.append(f"\nProject '{project_name}' start issued (status: {final_status}).")
|
||||
|
||||
except Exception as e:
|
||||
results.append(f"Error during redeploy: {e}")
|
||||
@@ -211,6 +233,39 @@ async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _wait_for_project_running(
|
||||
client: DsmClient,
|
||||
name: str,
|
||||
timeout: int = _POLL_TIMEOUT,
|
||||
interval: int = _POLL_INTERVAL,
|
||||
) -> str:
|
||||
"""Poll until the project reaches RUNNING status or timeout expires.
|
||||
|
||||
Args:
|
||||
client: DsmClient instance.
|
||||
name: Project name to watch.
|
||||
timeout: Maximum seconds to wait (default 30).
|
||||
interval: Seconds between polls (default 2).
|
||||
|
||||
Returns:
|
||||
Final project status string (may not be "RUNNING" on timeout).
|
||||
"""
|
||||
elapsed = 0
|
||||
while elapsed < timeout:
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
project = await _find_project(client, name)
|
||||
if project is None:
|
||||
continue
|
||||
current = (project.get("status") or "").upper()
|
||||
logger.debug("Polling '%s': status=%s elapsed=%ds", name, current, elapsed)
|
||||
if current == "RUNNING":
|
||||
return current
|
||||
# Return whatever status we last saw (or UNKNOWN on repeated failures)
|
||||
project = await _find_project(client, name)
|
||||
return (project.get("status") or "UNKNOWN").upper() if project else "UNKNOWN"
|
||||
|
||||
|
||||
def _format_project_detail(project: dict[str, Any]) -> str:
|
||||
"""Format project details as human-readable text."""
|
||||
lines = [
|
||||
|
||||
Reference in New Issue
Block a user