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