feat: v0.2.4 — image delete workaround + auto-version env-var update
redeploy_project: replace broken SYNO.Docker.Image/pull with a unified 4-step delete-before-start flow for all project states (RUNNING, STOPPED, BUILD_FAILED). Reads image tags from the project's compose.yaml via FileStation before stopping, deletes each cached image (non-fatal), then starts the project so DSM auto-pulls the latest version. Polls for RUNNING as before. update_image_tag: auto-update env vars whose value equals the numeric version prefix of the old tag when the new tag shares the same <digits>-<suffix> pattern (e.g. JENKINS_VERSION=2.558 → 2.560 when tag changes 2.558-jdk21 → 2.560-jdk21). Preview mode lists the pending auto-updates. Only triggers when the var exists and the pattern matches. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,27 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.2.4] - 2026-04-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `redeploy_project`: Replaced broken `SYNO.Docker.Image/pull` with a
|
||||||
|
delete-before-start workaround. The tool now reads image tags from the
|
||||||
|
project's compose file via FileStation, deletes each cached image before
|
||||||
|
calling `start` (so DSM auto-pulls the latest version), then polls for
|
||||||
|
`RUNNING`. Image deletion is non-fatal — if it fails the project still starts.
|
||||||
|
Unified 4-step flow for all project states (RUNNING, STOPPED, BUILD_FAILED).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `update_image_tag`: Auto-updates environment variables whose value equals
|
||||||
|
the numeric version prefix of the old tag when the new tag shares the same
|
||||||
|
`<digits>-<suffix>` pattern. For example, changing `2.558-jdk21` →
|
||||||
|
`2.560-jdk21` automatically updates `JENKINS_VERSION=2.558` to
|
||||||
|
`JENKINS_VERSION=2.560`. The preview (unconfirmed call) now lists which env
|
||||||
|
vars will be updated. Only triggers when the variable exists and the pattern
|
||||||
|
matches; no change for plain tags like `latest`.
|
||||||
|
|
||||||
## [0.2.3] - 2026-04-21
|
## [0.2.3] - 2026-04-21
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.2.3"
|
version = "0.2.4"
|
||||||
description = "MCP server for Synology Container Manager"
|
description = "MCP server for Synology Container Manager"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from mcp_synology_container.config import AppConfig
|
from mcp_synology_container.config import AppConfig
|
||||||
from mcp_synology_container.dsm_client import DsmClient
|
from mcp_synology_container.dsm_client import DsmClient
|
||||||
|
|
||||||
@@ -43,6 +44,24 @@ _COMPOSE_FILENAMES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_version_prefix(tag: str) -> str | None:
|
||||||
|
"""Extract the leading numeric segment from a versioned image tag.
|
||||||
|
|
||||||
|
Returns the numeric prefix before the first ``-`` when the tag has the
|
||||||
|
form ``<digits[.digits...]>-<suffix>`` (e.g. ``2.558-jdk21`` → ``"2.558"``).
|
||||||
|
Returns ``None`` for tags that do not match this pattern (e.g. ``"latest"``,
|
||||||
|
``"1.25"`` without a suffix, or empty strings).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tag: Image tag string to inspect.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Numeric prefix string, or None if the pattern does not match.
|
||||||
|
"""
|
||||||
|
m = re.match(r"^(\d[\d.]*)-.+", tag)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||||
"""Register all compose file management tools with the MCP server."""
|
"""Register all compose file management tools with the MCP server."""
|
||||||
|
|
||||||
@@ -126,16 +145,61 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
|||||||
|
|
||||||
new_image = f"{image_name}:{new_tag}"
|
new_image = f"{image_name}:{new_tag}"
|
||||||
|
|
||||||
|
# Detect version env vars that should be auto-updated alongside the tag.
|
||||||
|
# Applies when both tags share the pattern <digits>-<suffix>:
|
||||||
|
# e.g. 2.558-jdk21 → 2.560-jdk21 auto-updates JENKINS_VERSION=2.558 → 2.560.
|
||||||
|
old_version = _extract_version_prefix(current_tag)
|
||||||
|
new_version = _extract_version_prefix(new_tag)
|
||||||
|
version_vars: list[str] = [] # env var names that will be auto-updated
|
||||||
|
if old_version and new_version and old_version != new_version:
|
||||||
|
env = services[service_name].get("environment") or []
|
||||||
|
if isinstance(env, list):
|
||||||
|
for entry in env:
|
||||||
|
if isinstance(entry, str) and "=" in entry:
|
||||||
|
k, v = entry.split("=", 1)
|
||||||
|
if v == old_version:
|
||||||
|
version_vars.append(k)
|
||||||
|
elif isinstance(env, dict):
|
||||||
|
for k, v in env.items():
|
||||||
|
if str(v) == old_version:
|
||||||
|
version_vars.append(k)
|
||||||
|
|
||||||
if not confirmed:
|
if not confirmed:
|
||||||
return (
|
lines = [
|
||||||
f"About to update service '{service_name}' in project '{project_name}':\n"
|
f"About to update service '{service_name}' in project '{project_name}':",
|
||||||
f" Before: {current_image}\n"
|
f" Before: {current_image}",
|
||||||
f" After: {new_image}\n\n"
|
f" After: {new_image}",
|
||||||
f"Call this tool again with confirmed=True to apply the change."
|
]
|
||||||
)
|
if version_vars:
|
||||||
|
lines.append(
|
||||||
|
" Auto-update env var(s): "
|
||||||
|
+ ", ".join(f"{k}: {old_version} → {new_version}" for k in version_vars)
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Call this tool again with confirmed=True to apply the change.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
services[service_name]["image"] = new_image
|
services[service_name]["image"] = new_image
|
||||||
new_content = yaml.dump(compose, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
||||||
|
# Apply auto-updates to version env vars.
|
||||||
|
if old_version and new_version and old_version != new_version:
|
||||||
|
env = services[service_name].get("environment") or []
|
||||||
|
if isinstance(env, list):
|
||||||
|
for i, entry in enumerate(env):
|
||||||
|
if isinstance(entry, str) and "=" in entry:
|
||||||
|
k, v = entry.split("=", 1)
|
||||||
|
if v == old_version and k in version_vars:
|
||||||
|
env[i] = f"{k}={new_version}"
|
||||||
|
services[service_name]["environment"] = env
|
||||||
|
elif isinstance(env, dict):
|
||||||
|
for k in version_vars:
|
||||||
|
if k in env:
|
||||||
|
env[k] = new_version
|
||||||
|
services[service_name]["environment"] = env
|
||||||
|
|
||||||
|
new_content = yaml.dump(
|
||||||
|
compose, default_flow_style=False, sort_keys=False, allow_unicode=True
|
||||||
|
)
|
||||||
|
|
||||||
folder_path = path.rsplit("/", 1)[0]
|
folder_path = path.rsplit("/", 1)[0]
|
||||||
filename = path.rsplit("/", 1)[1]
|
filename = path.rsplit("/", 1)[1]
|
||||||
@@ -144,11 +208,20 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error writing compose file: {e}"
|
return f"Error writing compose file: {e}"
|
||||||
|
|
||||||
return (
|
result_lines = [
|
||||||
f"Updated '{service_name}' image in '{project_name}':\n"
|
f"Updated '{service_name}' image in '{project_name}':",
|
||||||
f" {current_image} → {new_image}\n\n"
|
f" {current_image} → {new_image}",
|
||||||
|
]
|
||||||
|
if version_vars:
|
||||||
|
result_lines.append(
|
||||||
|
" Auto-updated env var(s): "
|
||||||
|
+ ", ".join(f"{k}={new_version}" for k in version_vars)
|
||||||
|
)
|
||||||
|
result_lines.append("")
|
||||||
|
result_lines.append(
|
||||||
f"Tip: Run redeploy_project('{project_name}', confirmed=True) to apply the change."
|
f"Tip: Run redeploy_project('{project_name}', confirmed=True) to apply the change."
|
||||||
)
|
)
|
||||||
|
return "\n".join(result_lines)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def update_env_var(
|
async def update_env_var(
|
||||||
@@ -225,11 +298,7 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
|||||||
new_entry = f"{var_name}={var_value}"
|
new_entry = f"{var_name}={var_value}"
|
||||||
updated = False
|
updated = False
|
||||||
for i, entry in enumerate(env_list):
|
for i, entry in enumerate(env_list):
|
||||||
if isinstance(entry, str) and entry.startswith(f"{var_name}="):
|
if isinstance(entry, str) and entry.startswith(f"{var_name}=") or entry == var_name:
|
||||||
env_list[i] = new_entry
|
|
||||||
updated = True
|
|
||||||
break
|
|
||||||
elif entry == var_name:
|
|
||||||
env_list[i] = new_entry
|
env_list[i] = new_entry
|
||||||
updated = True
|
updated = True
|
||||||
break
|
break
|
||||||
@@ -242,7 +311,9 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
|||||||
else:
|
else:
|
||||||
service["environment"] = [f"{var_name}={var_value}"]
|
service["environment"] = [f"{var_name}={var_value}"]
|
||||||
|
|
||||||
new_content = yaml.dump(compose, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
new_content = yaml.dump(
|
||||||
|
compose, default_flow_style=False, sort_keys=False, allow_unicode=True
|
||||||
|
)
|
||||||
|
|
||||||
folder_path = path.rsplit("/", 1)[0]
|
folder_path = path.rsplit("/", 1)[0]
|
||||||
filename = path.rsplit("/", 1)[1]
|
filename = path.rsplit("/", 1)[1]
|
||||||
@@ -312,9 +383,7 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _find_compose_path(
|
async def _find_compose_path(client: DsmClient, config: AppConfig, project_name: str) -> str | None:
|
||||||
client: DsmClient, config: AppConfig, project_name: str
|
|
||||||
) -> str | None:
|
|
||||||
"""Find the compose file path for a project.
|
"""Find the compose file path for a project.
|
||||||
|
|
||||||
Resolves the project's real directory via SYNO.Docker.Project list,
|
Resolves the project's real directory via SYNO.Docker.Project list,
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
@@ -18,6 +22,15 @@ logger = logging.getLogger(__name__)
|
|||||||
_POLL_INTERVAL = 2 # seconds between status checks
|
_POLL_INTERVAL = 2 # seconds between status checks
|
||||||
_POLL_TIMEOUT = 30 # maximum seconds to wait for RUNNING
|
_POLL_TIMEOUT = 30 # maximum seconds to wait for RUNNING
|
||||||
|
|
||||||
|
# Compose file names probed in priority order (mirrors compose.py)
|
||||||
|
_COMPOSE_FILENAMES = [
|
||||||
|
"docker-compose.yml",
|
||||||
|
"docker-compose.yaml",
|
||||||
|
"compose.yml",
|
||||||
|
"compose.yaml",
|
||||||
|
]
|
||||||
|
_VOLUME_PREFIX_RE = re.compile(r"^/volume\d+")
|
||||||
|
|
||||||
|
|
||||||
def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||||
"""Register all project management tools with the MCP server."""
|
"""Register all project management tools with the MCP server."""
|
||||||
@@ -121,18 +134,20 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def redeploy_project(project_name: str, confirmed: bool = False) -> str:
|
async def redeploy_project(project_name: str, confirmed: bool = False) -> str:
|
||||||
"""Redeploy a project by stopping and restarting it.
|
"""Redeploy a project, forcing a fresh image pull via delete-before-start.
|
||||||
|
|
||||||
Checks the current project status to determine the correct action:
|
Unified 4-step flow for all project states:
|
||||||
- RUNNING → stop, then start
|
|
||||||
- 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
|
Step 1 — Stop (skipped for STOPPED; error-suppressed for BUILD_FAILED)
|
||||||
to 30 seconds until the project reaches RUNNING. Reports the final
|
Step 2 — Delete cached images from compose.yaml so that DSM/Docker
|
||||||
status; emits a warning on timeout instead of failing.
|
pulls the latest version when the project starts.
|
||||||
|
Image deletion is non-fatal: if it fails the project still
|
||||||
|
starts (possibly using the cached image).
|
||||||
|
Step 3 — Start project (DSM auto-pulls missing images)
|
||||||
|
Step 4 — Poll SYNO.Docker.Project/list every 2 s for up to 30 s
|
||||||
|
until the project reaches RUNNING. A warning is emitted on
|
||||||
|
timeout instead of returning an error.
|
||||||
|
|
||||||
This operation will briefly take the project offline.
|
|
||||||
Requires confirmation before executing.
|
Requires confirmation before executing.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -142,7 +157,7 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
if not confirmed:
|
if not confirmed:
|
||||||
return (
|
return (
|
||||||
f"Redeploying project '{project_name}' will stop and restart all its "
|
f"Redeploying project '{project_name}' will stop and restart all its "
|
||||||
f"containers (auto-detects current state).\n\n"
|
f"containers, deleting cached images to force a fresh pull.\n\n"
|
||||||
f"Call this tool again with confirmed=True to proceed."
|
f"Call this tool again with confirmed=True to proceed."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,54 +167,48 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
|
|
||||||
project_id = project.get("id", "")
|
project_id = project.get("id", "")
|
||||||
status = (project.get("status") or "").upper()
|
status = (project.get("status") or "").upper()
|
||||||
results = []
|
|
||||||
|
if status not in ("RUNNING", "STOPPED", "BUILD_FAILED", ""):
|
||||||
|
return (
|
||||||
|
f"Cannot redeploy '{project_name}': unexpected status '{status}'.\n"
|
||||||
|
f"Workaround: use stop_project + start_project separately."
|
||||||
|
)
|
||||||
|
|
||||||
|
results: list[str] = []
|
||||||
|
|
||||||
|
# Read compose images before stopping (best-effort; empty list on failure)
|
||||||
|
images_to_delete = await _read_compose_images_for_project(client, config, project_name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# ── Step 1: Stop ──────────────────────────────────────────────────
|
||||||
if status == "STOPPED":
|
if status == "STOPPED":
|
||||||
results.append("Project is STOPPED — starting directly.")
|
results.append("Step 1/4: Project is STOPPED — skipping stop.")
|
||||||
results.append("Step 1/2: Starting project...")
|
elif status in ("BUILD_FAILED", ""):
|
||||||
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
|
|
||||||
results.append(" Start issued.")
|
|
||||||
|
|
||||||
elif status == "BUILD_FAILED":
|
|
||||||
results.append("Step 1/4: Stopping failed build...")
|
results.append("Step 1/4: Stopping failed build...")
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
|
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
|
||||||
results.append(" Build stopped.")
|
results.append(" Stopped.")
|
||||||
results.append("Step 2/4: Pulling updated images...")
|
else: # RUNNING
|
||||||
try:
|
results.append("Step 1/4: Stopping project...")
|
||||||
await client.request("SYNO.Docker.Image", "pull", params={"id": project_id})
|
|
||||||
except Exception as pull_err:
|
|
||||||
results.append(f" Pull failed: {pull_err}")
|
|
||||||
results.append(
|
|
||||||
"Aborted: image pull failed — the image tag in compose.yaml may not exist. "
|
|
||||||
"Fix the tag with update_image_tag, then retry redeploy_project."
|
|
||||||
)
|
|
||||||
return "\n".join(results)
|
|
||||||
results.append(" Images pulled.")
|
|
||||||
results.append("Step 3/4: Starting project...")
|
|
||||||
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
|
|
||||||
results.append(" Start issued.")
|
|
||||||
|
|
||||||
elif status in ("RUNNING", ""):
|
|
||||||
results.append("Step 1/3: Stopping project...")
|
|
||||||
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
|
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
|
||||||
results.append(" Project stopped.")
|
results.append(" Stopped.")
|
||||||
results.append("Step 2/3: Starting project...")
|
|
||||||
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
|
|
||||||
results.append(" Start issued.")
|
|
||||||
|
|
||||||
|
# ── Step 2: Delete cached images ──────────────────────────────────
|
||||||
|
results.append("Step 2/4: Removing cached images to force re-pull...")
|
||||||
|
if images_to_delete:
|
||||||
|
for img_ref in images_to_delete:
|
||||||
|
results.append(f" {img_ref}:")
|
||||||
|
await _try_delete_image(client, img_ref, results)
|
||||||
else:
|
else:
|
||||||
return (
|
results.append(" Compose file not readable — skipping image removal.")
|
||||||
f"Cannot redeploy '{project_name}': unexpected status '{status}'.\n"
|
|
||||||
f"Workaround: use stop_project + start_project separately."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Poll until RUNNING or timeout
|
# ── Step 3: Start ─────────────────────────────────────────────────
|
||||||
poll_step = (
|
results.append("Step 3/4: Starting project...")
|
||||||
"2/2" if status == "STOPPED" else ("4/4" if status == "BUILD_FAILED" else "3/3")
|
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
|
||||||
)
|
results.append(" Start issued.")
|
||||||
results.append(f"Step {poll_step}: Waiting for project to reach RUNNING state...")
|
|
||||||
|
# ── Step 4: Poll ──────────────────────────────────────────────────
|
||||||
|
results.append("Step 4/4: Waiting for project to reach RUNNING state...")
|
||||||
final_status = await _wait_for_project_running(client, project_name)
|
final_status = await _wait_for_project_running(client, project_name)
|
||||||
if final_status == "RUNNING":
|
if final_status == "RUNNING":
|
||||||
results.append(" Project is RUNNING.")
|
results.append(" Project is RUNNING.")
|
||||||
@@ -240,6 +249,162 @@ async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_compose_images_for_project(
|
||||||
|
client: DsmClient,
|
||||||
|
config: AppConfig,
|
||||||
|
project_name: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Read all image references from a project's compose file.
|
||||||
|
|
||||||
|
Uses the project's filesystem path from the DSM API, then lists the
|
||||||
|
directory via FileStation to find the compose file, downloads and
|
||||||
|
parses it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: DsmClient instance.
|
||||||
|
config: AppConfig (provides compose_base_path fallback).
|
||||||
|
project_name: Name of the project.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Deduplicated list of image ref strings (e.g. ["nginx:1.24"]).
|
||||||
|
Returns an empty list on any error.
|
||||||
|
"""
|
||||||
|
project = await _find_project(client, project_name)
|
||||||
|
if project is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
raw_path = project.get("path", "").rstrip("/")
|
||||||
|
if not raw_path:
|
||||||
|
raw_path = f"{config.compose_base_path}/{project_name}"
|
||||||
|
|
||||||
|
fs_base = _VOLUME_PREFIX_RE.sub("", raw_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await client.request(
|
||||||
|
"SYNO.FileStation.List",
|
||||||
|
"list",
|
||||||
|
params={"folder_path": fs_base, "additional": "[]"},
|
||||||
|
)
|
||||||
|
names_present = {f.get("name", "") for f in data.get("files", [])}
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Could not list compose directory '%s': %s", fs_base, e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
compose_path: str | None = None
|
||||||
|
for fname in _COMPOSE_FILENAMES:
|
||||||
|
if fname in names_present:
|
||||||
|
compose_path = f"{fs_base}/{fname}"
|
||||||
|
break
|
||||||
|
|
||||||
|
if compose_path is None:
|
||||||
|
logger.debug("No compose file found in '%s'", fs_base)
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = await client.download_text(compose_path)
|
||||||
|
parsed = yaml.safe_load(content)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Could not read/parse compose file '%s': %s", compose_path, e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
|
images: set[str] = set()
|
||||||
|
for service in (parsed.get("services") or {}).values():
|
||||||
|
img = (service or {}).get("image", "")
|
||||||
|
if img:
|
||||||
|
images.add(img)
|
||||||
|
|
||||||
|
return list(images)
|
||||||
|
|
||||||
|
|
||||||
|
async def _try_delete_image(
|
||||||
|
client: DsmClient,
|
||||||
|
image_ref: str,
|
||||||
|
results: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Best-effort delete of a local Docker image to force re-pull on start.
|
||||||
|
|
||||||
|
Only blocks deletion if a *running* container (not from this project)
|
||||||
|
uses the image. Stopped-container references are ignored so that the
|
||||||
|
just-stopped project containers do not prevent deletion.
|
||||||
|
All DSM errors are non-fatal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: DsmClient instance.
|
||||||
|
image_ref: Image reference as "name:tag".
|
||||||
|
results: Output list; status lines are appended here.
|
||||||
|
"""
|
||||||
|
name, sep, tag = image_ref.rpartition(":")
|
||||||
|
if not sep:
|
||||||
|
name = image_ref
|
||||||
|
tag = "latest"
|
||||||
|
|
||||||
|
# Locate the image in the local registry
|
||||||
|
try:
|
||||||
|
img_data = await client.request(
|
||||||
|
"SYNO.Docker.Image",
|
||||||
|
"list",
|
||||||
|
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
results.append(f" Could not list images: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
images: list[dict[str, Any]] = img_data.get("images", [])
|
||||||
|
target: dict[str, Any] | None = None
|
||||||
|
for img in images:
|
||||||
|
if img.get("repository", "") == name and tag in (img.get("tags") or []):
|
||||||
|
target = img
|
||||||
|
break
|
||||||
|
|
||||||
|
if target is None:
|
||||||
|
results.append(f" '{image_ref}' not in local cache — DSM will pull on start.")
|
||||||
|
return
|
||||||
|
|
||||||
|
img_hash = target.get("id", "")
|
||||||
|
hash_prefix = img_hash[:12] if img_hash else ""
|
||||||
|
|
||||||
|
# Block only on running containers (stopped containers are non-blocking)
|
||||||
|
try:
|
||||||
|
ctr_data = await client.request(
|
||||||
|
"SYNO.Docker.Container",
|
||||||
|
"list",
|
||||||
|
params={"limit": "-1", "offset": "0", "type": "all"},
|
||||||
|
)
|
||||||
|
for ctr in ctr_data.get("containers", []):
|
||||||
|
ctr_img_id = ctr.get("image_id") or ctr.get("ImageID") or ctr.get("Image", "")
|
||||||
|
if (
|
||||||
|
img_hash
|
||||||
|
and (ctr_img_id == img_hash or (hash_prefix and ctr_img_id.startswith(hash_prefix)))
|
||||||
|
and ctr.get("status", ctr.get("state", "")).lower() == "running"
|
||||||
|
):
|
||||||
|
ctr_name = ctr.get("name", "?")
|
||||||
|
results.append(
|
||||||
|
f" Skipped: '{image_ref}' is used by running container '{ctr_name}'."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass # Best-effort check; proceed with delete attempt
|
||||||
|
|
||||||
|
# Attempt deletion
|
||||||
|
repo = target.get("repository", name)
|
||||||
|
img_tags = target.get("tags") or [tag]
|
||||||
|
images_param = json.dumps([{"repository": repo, "tags": [img_tags[0]]}])
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.post_request(
|
||||||
|
"SYNO.Docker.Image",
|
||||||
|
"delete",
|
||||||
|
version=1,
|
||||||
|
params={"images": images_param},
|
||||||
|
)
|
||||||
|
results.append(f" Removed '{image_ref}' from local cache.")
|
||||||
|
except Exception as e:
|
||||||
|
results.append(f" Could not remove '{image_ref}' (non-fatal): {e}")
|
||||||
|
|
||||||
|
|
||||||
async def _wait_for_project_running(
|
async def _wait_for_project_running(
|
||||||
client: DsmClient,
|
client: DsmClient,
|
||||||
name: str,
|
name: str,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Tests for modules/compose.py."""
|
"""Tests for modules/compose.py."""
|
||||||
|
|
||||||
import pytest
|
from unittest.mock import AsyncMock
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ def make_mock_mcp():
|
|||||||
def decorator(fn):
|
def decorator(fn):
|
||||||
tools[fn.__name__] = fn
|
tools[fn.__name__] = fn
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
return MockMCP(), tools
|
return MockMCP(), tools
|
||||||
@@ -21,6 +22,7 @@ def make_mock_mcp():
|
|||||||
|
|
||||||
def make_config():
|
def make_config():
|
||||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||||
|
|
||||||
return AppConfig(
|
return AppConfig(
|
||||||
schema_version=1,
|
schema_version=1,
|
||||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||||
@@ -45,14 +47,30 @@ services:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def make_compose_client(content: str, filename: str = "docker-compose.yml") -> AsyncMock:
|
||||||
|
"""Create an AsyncMock client pre-configured for compose tests.
|
||||||
|
|
||||||
|
FileStation.List returns a single file entry so that _find_compose_path
|
||||||
|
can locate the compose file. All other requests return {}.
|
||||||
|
download_text returns the provided content.
|
||||||
|
"""
|
||||||
|
client = AsyncMock()
|
||||||
|
|
||||||
|
async def _request(api, method, **kwargs):
|
||||||
|
if api == "SYNO.FileStation.List":
|
||||||
|
return {"files": [{"name": filename}]}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.request.side_effect = _request
|
||||||
|
client.download_text.return_value = content
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_read_compose():
|
async def test_read_compose():
|
||||||
from mcp_synology_container.modules.compose import register_compose
|
from mcp_synology_container.modules.compose import register_compose
|
||||||
|
|
||||||
client = AsyncMock()
|
client = make_compose_client(SAMPLE_COMPOSE)
|
||||||
# Simulate FileStation.Info success for the first filename
|
|
||||||
client.request.return_value = {}
|
|
||||||
client.download_text.return_value = SAMPLE_COMPOSE
|
|
||||||
|
|
||||||
mcp, tools = make_mock_mcp()
|
mcp, tools = make_mock_mcp()
|
||||||
register_compose(mcp, make_config(), client)
|
register_compose(mcp, make_config(), client)
|
||||||
@@ -69,6 +87,7 @@ async def test_read_compose_not_found():
|
|||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
# Simulate all FileStation.Info calls failing
|
# Simulate all FileStation.Info calls failing
|
||||||
from mcp_synology_container.dsm_client import SynologyError
|
from mcp_synology_container.dsm_client import SynologyError
|
||||||
|
|
||||||
client.request.side_effect = SynologyError("not found", code=408)
|
client.request.side_effect = SynologyError("not found", code=408)
|
||||||
|
|
||||||
mcp, tools = make_mock_mcp()
|
mcp, tools = make_mock_mcp()
|
||||||
@@ -82,10 +101,7 @@ async def test_read_compose_not_found():
|
|||||||
async def test_update_image_tag_requires_confirmation():
|
async def test_update_image_tag_requires_confirmation():
|
||||||
from mcp_synology_container.modules.compose import register_compose
|
from mcp_synology_container.modules.compose import register_compose
|
||||||
|
|
||||||
client = AsyncMock()
|
client = make_compose_client(SAMPLE_COMPOSE)
|
||||||
client.request.return_value = {}
|
|
||||||
client.download_text.return_value = SAMPLE_COMPOSE
|
|
||||||
|
|
||||||
mcp, tools = make_mock_mcp()
|
mcp, tools = make_mock_mcp()
|
||||||
register_compose(mcp, make_config(), client)
|
register_compose(mcp, make_config(), client)
|
||||||
|
|
||||||
@@ -100,10 +116,7 @@ async def test_update_image_tag_requires_confirmation():
|
|||||||
async def test_update_image_tag_confirmed():
|
async def test_update_image_tag_confirmed():
|
||||||
from mcp_synology_container.modules.compose import register_compose
|
from mcp_synology_container.modules.compose import register_compose
|
||||||
|
|
||||||
client = AsyncMock()
|
client = make_compose_client(SAMPLE_COMPOSE)
|
||||||
client.request.return_value = {}
|
|
||||||
client.download_text.return_value = SAMPLE_COMPOSE
|
|
||||||
|
|
||||||
mcp, tools = make_mock_mcp()
|
mcp, tools = make_mock_mcp()
|
||||||
register_compose(mcp, make_config(), client)
|
register_compose(mcp, make_config(), client)
|
||||||
|
|
||||||
@@ -122,10 +135,7 @@ async def test_update_image_tag_confirmed():
|
|||||||
async def test_update_image_tag_service_not_found():
|
async def test_update_image_tag_service_not_found():
|
||||||
from mcp_synology_container.modules.compose import register_compose
|
from mcp_synology_container.modules.compose import register_compose
|
||||||
|
|
||||||
client = AsyncMock()
|
client = make_compose_client(SAMPLE_COMPOSE)
|
||||||
client.request.return_value = {}
|
|
||||||
client.download_text.return_value = SAMPLE_COMPOSE
|
|
||||||
|
|
||||||
mcp, tools = make_mock_mcp()
|
mcp, tools = make_mock_mcp()
|
||||||
register_compose(mcp, make_config(), client)
|
register_compose(mcp, make_config(), client)
|
||||||
|
|
||||||
@@ -138,10 +148,7 @@ async def test_update_image_tag_service_not_found():
|
|||||||
async def test_update_env_var_new_var_list_format():
|
async def test_update_env_var_new_var_list_format():
|
||||||
from mcp_synology_container.modules.compose import register_compose
|
from mcp_synology_container.modules.compose import register_compose
|
||||||
|
|
||||||
client = AsyncMock()
|
client = make_compose_client(SAMPLE_COMPOSE)
|
||||||
client.request.return_value = {}
|
|
||||||
client.download_text.return_value = SAMPLE_COMPOSE
|
|
||||||
|
|
||||||
mcp, tools = make_mock_mcp()
|
mcp, tools = make_mock_mcp()
|
||||||
register_compose(mcp, make_config(), client)
|
register_compose(mcp, make_config(), client)
|
||||||
|
|
||||||
@@ -158,10 +165,7 @@ async def test_update_env_var_new_var_list_format():
|
|||||||
async def test_update_env_var_update_existing_list():
|
async def test_update_env_var_update_existing_list():
|
||||||
from mcp_synology_container.modules.compose import register_compose
|
from mcp_synology_container.modules.compose import register_compose
|
||||||
|
|
||||||
client = AsyncMock()
|
client = make_compose_client(SAMPLE_COMPOSE)
|
||||||
client.request.return_value = {}
|
|
||||||
client.download_text.return_value = SAMPLE_COMPOSE
|
|
||||||
|
|
||||||
mcp, tools = make_mock_mcp()
|
mcp, tools = make_mock_mcp()
|
||||||
register_compose(mcp, make_config(), client)
|
register_compose(mcp, make_config(), client)
|
||||||
|
|
||||||
@@ -179,10 +183,7 @@ async def test_update_env_var_update_existing_list():
|
|||||||
async def test_update_env_var_dict_format():
|
async def test_update_env_var_dict_format():
|
||||||
from mcp_synology_container.modules.compose import register_compose
|
from mcp_synology_container.modules.compose import register_compose
|
||||||
|
|
||||||
client = AsyncMock()
|
client = make_compose_client(SAMPLE_COMPOSE)
|
||||||
client.request.return_value = {}
|
|
||||||
client.download_text.return_value = SAMPLE_COMPOSE
|
|
||||||
|
|
||||||
mcp, tools = make_mock_mcp()
|
mcp, tools = make_mock_mcp()
|
||||||
register_compose(mcp, make_config(), client)
|
register_compose(mcp, make_config(), client)
|
||||||
|
|
||||||
@@ -199,8 +200,8 @@ async def test_update_env_var_dict_format():
|
|||||||
async def test_update_compose_invalid_yaml():
|
async def test_update_compose_invalid_yaml():
|
||||||
from mcp_synology_container.modules.compose import register_compose
|
from mcp_synology_container.modules.compose import register_compose
|
||||||
|
|
||||||
|
# YAML validation happens before any file I/O — no compose file needed
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
|
|
||||||
mcp, tools = make_mock_mcp()
|
mcp, tools = make_mock_mcp()
|
||||||
register_compose(mcp, make_config(), client)
|
register_compose(mcp, make_config(), client)
|
||||||
|
|
||||||
@@ -213,7 +214,6 @@ async def test_update_compose_missing_services_key():
|
|||||||
from mcp_synology_container.modules.compose import register_compose
|
from mcp_synology_container.modules.compose import register_compose
|
||||||
|
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
|
|
||||||
mcp, tools = make_mock_mcp()
|
mcp, tools = make_mock_mcp()
|
||||||
register_compose(mcp, make_config(), client)
|
register_compose(mcp, make_config(), client)
|
||||||
|
|
||||||
@@ -225,12 +225,123 @@ async def test_update_compose_missing_services_key():
|
|||||||
async def test_update_compose_requires_confirmation():
|
async def test_update_compose_requires_confirmation():
|
||||||
from mcp_synology_container.modules.compose import register_compose
|
from mcp_synology_container.modules.compose import register_compose
|
||||||
|
|
||||||
client = AsyncMock()
|
client = make_compose_client(SAMPLE_COMPOSE)
|
||||||
client.request.return_value = {}
|
|
||||||
|
|
||||||
mcp, tools = make_mock_mcp()
|
mcp, tools = make_mock_mcp()
|
||||||
register_compose(mcp, make_config(), client)
|
register_compose(mcp, make_config(), client)
|
||||||
|
|
||||||
result = await tools["update_compose"]("myapp", SAMPLE_COMPOSE, confirmed=False)
|
result = await tools["update_compose"]("myapp", SAMPLE_COMPOSE, confirmed=False)
|
||||||
assert "confirmed=True" in result
|
assert "confirmed=True" in result
|
||||||
client.upload_text.assert_not_called()
|
client.upload_text.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Auto-version-update in update_image_tag
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SAMPLE_COMPOSE_VERSIONED = """
|
||||||
|
services:
|
||||||
|
jenkins:
|
||||||
|
image: jenkins/jenkins:2.558-jdk21
|
||||||
|
environment:
|
||||||
|
- JENKINS_VERSION=2.558
|
||||||
|
- JAVA_OPTS=-Xmx512m
|
||||||
|
"""
|
||||||
|
|
||||||
|
SAMPLE_COMPOSE_VERSIONED_DICT_ENV = """
|
||||||
|
services:
|
||||||
|
jenkins:
|
||||||
|
image: jenkins/jenkins:2.558-jdk21
|
||||||
|
environment:
|
||||||
|
JENKINS_VERSION: "2.558"
|
||||||
|
JAVA_OPTS: -Xmx512m
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_image_tag_auto_updates_version_env_var_list():
|
||||||
|
"""Tag 2.558-jdk21 → 2.560-jdk21 must also update JENKINS_VERSION=2.558 → 2.560."""
|
||||||
|
from mcp_synology_container.modules.compose import register_compose
|
||||||
|
|
||||||
|
client = make_compose_client(SAMPLE_COMPOSE_VERSIONED)
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_compose(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["update_image_tag"]("myapp", "jenkins", "2.560-jdk21", confirmed=True)
|
||||||
|
assert "jenkins/jenkins:2.558-jdk21 → jenkins/jenkins:2.560-jdk21" in result
|
||||||
|
assert "JENKINS_VERSION=2.560" in result
|
||||||
|
client.upload_text.assert_called_once()
|
||||||
|
|
||||||
|
uploaded = client.upload_text.call_args[0][2]
|
||||||
|
parsed = yaml.safe_load(uploaded)
|
||||||
|
assert parsed["services"]["jenkins"]["image"] == "jenkins/jenkins:2.560-jdk21"
|
||||||
|
env = parsed["services"]["jenkins"]["environment"]
|
||||||
|
assert "JENKINS_VERSION=2.560" in env
|
||||||
|
assert "JENKINS_VERSION=2.558" not in env
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_image_tag_auto_updates_version_env_var_dict():
|
||||||
|
"""Dict-format env: JENKINS_VERSION value matching old prefix must be updated."""
|
||||||
|
from mcp_synology_container.modules.compose import register_compose
|
||||||
|
|
||||||
|
client = make_compose_client(SAMPLE_COMPOSE_VERSIONED_DICT_ENV)
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_compose(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["update_image_tag"]("myapp", "jenkins", "2.560-jdk21", confirmed=True)
|
||||||
|
assert "JENKINS_VERSION=2.560" in result
|
||||||
|
|
||||||
|
uploaded = client.upload_text.call_args[0][2]
|
||||||
|
parsed = yaml.safe_load(uploaded)
|
||||||
|
assert parsed["services"]["jenkins"]["environment"]["JENKINS_VERSION"] == "2.560"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_image_tag_no_auto_update_without_version_suffix():
|
||||||
|
"""Tag without numeric-prefix pattern (e.g. 'latest') must not touch env vars."""
|
||||||
|
from mcp_synology_container.modules.compose import register_compose
|
||||||
|
|
||||||
|
client = make_compose_client(SAMPLE_COMPOSE_VERSIONED)
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_compose(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["update_image_tag"]("myapp", "jenkins", "latest", confirmed=True)
|
||||||
|
assert "jenkins/jenkins:2.558-jdk21 → jenkins/jenkins:latest" in result
|
||||||
|
# No auto-update mention expected
|
||||||
|
assert "Auto-updated" not in result
|
||||||
|
|
||||||
|
uploaded = client.upload_text.call_args[0][2]
|
||||||
|
parsed = yaml.safe_load(uploaded)
|
||||||
|
env = parsed["services"]["jenkins"]["environment"]
|
||||||
|
# JENKINS_VERSION must be unchanged
|
||||||
|
assert "JENKINS_VERSION=2.558" in env
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_image_tag_preview_shows_auto_update():
|
||||||
|
"""Unconfirmed call must preview auto-update of matching env vars."""
|
||||||
|
from mcp_synology_container.modules.compose import register_compose
|
||||||
|
|
||||||
|
client = make_compose_client(SAMPLE_COMPOSE_VERSIONED)
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_compose(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["update_image_tag"]("myapp", "jenkins", "2.560-jdk21", confirmed=False)
|
||||||
|
assert "confirmed=True" in result
|
||||||
|
assert "JENKINS_VERSION" in result
|
||||||
|
assert "2.558" in result
|
||||||
|
assert "2.560" in result
|
||||||
|
client.upload_text.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_version_prefix():
|
||||||
|
"""Unit tests for _extract_version_prefix helper."""
|
||||||
|
from mcp_synology_container.modules.compose import _extract_version_prefix
|
||||||
|
|
||||||
|
assert _extract_version_prefix("2.558-jdk21") == "2.558"
|
||||||
|
assert _extract_version_prefix("1.2.3-alpine") == "1.2.3"
|
||||||
|
assert _extract_version_prefix("2-slim") == "2"
|
||||||
|
assert _extract_version_prefix("latest") is None
|
||||||
|
assert _extract_version_prefix("1.24") is None # no suffix
|
||||||
|
assert _extract_version_prefix("") is None
|
||||||
|
assert _extract_version_prefix("v2.0-rc1") is None # starts with 'v'
|
||||||
|
|||||||
@@ -206,12 +206,15 @@ def project_list(status: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def make_stateful_redeploy_mock(initial_status: str, stop_raises=None, pull_raises=None):
|
def make_stateful_redeploy_mock(initial_status: str, stop_raises=None):
|
||||||
"""Create a stateful client mock for redeploy tests.
|
"""Create a stateful client mock for redeploy tests.
|
||||||
|
|
||||||
Returns (client, calls_list). After ``start`` is called, subsequent
|
Returns (client, calls_list). After ``start`` is called, subsequent
|
||||||
``list`` calls return RUNNING so the polling loop terminates immediately.
|
``list`` calls return RUNNING so the polling loop terminates immediately.
|
||||||
asyncio.sleep is NOT patched here — patch it at call-site.
|
asyncio.sleep is NOT patched here — patch it at call-site.
|
||||||
|
|
||||||
|
FileStation.List returns an empty file list so compose image detection is
|
||||||
|
skipped (image deletion is tested separately).
|
||||||
"""
|
"""
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
calls = []
|
calls = []
|
||||||
@@ -220,17 +223,18 @@ def make_stateful_redeploy_mock(initial_status: str, stop_raises=None, pull_rais
|
|||||||
async def mock_request(api, method, **kwargs):
|
async def mock_request(api, method, **kwargs):
|
||||||
nonlocal start_called
|
nonlocal start_called
|
||||||
calls.append((api, method))
|
calls.append((api, method))
|
||||||
|
if api == "SYNO.FileStation.List":
|
||||||
|
return {"files": []} # No compose file → skip image deletion
|
||||||
if method == "start":
|
if method == "start":
|
||||||
start_called = True
|
start_called = True
|
||||||
if method == "stop" and stop_raises:
|
if method == "stop" and stop_raises:
|
||||||
raise stop_raises
|
raise stop_raises
|
||||||
if method == "pull" and pull_raises:
|
|
||||||
raise pull_raises
|
|
||||||
if method == "list":
|
if method == "list":
|
||||||
return project_list("RUNNING") if start_called else project_list(initial_status)
|
return project_list("RUNNING") if start_called else project_list(initial_status)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
client.request.side_effect = mock_request
|
client.request.side_effect = mock_request
|
||||||
|
client.post_request = AsyncMock()
|
||||||
return client, calls
|
return client, calls
|
||||||
|
|
||||||
|
|
||||||
@@ -268,7 +272,7 @@ async def test_redeploy_stopped_project_starts_directly():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_redeploy_build_failed_project():
|
async def test_redeploy_build_failed_project():
|
||||||
"""BUILD_FAILED project: stop → pull → start; polls until RUNNING."""
|
"""BUILD_FAILED project: stop → (delete images) → start; polls until RUNNING."""
|
||||||
client, calls = make_stateful_redeploy_mock("BUILD_FAILED")
|
client, calls = make_stateful_redeploy_mock("BUILD_FAILED")
|
||||||
tools = make_projects_tools(client)
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
@@ -278,10 +282,8 @@ async def test_redeploy_build_failed_project():
|
|||||||
assert "redeployed successfully" in result
|
assert "redeployed successfully" in result
|
||||||
methods = [m for _, m in calls]
|
methods = [m for _, m in calls]
|
||||||
assert "stop" in methods
|
assert "stop" in methods
|
||||||
assert "pull" in methods
|
|
||||||
assert "start" in methods
|
assert "start" in methods
|
||||||
assert methods.index("stop") < methods.index("pull")
|
assert methods.index("stop") < methods.index("start")
|
||||||
assert methods.index("pull") < methods.index("start")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -292,7 +294,6 @@ async def test_redeploy_build_failed_stop_error_nonfatal():
|
|||||||
client, _ = make_stateful_redeploy_mock(
|
client, _ = make_stateful_redeploy_mock(
|
||||||
"BUILD_FAILED",
|
"BUILD_FAILED",
|
||||||
stop_raises=SynologyError("already stopped", code=2101),
|
stop_raises=SynologyError("already stopped", code=2101),
|
||||||
pull_raises=None, # pull succeeds
|
|
||||||
)
|
)
|
||||||
tools = make_projects_tools(client)
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
@@ -303,26 +304,49 @@ async def test_redeploy_build_failed_stop_error_nonfatal():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_redeploy_build_failed_pull_error_aborts():
|
async def test_redeploy_image_delete_failure_nonfatal():
|
||||||
"""BUILD_FAILED: pull failure must abort redeploy with a clear message."""
|
"""Image deletion failure must be non-fatal: start must still be called."""
|
||||||
from mcp_synology_container.dsm_client import SynologyError
|
client = AsyncMock()
|
||||||
|
start_called = False
|
||||||
|
|
||||||
|
async def mock_request(api, method, **kwargs):
|
||||||
|
nonlocal start_called
|
||||||
|
if api == "SYNO.FileStation.List":
|
||||||
|
# Return a compose file so that image listing is attempted
|
||||||
|
return {"files": [{"name": "docker-compose.yml"}]}
|
||||||
|
if api == "SYNO.Docker.Image" and method == "list":
|
||||||
|
# Return one image matching the compose service
|
||||||
|
return {
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"id": "sha256:abc123",
|
||||||
|
"repository": "nginx",
|
||||||
|
"tags": ["1.24"],
|
||||||
|
"size": 50000000,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if api == "SYNO.Docker.Container" and method == "list":
|
||||||
|
return {"containers": []}
|
||||||
|
if method == "start":
|
||||||
|
start_called = True
|
||||||
|
if method == "list":
|
||||||
|
return project_list("RUNNING") if start_called else project_list("RUNNING")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.request.side_effect = mock_request
|
||||||
|
# Simulate FileStation download of compose.yaml
|
||||||
|
client.download_text = AsyncMock(return_value="services:\n web:\n image: nginx:1.24\n")
|
||||||
|
# post_request (image delete) raises an error — must be non-fatal
|
||||||
|
client.post_request = AsyncMock(side_effect=Exception("delete failed"))
|
||||||
|
|
||||||
client, calls = make_stateful_redeploy_mock(
|
|
||||||
"BUILD_FAILED",
|
|
||||||
stop_raises=None,
|
|
||||||
pull_raises=SynologyError("image not found", code=114),
|
|
||||||
)
|
|
||||||
tools = make_projects_tools(client)
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||||
|
|
||||||
assert "redeployed successfully" not in result
|
assert "redeployed successfully" in result
|
||||||
assert "Aborted" in result or "pull failed" in result.lower()
|
assert start_called, "start must be called even when image deletion fails"
|
||||||
assert "compose.yaml" in result or "update_image_tag" in result
|
|
||||||
# start must NOT have been called after a pull failure
|
|
||||||
methods = [m for _, m in calls]
|
|
||||||
assert "start" not in methods
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -333,6 +357,8 @@ async def test_redeploy_poll_timeout():
|
|||||||
|
|
||||||
async def mock_request(api, method, **kwargs):
|
async def mock_request(api, method, **kwargs):
|
||||||
nonlocal start_called
|
nonlocal start_called
|
||||||
|
if api == "SYNO.FileStation.List":
|
||||||
|
return {"files": []}
|
||||||
if method == "start":
|
if method == "start":
|
||||||
start_called = True
|
start_called = True
|
||||||
if method == "list":
|
if method == "list":
|
||||||
@@ -342,6 +368,7 @@ async def test_redeploy_poll_timeout():
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
client.request.side_effect = mock_request
|
client.request.side_effect = mock_request
|
||||||
|
client.post_request = AsyncMock()
|
||||||
tools = make_projects_tools(client)
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
# Use tiny timeout so the test is instant (interval=1, timeout=1 → 1 poll)
|
# Use tiny timeout so the test is instant (interval=1, timeout=1 → 1 poll)
|
||||||
@@ -362,11 +389,14 @@ async def test_redeploy_unknown_status_returns_error():
|
|||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
|
|
||||||
async def mock_request(api, method, **kwargs):
|
async def mock_request(api, method, **kwargs):
|
||||||
|
if api == "SYNO.FileStation.List":
|
||||||
|
return {"files": []}
|
||||||
if method == "list":
|
if method == "list":
|
||||||
return project_list("UPDATING")
|
return project_list("UPDATING")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
client.request.side_effect = mock_request
|
client.request.side_effect = mock_request
|
||||||
|
client.post_request = AsyncMock()
|
||||||
tools = make_projects_tools(client)
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user