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