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