From bafa327412f3f0f2d73f22dbf4b3c74900496067 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Tue, 21 Apr 2026 07:57:57 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20v0.2.4=20=E2=80=94=20image=20delete=20w?= =?UTF-8?q?orkaround=20+=20auto-version=20env-var=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - 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 --- CHANGELOG.md | 21 ++ pyproject.toml | 2 +- src/mcp_synology_container/modules/compose.py | 109 ++++++-- .../modules/projects.py | 261 ++++++++++++++---- tests/test_modules/test_compose.py | 181 +++++++++--- tests/test_modules/test_projects.py | 74 +++-- 6 files changed, 522 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36da591..331168a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 + `-` 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 diff --git a/pyproject.toml b/pyproject.toml index 1883bb9..dda84c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/mcp_synology_container/modules/compose.py b/src/mcp_synology_container/modules/compose.py index e001a7a..aa97bb9 100644 --- a/src/mcp_synology_container/modules/compose.py +++ b/src/mcp_synology_container/modules/compose.py @@ -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 ``-`` (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 -: + # 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, diff --git a/src/mcp_synology_container/modules/projects.py b/src/mcp_synology_container/modules/projects.py index f1b0b79..f470b22 100644 --- a/src/mcp_synology_container/modules/projects.py +++ b/src/mcp_synology_container/modules/projects.py @@ -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, diff --git a/tests/test_modules/test_compose.py b/tests/test_modules/test_compose.py index 143a001..83c25e5 100644 --- a/tests/test_modules/test_compose.py +++ b/tests/test_modules/test_compose.py @@ -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' diff --git a/tests/test_modules/test_projects.py b/tests/test_modules/test_projects.py index fd8439c..959c00d 100644 --- a/tests/test_modules/test_projects.py +++ b/tests/test_modules/test_projects.py @@ -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)