mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 12:39:24 -07:00
feat(array): add parity_history, start/stop array, disk add/remove/mount/unmount/clear_stats
Expands unraid_array from 5 to 13 actions: adds parity_history query, start_array/stop_array state mutations, and disk operations (add_disk, remove_disk, mount_disk, unmount_disk, clear_disk_stats). Destructive actions remove_disk and clear_disk_stats require confirm=True. Safety audit tests updated to cover the new DESTRUCTIVE_ACTIONS registry entry.
This commit is contained in:
@@ -19,14 +19,18 @@ from conftest import make_tool_fn
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
# Import DESTRUCTIVE_ACTIONS sets from every tool module that defines one
|
||||
from unraid_mcp.tools.docker import DESTRUCTIVE_ACTIONS as DOCKER_DESTRUCTIVE
|
||||
from unraid_mcp.tools.docker import MUTATIONS as DOCKER_MUTATIONS
|
||||
from unraid_mcp.tools.array import DESTRUCTIVE_ACTIONS as ARRAY_DESTRUCTIVE
|
||||
from unraid_mcp.tools.array import MUTATIONS as ARRAY_MUTATIONS
|
||||
from unraid_mcp.tools.keys import DESTRUCTIVE_ACTIONS as KEYS_DESTRUCTIVE
|
||||
from unraid_mcp.tools.keys import MUTATIONS as KEYS_MUTATIONS
|
||||
from unraid_mcp.tools.notifications import DESTRUCTIVE_ACTIONS as NOTIF_DESTRUCTIVE
|
||||
from unraid_mcp.tools.notifications import MUTATIONS as NOTIF_MUTATIONS
|
||||
from unraid_mcp.tools.rclone import DESTRUCTIVE_ACTIONS as RCLONE_DESTRUCTIVE
|
||||
from unraid_mcp.tools.rclone import MUTATIONS as RCLONE_MUTATIONS
|
||||
from unraid_mcp.tools.settings import DESTRUCTIVE_ACTIONS as SETTINGS_DESTRUCTIVE
|
||||
from unraid_mcp.tools.settings import MUTATIONS as SETTINGS_MUTATIONS
|
||||
from unraid_mcp.tools.storage import DESTRUCTIVE_ACTIONS as STORAGE_DESTRUCTIVE
|
||||
from unraid_mcp.tools.storage import MUTATIONS as STORAGE_MUTATIONS
|
||||
from unraid_mcp.tools.virtualization import DESTRUCTIVE_ACTIONS as VM_DESTRUCTIVE
|
||||
from unraid_mcp.tools.virtualization import MUTATIONS as VM_MUTATIONS
|
||||
|
||||
@@ -36,13 +40,13 @@ from unraid_mcp.tools.virtualization import MUTATIONS as VM_MUTATIONS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Every destructive action in the codebase, keyed by (tool_module, tool_name)
|
||||
KNOWN_DESTRUCTIVE: dict[str, dict[str, set[str]]] = {
|
||||
"docker": {
|
||||
"module": "unraid_mcp.tools.docker",
|
||||
"register_fn": "register_docker_tool",
|
||||
"tool_name": "unraid_docker",
|
||||
"actions": {"remove", "update_all", "delete_entries", "reset_template_mappings"},
|
||||
"runtime_set": DOCKER_DESTRUCTIVE,
|
||||
KNOWN_DESTRUCTIVE: dict[str, dict[str, set[str] | str]] = {
|
||||
"array": {
|
||||
"module": "unraid_mcp.tools.array",
|
||||
"register_fn": "register_array_tool",
|
||||
"tool_name": "unraid_array",
|
||||
"actions": {"remove_disk", "clear_disk_stats"},
|
||||
"runtime_set": ARRAY_DESTRUCTIVE,
|
||||
},
|
||||
"vm": {
|
||||
"module": "unraid_mcp.tools.virtualization",
|
||||
@@ -72,6 +76,20 @@ KNOWN_DESTRUCTIVE: dict[str, dict[str, set[str]]] = {
|
||||
"actions": {"delete"},
|
||||
"runtime_set": KEYS_DESTRUCTIVE,
|
||||
},
|
||||
"storage": {
|
||||
"module": "unraid_mcp.tools.storage",
|
||||
"register_fn": "register_storage_tool",
|
||||
"tool_name": "unraid_storage",
|
||||
"actions": {"flash_backup"},
|
||||
"runtime_set": STORAGE_DESTRUCTIVE,
|
||||
},
|
||||
"settings": {
|
||||
"module": "unraid_mcp.tools.settings",
|
||||
"register_fn": "register_settings_tool",
|
||||
"tool_name": "unraid_settings",
|
||||
"actions": {"configure_ups"},
|
||||
"runtime_set": SETTINGS_DESTRUCTIVE,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -96,11 +114,13 @@ class TestDestructiveActionRegistries:
|
||||
"""Every destructive action must correspond to an actual mutation."""
|
||||
info = KNOWN_DESTRUCTIVE[tool_key]
|
||||
mutations_map = {
|
||||
"docker": DOCKER_MUTATIONS,
|
||||
"array": ARRAY_MUTATIONS,
|
||||
"vm": VM_MUTATIONS,
|
||||
"notifications": NOTIF_MUTATIONS,
|
||||
"rclone": RCLONE_MUTATIONS,
|
||||
"keys": KEYS_MUTATIONS,
|
||||
"storage": STORAGE_MUTATIONS,
|
||||
"settings": SETTINGS_MUTATIONS,
|
||||
}
|
||||
mutations = mutations_map[tool_key]
|
||||
for action in info["actions"]:
|
||||
@@ -111,18 +131,22 @@ class TestDestructiveActionRegistries:
|
||||
def test_no_delete_or_remove_mutations_missing_from_destructive(self) -> None:
|
||||
"""Any mutation with 'delete' or 'remove' in its name should be destructive."""
|
||||
all_mutations = {
|
||||
"docker": DOCKER_MUTATIONS,
|
||||
"array": ARRAY_MUTATIONS,
|
||||
"vm": VM_MUTATIONS,
|
||||
"notifications": NOTIF_MUTATIONS,
|
||||
"rclone": RCLONE_MUTATIONS,
|
||||
"keys": KEYS_MUTATIONS,
|
||||
"storage": STORAGE_MUTATIONS,
|
||||
"settings": SETTINGS_MUTATIONS,
|
||||
}
|
||||
all_destructive = {
|
||||
"docker": DOCKER_DESTRUCTIVE,
|
||||
"array": ARRAY_DESTRUCTIVE,
|
||||
"vm": VM_DESTRUCTIVE,
|
||||
"notifications": NOTIF_DESTRUCTIVE,
|
||||
"rclone": RCLONE_DESTRUCTIVE,
|
||||
"keys": KEYS_DESTRUCTIVE,
|
||||
"storage": STORAGE_DESTRUCTIVE,
|
||||
"settings": SETTINGS_DESTRUCTIVE,
|
||||
}
|
||||
missing: list[str] = []
|
||||
for tool_key, mutations in all_mutations.items():
|
||||
@@ -145,11 +169,9 @@ class TestDestructiveActionRegistries:
|
||||
# Build parametrized test cases: (tool_key, action, kwargs_without_confirm)
|
||||
# Each destructive action needs the minimum required params (minus confirm)
|
||||
_DESTRUCTIVE_TEST_CASES: list[tuple[str, str, dict]] = [
|
||||
# Docker
|
||||
("docker", "remove", {"container_id": "abc123"}),
|
||||
("docker", "update_all", {}),
|
||||
("docker", "delete_entries", {"entry_ids": ["e1"]}),
|
||||
("docker", "reset_template_mappings", {}),
|
||||
# Array
|
||||
("array", "remove_disk", {"disk_id": "abc123:local"}),
|
||||
("array", "clear_disk_stats", {"disk_id": "abc123:local"}),
|
||||
# VM
|
||||
("vm", "force_stop", {"vm_id": "test-vm-uuid"}),
|
||||
("vm", "reset", {"vm_id": "test-vm-uuid"}),
|
||||
@@ -160,6 +182,14 @@ _DESTRUCTIVE_TEST_CASES: list[tuple[str, str, dict]] = [
|
||||
("rclone", "delete_remote", {"name": "my-remote"}),
|
||||
# Keys
|
||||
("keys", "delete", {"key_id": "key-123"}),
|
||||
# Storage
|
||||
(
|
||||
"storage",
|
||||
"flash_backup",
|
||||
{"remote_name": "r", "source_path": "/boot", "destination_path": "r:b"},
|
||||
),
|
||||
# Settings
|
||||
("settings", "configure_ups", {"ups_config": {"mode": "slave"}}),
|
||||
]
|
||||
|
||||
|
||||
@@ -167,8 +197,8 @@ _CASE_IDS = [f"{c[0]}/{c[1]}" for c in _DESTRUCTIVE_TEST_CASES]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_docker_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
def _mock_array_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.array.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
yield m
|
||||
|
||||
|
||||
@@ -196,9 +226,21 @@ def _mock_keys_graphql() -> Generator[AsyncMock, None, None]:
|
||||
yield m
|
||||
|
||||
|
||||
# Map tool_key -> (fixture name, module path, register fn, tool name)
|
||||
@pytest.fixture
|
||||
def _mock_storage_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.storage.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_settings_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.settings.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
yield m
|
||||
|
||||
|
||||
# Map tool_key -> (module path, register fn, tool name)
|
||||
_TOOL_REGISTRY = {
|
||||
"docker": ("unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker"),
|
||||
"array": ("unraid_mcp.tools.array", "register_array_tool", "unraid_array"),
|
||||
"vm": ("unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm"),
|
||||
"notifications": (
|
||||
"unraid_mcp.tools.notifications",
|
||||
@@ -207,6 +249,8 @@ _TOOL_REGISTRY = {
|
||||
),
|
||||
"rclone": ("unraid_mcp.tools.rclone", "register_rclone_tool", "unraid_rclone"),
|
||||
"keys": ("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys"),
|
||||
"storage": ("unraid_mcp.tools.storage", "register_storage_tool", "unraid_storage"),
|
||||
"settings": ("unraid_mcp.tools.settings", "register_settings_tool", "unraid_settings"),
|
||||
}
|
||||
|
||||
|
||||
@@ -219,11 +263,13 @@ class TestConfirmationGuards:
|
||||
tool_key: str,
|
||||
action: str,
|
||||
kwargs: dict,
|
||||
_mock_docker_graphql: AsyncMock,
|
||||
_mock_array_graphql: AsyncMock,
|
||||
_mock_vm_graphql: AsyncMock,
|
||||
_mock_notif_graphql: AsyncMock,
|
||||
_mock_rclone_graphql: AsyncMock,
|
||||
_mock_keys_graphql: AsyncMock,
|
||||
_mock_storage_graphql: AsyncMock,
|
||||
_mock_settings_graphql: AsyncMock,
|
||||
) -> None:
|
||||
"""Calling a destructive action without confirm=True must raise ToolError."""
|
||||
module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key]
|
||||
@@ -238,17 +284,19 @@ class TestConfirmationGuards:
|
||||
tool_key: str,
|
||||
action: str,
|
||||
kwargs: dict,
|
||||
_mock_docker_graphql: AsyncMock,
|
||||
_mock_array_graphql: AsyncMock,
|
||||
_mock_vm_graphql: AsyncMock,
|
||||
_mock_notif_graphql: AsyncMock,
|
||||
_mock_rclone_graphql: AsyncMock,
|
||||
_mock_keys_graphql: AsyncMock,
|
||||
_mock_storage_graphql: AsyncMock,
|
||||
_mock_settings_graphql: AsyncMock,
|
||||
) -> None:
|
||||
"""Explicitly passing confirm=False must still raise ToolError."""
|
||||
module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key]
|
||||
tool_fn = make_tool_fn(module_path, register_fn, tool_name)
|
||||
|
||||
with pytest.raises(ToolError, match="destructive"):
|
||||
with pytest.raises(ToolError, match="confirm=True"):
|
||||
await tool_fn(action=action, confirm=False, **kwargs)
|
||||
|
||||
@pytest.mark.parametrize("tool_key,action,kwargs", _DESTRUCTIVE_TEST_CASES, ids=_CASE_IDS)
|
||||
@@ -257,11 +305,13 @@ class TestConfirmationGuards:
|
||||
tool_key: str,
|
||||
action: str,
|
||||
kwargs: dict,
|
||||
_mock_docker_graphql: AsyncMock,
|
||||
_mock_array_graphql: AsyncMock,
|
||||
_mock_vm_graphql: AsyncMock,
|
||||
_mock_notif_graphql: AsyncMock,
|
||||
_mock_rclone_graphql: AsyncMock,
|
||||
_mock_keys_graphql: AsyncMock,
|
||||
_mock_storage_graphql: AsyncMock,
|
||||
_mock_settings_graphql: AsyncMock,
|
||||
) -> None:
|
||||
"""The error message should include the action name for clarity."""
|
||||
module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key]
|
||||
@@ -279,51 +329,6 @@ class TestConfirmationGuards:
|
||||
class TestConfirmAllowsExecution:
|
||||
"""Destructive actions with confirm=True should reach the GraphQL layer."""
|
||||
|
||||
async def test_docker_update_all_with_confirm(self, _mock_docker_graphql: AsyncMock) -> None:
|
||||
_mock_docker_graphql.return_value = {
|
||||
"docker": {
|
||||
"updateAllContainers": [
|
||||
{"id": "c1", "names": ["app"], "state": "running", "status": "Up"}
|
||||
]
|
||||
}
|
||||
}
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker")
|
||||
result = await tool_fn(action="update_all", confirm=True)
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "update_all"
|
||||
|
||||
async def test_docker_delete_entries_with_confirm(
|
||||
self, _mock_docker_graphql: AsyncMock
|
||||
) -> None:
|
||||
organizer_response = {
|
||||
"version": 1.0,
|
||||
"views": [{"id": "default", "name": "Default", "rootId": "root", "flatEntries": []}],
|
||||
}
|
||||
_mock_docker_graphql.return_value = {"deleteDockerEntries": organizer_response}
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker")
|
||||
result = await tool_fn(action="delete_entries", entry_ids=["e1"], confirm=True)
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "delete_entries"
|
||||
|
||||
async def test_docker_reset_template_mappings_with_confirm(
|
||||
self, _mock_docker_graphql: AsyncMock
|
||||
) -> None:
|
||||
_mock_docker_graphql.return_value = {"resetDockerTemplateMappings": True}
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker")
|
||||
result = await tool_fn(action="reset_template_mappings", confirm=True)
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "reset_template_mappings"
|
||||
|
||||
async def test_docker_remove_with_confirm(self, _mock_docker_graphql: AsyncMock) -> None:
|
||||
cid = "a" * 64 + ":local"
|
||||
_mock_docker_graphql.side_effect = [
|
||||
{"docker": {"containers": [{"id": cid, "names": ["old-app"]}]}},
|
||||
{"docker": {"removeContainer": True}},
|
||||
]
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker")
|
||||
result = await tool_fn(action="remove", container_id="old-app", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_vm_force_stop_with_confirm(self, _mock_vm_graphql: AsyncMock) -> None:
|
||||
_mock_vm_graphql.return_value = {"vm": {"forceStop": True}}
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm")
|
||||
@@ -380,3 +385,47 @@ class TestConfirmAllowsExecution:
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys")
|
||||
result = await tool_fn(action="delete", key_id="key-123", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_storage_flash_backup_with_confirm(
|
||||
self, _mock_storage_graphql: AsyncMock
|
||||
) -> None:
|
||||
_mock_storage_graphql.return_value = {
|
||||
"initiateFlashBackup": {"status": "started", "jobId": "j:1"}
|
||||
}
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.storage", "register_storage_tool", "unraid_storage"
|
||||
)
|
||||
result = await tool_fn(
|
||||
action="flash_backup",
|
||||
confirm=True,
|
||||
remote_name="r",
|
||||
source_path="/boot",
|
||||
destination_path="r:b",
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_settings_configure_ups_with_confirm(
|
||||
self, _mock_settings_graphql: AsyncMock
|
||||
) -> None:
|
||||
_mock_settings_graphql.return_value = {"configureUps": True}
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.settings", "register_settings_tool", "unraid_settings"
|
||||
)
|
||||
result = await tool_fn(
|
||||
action="configure_ups", confirm=True, ups_config={"mode": "master", "cable": "usb"}
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_array_remove_disk_with_confirm(self, _mock_array_graphql: AsyncMock) -> None:
|
||||
_mock_array_graphql.return_value = {"array": {"removeDiskFromArray": {"state": "STOPPED"}}}
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.array", "register_array_tool", "unraid_array")
|
||||
result = await tool_fn(action="remove_disk", disk_id="abc:local", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_array_clear_disk_stats_with_confirm(
|
||||
self, _mock_array_graphql: AsyncMock
|
||||
) -> None:
|
||||
_mock_array_graphql.return_value = {"array": {"clearArrayDiskStatistics": True}}
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.array", "register_array_tool", "unraid_array")
|
||||
result = await tool_fn(action="clear_disk_stats", disk_id="abc:local", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
@@ -32,8 +32,6 @@ class TestArrayValidation:
|
||||
"stop",
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"mount_disk",
|
||||
"unmount_disk",
|
||||
"clear_stats",
|
||||
):
|
||||
with pytest.raises(ToolError, match="Invalid action"):
|
||||
@@ -143,3 +141,108 @@ class TestArrayNetworkErrors:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Network connection error"):
|
||||
await tool_fn(action="parity_status")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New actions: parity_history, start/stop array, disk operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_array_graphql():
|
||||
with patch("unraid_mcp.tools.array.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
yield m
|
||||
|
||||
|
||||
# parity_history
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parity_history_returns_history(_mock_array_graphql):
|
||||
_mock_array_graphql.return_value = {
|
||||
"parityHistory": [{"date": "2026-03-01T00:00:00Z", "status": "COMPLETED", "errors": 0}]
|
||||
}
|
||||
result = await _make_tool()(action="parity_history")
|
||||
assert result["success"] is True
|
||||
assert len(result["data"]["parityHistory"]) == 1
|
||||
|
||||
|
||||
# Array state mutations
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_array(_mock_array_graphql):
|
||||
_mock_array_graphql.return_value = {"array": {"setState": {"state": "STARTED"}}}
|
||||
result = await _make_tool()(action="start_array")
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_array(_mock_array_graphql):
|
||||
_mock_array_graphql.return_value = {"array": {"setState": {"state": "STOPPED"}}}
|
||||
result = await _make_tool()(action="stop_array")
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
# add_disk
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_disk_requires_disk_id(_mock_array_graphql):
|
||||
with pytest.raises(ToolError, match="disk_id"):
|
||||
await _make_tool()(action="add_disk")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_disk_success(_mock_array_graphql):
|
||||
_mock_array_graphql.return_value = {"array": {"addDiskToArray": {"state": "STARTED"}}}
|
||||
result = await _make_tool()(action="add_disk", disk_id="abc123:local")
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
# remove_disk — destructive
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_disk_requires_confirm(_mock_array_graphql):
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await _make_tool()(action="remove_disk", disk_id="abc123:local", confirm=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_disk_with_confirm(_mock_array_graphql):
|
||||
_mock_array_graphql.return_value = {"array": {"removeDiskFromArray": {"state": "STOPPED"}}}
|
||||
result = await _make_tool()(action="remove_disk", disk_id="abc123:local", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
# mount_disk / unmount_disk
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mount_disk_requires_disk_id(_mock_array_graphql):
|
||||
with pytest.raises(ToolError, match="disk_id"):
|
||||
await _make_tool()(action="mount_disk")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unmount_disk_success(_mock_array_graphql):
|
||||
_mock_array_graphql.return_value = {"array": {"unmountArrayDisk": {"id": "abc123:local"}}}
|
||||
result = await _make_tool()(action="unmount_disk", disk_id="abc123:local")
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
# clear_disk_stats — destructive
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_disk_stats_requires_confirm(_mock_array_graphql):
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await _make_tool()(action="clear_disk_stats", disk_id="abc123:local", confirm=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_disk_stats_with_confirm(_mock_array_graphql):
|
||||
_mock_array_graphql.return_value = {"array": {"clearArrayDiskStatistics": True}}
|
||||
result = await _make_tool()(action="clear_disk_stats", disk_id="abc123:local", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
"""Array parity check operations.
|
||||
"""Array management: parity checks, array state, and disk operations.
|
||||
|
||||
Provides the `unraid_array` tool with 5 actions for parity check management.
|
||||
Provides the `unraid_array` tool with 13 actions covering parity check
|
||||
management, array start/stop, and disk add/remove/mount operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, get_args
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp import Context, FastMCP
|
||||
|
||||
from ..config.logging import logger
|
||||
from ..core.client import make_graphql_request
|
||||
from ..core.exceptions import ToolError, tool_error_handler
|
||||
from ..core.setup import elicit_destructive_confirmation
|
||||
|
||||
|
||||
QUERIES: dict[str, str] = {
|
||||
"parity_status": """
|
||||
query GetParityStatus {
|
||||
array { parityCheckStatus { progress speed errors } }
|
||||
array { parityCheckStatus { progress speed errors status paused running correcting } }
|
||||
}
|
||||
""",
|
||||
"parity_history": """
|
||||
query GetParityHistory {
|
||||
parityHistory {
|
||||
date duration speed status errors progress correcting paused running
|
||||
}
|
||||
}
|
||||
""",
|
||||
}
|
||||
@@ -41,16 +52,68 @@ MUTATIONS: dict[str, str] = {
|
||||
parityCheck { cancel }
|
||||
}
|
||||
""",
|
||||
"start_array": """
|
||||
mutation StartArray {
|
||||
array { setState(input: { desiredState: START }) {
|
||||
state capacity { kilobytes { free used total } }
|
||||
}}
|
||||
}
|
||||
""",
|
||||
"stop_array": """
|
||||
mutation StopArray {
|
||||
array { setState(input: { desiredState: STOP }) {
|
||||
state
|
||||
}}
|
||||
}
|
||||
""",
|
||||
"add_disk": """
|
||||
mutation AddDisk($id: PrefixedID!, $slot: Int) {
|
||||
array { addDiskToArray(input: { id: $id, slot: $slot }) {
|
||||
state disks { id name device type status }
|
||||
}}
|
||||
}
|
||||
""",
|
||||
"remove_disk": """
|
||||
mutation RemoveDisk($id: PrefixedID!) {
|
||||
array { removeDiskFromArray(input: { id: $id }) {
|
||||
state disks { id name device type }
|
||||
}}
|
||||
}
|
||||
""",
|
||||
"mount_disk": """
|
||||
mutation MountDisk($id: PrefixedID!) {
|
||||
array { mountArrayDisk(id: $id) { id name device status } }
|
||||
}
|
||||
""",
|
||||
"unmount_disk": """
|
||||
mutation UnmountDisk($id: PrefixedID!) {
|
||||
array { unmountArrayDisk(id: $id) { id name device status } }
|
||||
}
|
||||
""",
|
||||
"clear_disk_stats": """
|
||||
mutation ClearDiskStats($id: PrefixedID!) {
|
||||
array { clearArrayDiskStatistics(id: $id) }
|
||||
}
|
||||
""",
|
||||
}
|
||||
|
||||
DESTRUCTIVE_ACTIONS = {"remove_disk", "clear_disk_stats"}
|
||||
ALL_ACTIONS = set(QUERIES) | set(MUTATIONS)
|
||||
|
||||
ARRAY_ACTIONS = Literal[
|
||||
"parity_start",
|
||||
"add_disk",
|
||||
"clear_disk_stats",
|
||||
"mount_disk",
|
||||
"parity_cancel",
|
||||
"parity_history",
|
||||
"parity_pause",
|
||||
"parity_resume",
|
||||
"parity_cancel",
|
||||
"parity_start",
|
||||
"parity_status",
|
||||
"remove_disk",
|
||||
"start_array",
|
||||
"stop_array",
|
||||
"unmount_disk",
|
||||
]
|
||||
|
||||
if set(get_args(ARRAY_ACTIONS)) != ALL_ACTIONS:
|
||||
@@ -68,41 +131,86 @@ def register_array_tool(mcp: FastMCP) -> None:
|
||||
@mcp.tool()
|
||||
async def unraid_array(
|
||||
action: ARRAY_ACTIONS,
|
||||
ctx: Context | None = None,
|
||||
confirm: bool = False,
|
||||
correct: bool | None = None,
|
||||
disk_id: str | None = None,
|
||||
slot: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Manage Unraid array parity checks.
|
||||
"""Manage Unraid array: parity checks, array state, and disk operations.
|
||||
|
||||
Actions:
|
||||
parity_start - Start parity check (correct=True to fix errors, correct=False for read-only; required)
|
||||
parity_pause - Pause running parity check
|
||||
parity_resume - Resume paused parity check
|
||||
parity_cancel - Cancel running parity check
|
||||
parity_status - Get current parity check status
|
||||
Parity check actions:
|
||||
parity_start - Start parity check (correct=True to write fixes; required)
|
||||
parity_pause - Pause running parity check
|
||||
parity_resume - Resume paused parity check
|
||||
parity_cancel - Cancel running parity check
|
||||
parity_status - Get current parity check status and progress
|
||||
parity_history - Get parity check history log
|
||||
|
||||
Array state actions:
|
||||
start_array - Start the array (desiredState=START)
|
||||
stop_array - Stop the array (desiredState=STOP)
|
||||
|
||||
Disk operations (requires disk_id):
|
||||
add_disk - Add a disk to the array (requires disk_id; optional slot)
|
||||
remove_disk - Remove a disk from the array (requires disk_id, confirm=True; array must be stopped)
|
||||
mount_disk - Mount a disk (requires disk_id)
|
||||
unmount_disk - Unmount a disk (requires disk_id)
|
||||
clear_disk_stats - Clear I/O statistics for a disk (requires disk_id, confirm=True)
|
||||
"""
|
||||
if action not in ALL_ACTIONS:
|
||||
raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(ALL_ACTIONS)}")
|
||||
|
||||
if action in DESTRUCTIVE_ACTIONS and not confirm:
|
||||
desc_map = {
|
||||
"remove_disk": f"Remove disk **{disk_id}** from the array. The array must be stopped first.",
|
||||
"clear_disk_stats": f"Clear all I/O statistics for disk **{disk_id}**. This cannot be undone.",
|
||||
}
|
||||
confirmed = await elicit_destructive_confirmation(ctx, action, desc_map[action])
|
||||
if not confirmed:
|
||||
raise ToolError(
|
||||
f"Action '{action}' was not confirmed. "
|
||||
"Re-run with confirm=True to bypass elicitation."
|
||||
)
|
||||
|
||||
with tool_error_handler("array", action, logger):
|
||||
logger.info(f"Executing unraid_array action={action}")
|
||||
|
||||
# --- Queries ---
|
||||
if action in QUERIES:
|
||||
data = await make_graphql_request(QUERIES[action])
|
||||
return {"success": True, "action": action, "data": data}
|
||||
|
||||
query = MUTATIONS[action]
|
||||
variables: dict[str, Any] | None = None
|
||||
|
||||
# --- Mutations ---
|
||||
if action == "parity_start":
|
||||
if correct is None:
|
||||
raise ToolError("correct is required for 'parity_start' action")
|
||||
variables = {"correct": correct}
|
||||
data = await make_graphql_request(MUTATIONS[action], {"correct": correct})
|
||||
return {"success": True, "action": action, "data": data}
|
||||
|
||||
data = await make_graphql_request(query, variables)
|
||||
if action in ("parity_pause", "parity_resume", "parity_cancel"):
|
||||
data = await make_graphql_request(MUTATIONS[action])
|
||||
return {"success": True, "action": action, "data": data}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"action": action,
|
||||
"data": data,
|
||||
}
|
||||
if action in ("start_array", "stop_array"):
|
||||
data = await make_graphql_request(MUTATIONS[action])
|
||||
return {"success": True, "action": action, "data": data}
|
||||
|
||||
if action == "add_disk":
|
||||
if not disk_id:
|
||||
raise ToolError("disk_id is required for 'add_disk' action")
|
||||
variables: dict[str, Any] = {"id": disk_id}
|
||||
if slot is not None:
|
||||
variables["slot"] = slot
|
||||
data = await make_graphql_request(MUTATIONS[action], variables)
|
||||
return {"success": True, "action": action, "data": data}
|
||||
|
||||
if action in ("remove_disk", "mount_disk", "unmount_disk", "clear_disk_stats"):
|
||||
if not disk_id:
|
||||
raise ToolError(f"disk_id is required for '{action}' action")
|
||||
data = await make_graphql_request(MUTATIONS[action], {"id": disk_id})
|
||||
return {"success": True, "action": action, "data": data}
|
||||
|
||||
raise ToolError(f"Unhandled action '{action}' — this is a bug")
|
||||
|
||||
logger.info("Array tool registered successfully")
|
||||
|
||||
Reference in New Issue
Block a user