From 3a72f6c6b9b705dd366bc99dde1447d9dc14de22 Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Sun, 15 Mar 2026 19:03:01 -0400 Subject: [PATCH] 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. --- tests/safety/test_destructive_guards.py | 189 +++++++++++++++--------- tests/test_array.py | 107 +++++++++++++- unraid_mcp/tools/array.py | 154 ++++++++++++++++--- 3 files changed, 355 insertions(+), 95 deletions(-) diff --git a/tests/safety/test_destructive_guards.py b/tests/safety/test_destructive_guards.py index a27eaa8..1a77650 100644 --- a/tests/safety/test_destructive_guards.py +++ b/tests/safety/test_destructive_guards.py @@ -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 diff --git a/tests/test_array.py b/tests/test_array.py index d7d786f..80adbfe 100644 --- a/tests/test_array.py +++ b/tests/test_array.py @@ -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 diff --git a/unraid_mcp/tools/array.py b/unraid_mcp/tools/array.py index 12a863b..4cb0241 100644 --- a/unraid_mcp/tools/array.py +++ b/unraid_mcp/tools/array.py @@ -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")