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:
Jacob Magar
2026-03-15 19:03:01 -04:00
parent 675a466d02
commit 3a72f6c6b9
3 changed files with 355 additions and 95 deletions

View File

@@ -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")