diff --git a/src/mcp_synology_container/modules/system.py b/src/mcp_synology_container/modules/system.py new file mode 100644 index 0000000..fa17c41 --- /dev/null +++ b/src/mcp_synology_container/modules/system.py @@ -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 () or unused + for img in images: + tags = img.get("tags") or [] + is_untagged = not tags or tags == [""] + 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", "") + tags = img.get("tags") or [""] + 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) diff --git a/src/mcp_synology_container/server.py b/src/mcp_synology_container/server.py index 62d5426..43dab7c 100644 --- a/src/mcp_synology_container/server.py +++ b/src/mcp_synology_container/server.py @@ -26,15 +26,17 @@ def create_server(config: AppConfig, client: DsmClient) -> FastMCP: """ mcp = FastMCP("mcp-synology-container") - from mcp_synology_container.modules.projects import register_projects - from mcp_synology_container.modules.containers import register_containers from mcp_synology_container.modules.compose import register_compose + from mcp_synology_container.modules.containers import register_containers from mcp_synology_container.modules.images import register_images + from mcp_synology_container.modules.projects import register_projects + from mcp_synology_container.modules.system import register_system register_projects(mcp, config, client) register_containers(mcp, config, client) register_compose(mcp, config, client) register_images(mcp, config, client) + register_system(mcp, config, client) logger.info("MCP server configured with all tool modules") return mcp diff --git a/tests/test_modules/test_system.py b/tests/test_modules/test_system.py new file mode 100644 index 0000000..5ee6864 --- /dev/null +++ b/tests/test_modules/test_system.py @@ -0,0 +1,307 @@ +"""Tests for modules/system.py.""" + +from unittest.mock import AsyncMock + +import pytest + + +def make_mock_mcp(): + tools: dict = {} + + class MockMCP: + def tool(self): + def decorator(fn): + tools[fn.__name__] = fn + return fn + + return decorator + + return MockMCP(), tools + + +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), + ) + + +SAMPLE_IMAGES = { + "images": [ + { + "id": "sha256:aaaa", + "repository": "nginx", + "tags": ["1.24"], + "size": 50 * 1024 * 1024, + }, + { + "id": "sha256:bbbb", + "repository": "postgres", + "tags": ["15"], + "size": 80 * 1024 * 1024, + }, + { + "id": "sha256:cccc", + "repository": "", + "tags": [], + "size": 10 * 1024 * 1024, + }, + ] +} + +SAMPLE_CONTAINERS = { + "containers": [ + {"name": "web", "status": "running", "image_id": "sha256:aaaa"}, + {"name": "db", "status": "running", "image_id": "sha256:bbbb"}, + {"name": "old", "status": "stopped", "image_id": "sha256:aaaa"}, + ] +} + + +# ────────────────────────────────────────────────────────────────────────────── +# system_df +# ────────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_system_df_shows_image_stats(): + from mcp_synology_container.modules.system import register_system + + client = AsyncMock() + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Image": + return SAMPLE_IMAGES + if api == "SYNO.Docker.Container": + return SAMPLE_CONTAINERS + return {} + + client.request.side_effect = mock_request + mcp, tools = make_mock_mcp() + register_system(mcp, make_config(), client) + + result = await tools["system_df"]() + assert "Images" in result + assert "3" in result # total images + assert "Containers" in result + + +@pytest.mark.asyncio +async def test_system_df_reclaimable(): + """Unused images (not referenced by any container) are counted as reclaimable.""" + from mcp_synology_container.modules.system import register_system + + client = AsyncMock() + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Image": + return SAMPLE_IMAGES + if api == "SYNO.Docker.Container": + return SAMPLE_CONTAINERS + return {} + + client.request.side_effect = mock_request + mcp, tools = make_mock_mcp() + register_system(mcp, make_config(), client) + + result = await tools["system_df"]() + # sha256:cccc () is not referenced by any container → reclaimable + assert "reclaimable" in result + assert "unused" in result + + +@pytest.mark.asyncio +async def test_system_df_running_vs_stopped(): + from mcp_synology_container.modules.system import register_system + + client = AsyncMock() + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Image": + return SAMPLE_IMAGES + if api == "SYNO.Docker.Container": + return SAMPLE_CONTAINERS + return {} + + client.request.side_effect = mock_request + mcp, tools = make_mock_mcp() + register_system(mcp, make_config(), client) + + result = await tools["system_df"]() + assert "running" in result + assert "stopped" in result + + +@pytest.mark.asyncio +async def test_system_df_image_api_error_graceful(): + """Container data is still shown even when image API fails.""" + from mcp_synology_container.dsm_client import SynologyError + from mcp_synology_container.modules.system import register_system + + client = AsyncMock() + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Image": + raise SynologyError("image API down", code=102) + if api == "SYNO.Docker.Container": + return SAMPLE_CONTAINERS + return {} + + client.request.side_effect = mock_request + mcp, tools = make_mock_mcp() + register_system(mcp, make_config(), client) + + result = await tools["system_df"]() + # Should still show container section and a warning + assert "Containers" in result or "Warnings" in result + + +@pytest.mark.asyncio +async def test_system_df_no_images(): + from mcp_synology_container.modules.system import register_system + + client = AsyncMock() + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Image": + return {"images": []} + if api == "SYNO.Docker.Container": + return {"containers": []} + return {} + + client.request.side_effect = mock_request + mcp, tools = make_mock_mcp() + register_system(mcp, make_config(), client) + + result = await tools["system_df"]() + assert "0" in result + + +# ────────────────────────────────────────────────────────────────────────────── +# system_prune +# ────────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_system_prune_preview(): + from mcp_synology_container.modules.system import register_system + + client = AsyncMock() + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Image": + return SAMPLE_IMAGES + if api == "SYNO.Docker.Container": + return SAMPLE_CONTAINERS + return {} + + client.request.side_effect = mock_request + mcp, tools = make_mock_mcp() + register_system(mcp, make_config(), client) + + result = await tools["system_prune"]() + assert "preview" in result.lower() + assert "confirmed=True" in result + # prune API must NOT be called + calls = [c.args[:2] for c in client.request.call_args_list] + assert ("SYNO.Docker.Utils", "prune") not in calls + + +@pytest.mark.asyncio +async def test_system_prune_preview_lists_dangling(): + """Preview names dangling/unused images and stopped containers.""" + from mcp_synology_container.modules.system import register_system + + client = AsyncMock() + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Image": + return SAMPLE_IMAGES + if api == "SYNO.Docker.Container": + return SAMPLE_CONTAINERS + return {} + + client.request.side_effect = mock_request + mcp, tools = make_mock_mcp() + register_system(mcp, make_config(), client) + + result = await tools["system_prune"]() + # : is dangling + assert "" in result + # "old" container is stopped + assert "old" in result + + +@pytest.mark.asyncio +async def test_system_prune_confirmed(): + from mcp_synology_container.modules.system import register_system + + client = AsyncMock() + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Image": + return SAMPLE_IMAGES + if api == "SYNO.Docker.Container": + return SAMPLE_CONTAINERS + if api == "SYNO.Docker.Utils" and method == "prune": + return {"SpaceReclaimed": 10 * 1024 * 1024} + return {} + + client.request.side_effect = mock_request + mcp, tools = make_mock_mcp() + register_system(mcp, make_config(), client) + + result = await tools["system_prune"](confirmed=True) + assert "completed" in result.lower() + assert "MiB" in result or "reclaimed" in result.lower() + + +@pytest.mark.asyncio +async def test_system_prune_confirmed_no_space_reported(): + """Prune works even when DSM doesn't report reclaimed bytes.""" + from mcp_synology_container.modules.system import register_system + + client = AsyncMock() + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Image": + return SAMPLE_IMAGES + if api == "SYNO.Docker.Container": + return SAMPLE_CONTAINERS + if api == "SYNO.Docker.Utils" and method == "prune": + return {} + return {} + + client.request.side_effect = mock_request + mcp, tools = make_mock_mcp() + register_system(mcp, make_config(), client) + + result = await tools["system_prune"](confirmed=True) + assert "completed" in result.lower() + assert "not reported" in result + + +@pytest.mark.asyncio +async def test_system_prune_api_error(): + from mcp_synology_container.dsm_client import SynologyError + from mcp_synology_container.modules.system import register_system + + client = AsyncMock() + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Image": + return SAMPLE_IMAGES + if api == "SYNO.Docker.Container": + return SAMPLE_CONTAINERS + if api == "SYNO.Docker.Utils" and method == "prune": + raise SynologyError("prune failed", code=100) + return {} + + client.request.side_effect = mock_request + mcp, tools = make_mock_mcp() + register_system(mcp, make_config(), client) + + result = await tools["system_prune"](confirmed=True) + assert "Error" in result