Add system_df and system_prune tools (Gruppe 3)

system_df:
  Assembles disk-usage report from SYNO.Docker.Image/list and
  SYNO.Docker.Container/list. Reports image count/size/reclaimable
  (images not referenced by any container), container running/stopped.
  Gracefully degrades when one API is unavailable.

system_prune:
  Without confirmed=True: lists dangling/unused images and stopped
  containers with sizes (dry-run preview).
  With confirmed=True: calls SYNO.Docker.Utils/prune and reports
  reclaimed space from the response (SpaceReclaimed field).

10 unit tests: stats counts, reclaimable detection, preview content,
confirmed execution, missing-response-field graceful handling, API error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 18:36:49 +02:00
parent a8da306ce5
commit 6bdd2bcb6a
3 changed files with 510 additions and 2 deletions
@@ -0,0 +1,199 @@
"""MCP tools for Docker system-level operations: disk usage and prune."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from mcp_synology_container.modules.images import _human_size
if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP
from mcp_synology_container.config import AppConfig
from mcp_synology_container.dsm_client import DsmClient
logger = logging.getLogger(__name__)
def register_system(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
"""Register all system-level tools with the MCP server."""
@mcp.tool()
async def system_df() -> str:
"""Show Docker disk usage: images, containers, and volumes.
Assembles disk-usage data from the image and container lists.
Images that are not referenced by any container are marked as
reclaimable.
"""
errors: list[str] = []
# ── Images ───────────────────────────────────────────────────────────
images: list[dict[str, Any]] = []
try:
img_data = await client.request(
"SYNO.Docker.Image",
"list",
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
)
images = img_data.get("images", [])
except Exception as e:
errors.append(f"images: {e}")
# ── Containers ───────────────────────────────────────────────────────
containers: list[dict[str, Any]] = []
try:
ctr_data = await client.request(
"SYNO.Docker.Container",
"list",
params={"limit": "-1", "offset": "0", "type": "all"},
)
containers = ctr_data.get("containers", [])
except Exception as e:
errors.append(f"containers: {e}")
# ── Compute image stats ───────────────────────────────────────────────
# An image is "in use" if any container references its ID
in_use_ids: set[str] = set()
for ctr in containers:
img_id = ctr.get("image_id") or ctr.get("ImageID") or ctr.get("Image", "")
if img_id:
in_use_ids.add(img_id)
total_image_size = sum(img.get("size", 0) for img in images)
reclaimable_size = sum(
img.get("size", 0) for img in images if img.get("id", "") not in in_use_ids
)
reclaimable_count = sum(1 for img in images if img.get("id", "") not in in_use_ids)
# ── Compute container stats ───────────────────────────────────────────
running = sum(1 for c in containers if c.get("status") in ("running", "up"))
stopped = len(containers) - running
# ── Format output ─────────────────────────────────────────────────────
lines = ["Docker Disk Usage", ""]
# Images table
lines.append(f" Images: {len(images):>4} total {_human_size(total_image_size):>10}")
rec_str = _human_size(reclaimable_size)
lines.append(f" {reclaimable_count:>4} unused {rec_str:>10} (reclaimable)")
# Containers table
lines.append("")
lines.append(f" Containers: {len(containers):>4} total")
lines.append(f" {running:>4} running")
lines.append(f" {stopped:>4} stopped (reclaimable via system_prune)")
if errors:
lines.append("")
lines.append("Warnings:")
for err in errors:
lines.append(f" {err}")
return "\n".join(lines)
@mcp.tool()
async def system_prune(confirmed: bool = False) -> str:
"""Remove unused Docker resources: dangling images, stopped containers, unused networks.
Without confirmed=True, shows a preview of what would be removed.
With confirmed=True, runs the prune and reports reclaimed space.
Args:
confirmed: Must be True to actually prune. Default False shows
a preview only.
"""
# ── Gather preview data ───────────────────────────────────────────────
dangling_images: list[dict[str, Any]] = []
stopped_containers: list[dict[str, Any]] = []
try:
img_data = await client.request(
"SYNO.Docker.Image",
"list",
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
)
ctr_data = await client.request(
"SYNO.Docker.Container",
"list",
params={"limit": "-1", "offset": "0", "type": "all"},
)
except Exception as e:
return f"Error fetching resource list: {e}"
images: list[dict[str, Any]] = img_data.get("images", [])
containers: list[dict[str, Any]] = ctr_data.get("containers", [])
# Images in use by any container
in_use_ids: set[str] = {
ctr.get("image_id") or ctr.get("ImageID") or ctr.get("Image", "") for ctr in containers
} - {""}
# Dangling = untagged (<none>) or unused
for img in images:
tags = img.get("tags") or []
is_untagged = not tags or tags == ["<none>"]
is_unused = img.get("id", "") not in in_use_ids
if is_untagged or is_unused:
dangling_images.append(img)
for ctr in containers:
if ctr.get("status") not in ("running", "up"):
stopped_containers.append(ctr)
dangling_size = sum(img.get("size", 0) for img in dangling_images)
if not confirmed:
lines = ["system_prune — preview (nothing deleted yet):", ""]
lines.append(
f" Dangling/unused images: {len(dangling_images)} ({_human_size(dangling_size)})"
)
for img in dangling_images[:10]:
repo = img.get("repository", "<none>")
tags = img.get("tags") or ["<none>"]
lines.append(f" - {repo}:{tags[0]}")
if len(dangling_images) > 10:
lines.append(f" … and {len(dangling_images) - 10} more")
lines.append(f" Stopped containers: {len(stopped_containers)}")
for ctr in stopped_containers[:10]:
lines.append(f" - {ctr.get('name', '?')}")
if len(stopped_containers) > 10:
lines.append(f" … and {len(stopped_containers) - 10} more")
lines.append(" Unused networks: (not counted — run prune to remove)")
lines.append("")
lines.append(
f"Call system_prune(confirmed=True) to free ~{_human_size(dangling_size)}."
)
return "\n".join(lines)
# ── Execute prune ─────────────────────────────────────────────────────
try:
result = await client.request("SYNO.Docker.Utils", "prune")
except Exception as e:
return f"Error running system prune: {e}"
# Parse reclaimed space from response (field names vary by DSM version)
reclaimed = (
result.get("SpaceReclaimed")
or result.get("space_reclaimed")
or result.get("reclaimed")
or 0
)
lines = ["system_prune — completed.", ""]
if reclaimed:
lines.append(f" Space reclaimed: {_human_size(int(reclaimed))}")
else:
lines.append(" Space reclaimed: (not reported by DSM)")
# Surface any containers/images counts from the response
for key in ("ContainersDeleted", "ImagesDeleted", "VolumesDeleted", "NetworksDeleted"):
val = result.get(key)
if val is not None:
label = key.replace("Deleted", " deleted")
lines.append(f" {label}: {len(val) if isinstance(val, list) else val}")
return "\n".join(lines)