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:
2026-04-21 07:57:57 +02:00
parent ae36a9fbac
commit bafa327412
6 changed files with 522 additions and 126 deletions
+21
View File
@@ -2,6 +2,27 @@
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
### Changed
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "mcp-synology-container"
version = "0.2.3"
version = "0.2.4"
description = "MCP server for Synology Container Manager"
requires-python = ">=3.12"
dependencies = [
+89 -20
View File
@@ -9,12 +9,13 @@ from __future__ import annotations
import logging
import re
import sys
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
import yaml
if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP
from mcp_synology_container.config import AppConfig
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:
"""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}"
# 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:
return (
f"About to update service '{service_name}' in project '{project_name}':\n"
f" Before: {current_image}\n"
f" After: {new_image}\n\n"
f"Call this tool again with confirmed=True to apply the change."
)
lines = [
f"About to update service '{service_name}' in project '{project_name}':",
f" Before: {current_image}",
f" After: {new_image}",
]
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
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]
filename = path.rsplit("/", 1)[1]
@@ -144,11 +208,20 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
except Exception as e:
return f"Error writing compose file: {e}"
return (
f"Updated '{service_name}' image in '{project_name}':\n"
f" {current_image}{new_image}\n\n"
result_lines = [
f"Updated '{service_name}' image in '{project_name}':",
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."
)
return "\n".join(result_lines)
@mcp.tool()
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}"
updated = False
for i, entry in enumerate(env_list):
if isinstance(entry, str) and entry.startswith(f"{var_name}="):
env_list[i] = new_entry
updated = True
break
elif entry == var_name:
if isinstance(entry, str) and entry.startswith(f"{var_name}=") or entry == var_name:
env_list[i] = new_entry
updated = True
break
@@ -242,7 +311,9 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
else:
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]
filename = path.rsplit("/", 1)[1]
@@ -312,9 +383,7 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
)
async def _find_compose_path(
client: DsmClient, config: AppConfig, project_name: str
) -> str | None:
async def _find_compose_path(client: DsmClient, config: AppConfig, project_name: str) -> str | None:
"""Find the compose file path for a project.
Resolves the project's real directory via SYNO.Docker.Project list,
+213 -48
View File
@@ -4,9 +4,13 @@ from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import re
from typing import TYPE_CHECKING, Any
import yaml
if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP
@@ -18,6 +22,15 @@ logger = logging.getLogger(__name__)
_POLL_INTERVAL = 2 # seconds between status checks
_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:
"""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()
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:
- RUNNING → stop, then start
- STOPPED → start directly (nothing to stop)
- BUILD_FAILED → stop, pull images, then start
Unified 4-step flow for all project states:
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.
Step 1 — Stop (skipped for STOPPED; error-suppressed for BUILD_FAILED)
Step 2 — Delete cached images from compose.yaml so that DSM/Docker
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.
Args:
@@ -142,7 +157,7 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
if not confirmed:
return (
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."
)
@@ -152,54 +167,48 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
project_id = project.get("id", "")
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:
# ── Step 1: Stop ──────────────────────────────────────────────────
if status == "STOPPED":
results.append("Project is STOPPED — starting directly.")
results.append("Step 1/2: Starting project...")
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
results.append(" Start issued.")
elif status == "BUILD_FAILED":
results.append("Step 1/4: Project is STOPPED — skipping stop.")
elif status in ("BUILD_FAILED", ""):
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/4: Pulling updated images...")
try:
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...")
results.append(" Stopped.")
else: # RUNNING
results.append("Step 1/4: Stopping project...")
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
results.append(" Project stopped.")
results.append("Step 2/3: Starting project...")
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
results.append(" Start issued.")
results.append(" Stopped.")
# ── 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:
return (
f"Cannot redeploy '{project_name}': unexpected status '{status}'.\n"
f"Workaround: use stop_project + start_project separately."
)
results.append(" Compose file not readable — skipping image removal.")
# 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...")
# ── Step 3: Start ─────────────────────────────────────────────────
results.append("Step 3/4: Starting project...")
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
results.append(" Start issued.")
# ── 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)
if final_status == "RUNNING":
results.append(" Project is RUNNING.")
@@ -240,6 +249,162 @@ async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | 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(
client: DsmClient,
name: str,
+146 -35
View File
@@ -1,8 +1,8 @@
"""Tests for modules/compose.py."""
import pytest
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock
import pytest
import yaml
@@ -14,6 +14,7 @@ def make_mock_mcp():
def decorator(fn):
tools[fn.__name__] = fn
return fn
return decorator
return MockMCP(), tools
@@ -21,6 +22,7 @@ def make_mock_mcp():
def make_config():
from mcp_synology_container.config import AppConfig, ConnectionConfig
return AppConfig(
schema_version=1,
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
async def test_read_compose():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
# Simulate FileStation.Info success for the first filename
client.request.return_value = {}
client.download_text.return_value = SAMPLE_COMPOSE
client = make_compose_client(SAMPLE_COMPOSE)
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
@@ -69,6 +87,7 @@ async def test_read_compose_not_found():
client = AsyncMock()
# Simulate all FileStation.Info calls failing
from mcp_synology_container.dsm_client import SynologyError
client.request.side_effect = SynologyError("not found", code=408)
mcp, tools = make_mock_mcp()
@@ -82,10 +101,7 @@ async def test_read_compose_not_found():
async def test_update_image_tag_requires_confirmation():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
client.request.return_value = {}
client.download_text.return_value = SAMPLE_COMPOSE
client = make_compose_client(SAMPLE_COMPOSE)
mcp, tools = make_mock_mcp()
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():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
client.request.return_value = {}
client.download_text.return_value = SAMPLE_COMPOSE
client = make_compose_client(SAMPLE_COMPOSE)
mcp, tools = make_mock_mcp()
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():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
client.request.return_value = {}
client.download_text.return_value = SAMPLE_COMPOSE
client = make_compose_client(SAMPLE_COMPOSE)
mcp, tools = make_mock_mcp()
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():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
client.request.return_value = {}
client.download_text.return_value = SAMPLE_COMPOSE
client = make_compose_client(SAMPLE_COMPOSE)
mcp, tools = make_mock_mcp()
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():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
client.request.return_value = {}
client.download_text.return_value = SAMPLE_COMPOSE
client = make_compose_client(SAMPLE_COMPOSE)
mcp, tools = make_mock_mcp()
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():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
client.request.return_value = {}
client.download_text.return_value = SAMPLE_COMPOSE
client = make_compose_client(SAMPLE_COMPOSE)
mcp, tools = make_mock_mcp()
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():
from mcp_synology_container.modules.compose import register_compose
# YAML validation happens before any file I/O — no compose file needed
client = AsyncMock()
mcp, tools = make_mock_mcp()
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
client = AsyncMock()
mcp, tools = make_mock_mcp()
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():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
client.request.return_value = {}
client = make_compose_client(SAMPLE_COMPOSE)
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["update_compose"]("myapp", SAMPLE_COMPOSE, confirmed=False)
assert "confirmed=True" in result
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'
+52 -22
View File
@@ -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.
Returns (client, calls_list). After ``start`` is called, subsequent
``list`` calls return RUNNING so the polling loop terminates immediately.
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()
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):
nonlocal start_called
calls.append((api, method))
if api == "SYNO.FileStation.List":
return {"files": []} # No compose file → skip image deletion
if method == "start":
start_called = True
if method == "stop" and stop_raises:
raise stop_raises
if method == "pull" and pull_raises:
raise pull_raises
if method == "list":
return project_list("RUNNING") if start_called else project_list(initial_status)
return {}
client.request.side_effect = mock_request
client.post_request = AsyncMock()
return client, calls
@@ -268,7 +272,7 @@ async def test_redeploy_stopped_project_starts_directly():
@pytest.mark.asyncio
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")
tools = make_projects_tools(client)
@@ -278,10 +282,8 @@ async def test_redeploy_build_failed_project():
assert "redeployed successfully" in result
methods = [m for _, m in calls]
assert "stop" in methods
assert "pull" in methods
assert "start" in methods
assert methods.index("stop") < methods.index("pull")
assert methods.index("pull") < methods.index("start")
assert methods.index("stop") < methods.index("start")
@pytest.mark.asyncio
@@ -292,7 +294,6 @@ async def test_redeploy_build_failed_stop_error_nonfatal():
client, _ = make_stateful_redeploy_mock(
"BUILD_FAILED",
stop_raises=SynologyError("already stopped", code=2101),
pull_raises=None, # pull succeeds
)
tools = make_projects_tools(client)
@@ -303,26 +304,49 @@ async def test_redeploy_build_failed_stop_error_nonfatal():
@pytest.mark.asyncio
async def test_redeploy_build_failed_pull_error_aborts():
"""BUILD_FAILED: pull failure must abort redeploy with a clear message."""
from mcp_synology_container.dsm_client import SynologyError
async def test_redeploy_image_delete_failure_nonfatal():
"""Image deletion failure must be non-fatal: start must still be called."""
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)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "redeployed successfully" not in result
assert "Aborted" in result or "pull failed" in result.lower()
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
assert "redeployed successfully" in result
assert start_called, "start must be called even when image deletion fails"
@pytest.mark.asyncio
@@ -333,6 +357,8 @@ async def test_redeploy_poll_timeout():
async def mock_request(api, method, **kwargs):
nonlocal start_called
if api == "SYNO.FileStation.List":
return {"files": []}
if method == "start":
start_called = True
if method == "list":
@@ -342,6 +368,7 @@ async def test_redeploy_poll_timeout():
return {}
client.request.side_effect = mock_request
client.post_request = AsyncMock()
tools = make_projects_tools(client)
# 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()
async def mock_request(api, method, **kwargs):
if api == "SYNO.FileStation.List":
return {"files": []}
if method == "list":
return project_list("UPDATING")
return {}
client.request.side_effect = mock_request
client.post_request = AsyncMock()
tools = make_projects_tools(client)
result = await tools["redeploy_project"]("myapp", confirmed=True)