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
+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,