diff --git a/README.md b/README.md index d0f46fa..cd12430 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This server does **not** implement SOVD itself. It provides MCP tools that call ## Features -- **Full ros2_medkit gateway coverage**: Discovery, component data, operations (services/actions), and configurations (ROS 2 parameters) +- **Full ros2_medkit gateway coverage**: Discovery, component data, operations (services/actions), configurations (ROS 2 parameters), and entity lifecycle status (apps/components) - **Dual transport support**: stdio and streamable-http - **Async HTTP client** using httpx - **Pydantic validation** for configuration and models @@ -382,6 +382,38 @@ Reset all configurations (parameters) to their default values. **Returns:** Response from `DELETE /components/{component_id}/configurations` +### Lifecycle Tools + +Lifecycle status is only available for apps and components (not areas or functions). + +Reading the status needs no plugin - the gateway derives it from the ROS 2 graph and, +for managed lifecycle nodes, from their reported state. Triggering a transition does need +one: the gateway routes it to a `LifecycleProvider` plugin registered for that entity, and +no provider ships with the gateway. A transition on a local app or component of a gateway +with no provider therefore gets HTTP `501` from the gateway, which this server surfaces as +`[not-implemented] Lifecycle control not available for this entity`. An aggregating +gateway forwards requests for remote entities to the peer that owns them, so a peer that +does have a provider answers normally. + +#### `ros2_medkit_status_get` +Get the lifecycle status of an app or component (e.g. `ready` / `notReady`). + +**Arguments:** +- `entity_type` (required, string): `apps` or `components` +- `entity_id` (required, string): The entity identifier + +**Returns:** Response from `GET /{entity_type}/{entity_id}/status` + +#### `ros2_medkit_status_set` +Trigger a lifecycle transition on an app or component via `PUT /{entity_type}/{entity_id}/status/{action}`. Requires a gateway-side `LifecycleProvider` plugin for the entity. **Warning:** `shutdown`, `force-shutdown`, `restart`, and `force-restart` affect the running node or host process. + +**Arguments:** +- `entity_type` (required, string): `apps` or `components` +- `entity_id` (required, string): The entity identifier +- `action` (required, string): one of `start`, `restart`, `force-restart`, `shutdown`, `force-shutdown` + +**Returns:** `{}` - the gateway accepts the transition with a body-less `202`, which the tool renders as an empty JSON object. With no provider registered for the entity the call returns the gateway error instead: `[not-implemented] Lifecycle control not available for this entity`. + ## MCP Resources ### `sovd://openapi` diff --git a/src/ros2_medkit_mcp/client.py b/src/ros2_medkit_mcp/client.py index d8e594d..657cd22 100644 --- a/src/ros2_medkit_mcp/client.py +++ b/src/ros2_medkit_mcp/client.py @@ -15,6 +15,7 @@ import sys from collections.abc import AsyncIterator from contextlib import asynccontextmanager, suppress +from contextvars import ContextVar from typing import Any from urllib.parse import quote @@ -26,6 +27,7 @@ data, discovery, faults, + lifecycle, locking, logs, operations, @@ -102,6 +104,19 @@ def _fault_query_params( return params +# Status of the most recent generated-client response on this task. The generated +# parsers build their error models inside the API function, so a documented error +# status (400/404/500) carrying a non-JSON body - a proxy error page, say - raises +# out of the call before the caller ever sees the response. This is how the status +# survives that. A ContextVar rather than an attribute so concurrent tool calls do +# not read each other's status. +_last_response_status: ContextVar[int | None] = ContextVar("_last_response_status", default=None) + + +async def _record_response_status(response: httpx.Response) -> None: + _last_response_status.set(response.status_code) + + def _error_from_content(status_code: int, content: bytes) -> str: """Build an error message from an HTTP status and a raw response body. @@ -457,8 +472,43 @@ def _validate_relative_uri(uri: str) -> None: "functions": subscriptions.delete_function_subscription, }, }, + # Lifecycle is exposed only for apps and components (no areas/functions). + # Action keys use the hyphenated SOVD action names (force-restart, + # force-shutdown); the generated modules use underscores. + "lifecycle": { + "get": { + "components": lifecycle.get_components_status, + "apps": lifecycle.get_apps_status, + }, + "start": { + "components": lifecycle.put_components_status_start, + "apps": lifecycle.put_apps_status_start, + }, + "restart": { + "components": lifecycle.put_components_status_restart, + "apps": lifecycle.put_apps_status_restart, + }, + "force-restart": { + "components": lifecycle.put_components_status_force_restart, + "apps": lifecycle.put_apps_status_force_restart, + }, + "shutdown": { + "components": lifecycle.put_components_status_shutdown, + "apps": lifecycle.put_apps_status_shutdown, + }, + "force-shutdown": { + "components": lifecycle.put_components_status_force_shutdown, + "apps": lifecycle.put_apps_status_force_shutdown, + }, + }, } +# Lifecycle is exposed only for apps and components. +_LIFECYCLE_ENTITY_TYPES = frozenset({"apps", "components"}) + +# Valid lifecycle transition actions (hyphenated SOVD action names). +_LIFECYCLE_ACTIONS = frozenset({"start", "restart", "force-restart", "shutdown", "force-shutdown"}) + # Validate all function references at import time for _resource, _methods in _ENTITY_FUNC_MAP.items(): @@ -515,6 +565,8 @@ async def _ensure_client(self) -> MedkitClient: ) await self._medkit.__aenter__() self._entered = True + hooks = self._medkit.http.get_async_httpx_client().event_hooks + hooks.setdefault("response", []).append(_record_response_status) return self._medkit async def _httpx_client(self) -> httpx.AsyncClient: @@ -564,15 +616,20 @@ async def _call_void(self, api_func: Any, **kwargs: Any) -> dict[str, Any]: success off `parsed is None` would report success on those errors - a silent false-success on destructive operations. Uses the ``_detailed`` variant so the real status code is available; only 2xx is success. + + A redirect counts as a failure, not a success: no endpoint documents a + 3xx, redirects are not followed, and a proxy in front of the gateway + answering a destructive PUT with a 302 must not read as accepted. """ if "body" in kwargs and isinstance(kwargs["body"], dict): kwargs["body"] = _wrap_body_dict(api_func, kwargs["body"]) client = await self._ensure_client() detailed = sys.modules[api_func.__module__].asyncio_detailed + _last_response_status.set(None) try: response = await detailed(client=client.http, **kwargs) status = int(response.status_code) - if status >= 400: + if not 200 <= status < 300: raise SovdClientError( message=_error_from_content(status, response.content), status_code=status, @@ -585,6 +642,12 @@ async def _call_void(self, api_func: Any, **kwargs: Any) -> dict[str, Any]: except httpx.RequestError as e: raise SovdClientError(message=f"Request failed: {e}") from e except (ValueError, KeyError) as e: + recorded = _last_response_status.get() + if recorded is not None and not 200 <= recorded < 300: + raise SovdClientError( + message=(f"Gateway returned HTTP {recorded}: response body was not valid JSON"), + status_code=recorded, + ) from e raise SovdClientError(message=f"Failed to parse response: {e}") from e async def _raw_request(self, method: str, path: str) -> Any: @@ -1440,6 +1503,48 @@ async def _call_update_action(self, api_func: Any, **kwargs: Any) -> dict[str, A async def delete_update(self, update_id: str) -> dict[str, Any]: return await self._call_void(updates.delete_update.asyncio, update_id=update_id) + # ==================== Lifecycle ==================== + + async def get_status(self, entity_type: str, entity_id: str) -> dict[str, Any]: + """Get the lifecycle status of an app or component. + + Lifecycle is exposed only for ``apps`` and ``components``; any other + entity_type raises SovdClientError. + """ + if entity_type not in _LIFECYCLE_ENTITY_TYPES: + raise SovdClientError( + message=( + f"Lifecycle status is only available for apps and components, " + f"not '{entity_type}'" + ) + ) + fn = _entity_func("lifecycle", "get", entity_type) + return await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id}) + + async def set_status(self, entity_type: str, entity_id: str, action: str) -> dict[str, Any]: + """Trigger a lifecycle transition on an app or component. + + ``action`` is one of start, restart, force-restart, shutdown, + force-shutdown. The transition PUTs are body-less and return 202. + Lifecycle is exposed only for ``apps`` and ``components``. + """ + if entity_type not in _LIFECYCLE_ENTITY_TYPES: + raise SovdClientError( + message=( + f"Lifecycle transitions are only available for apps and " + f"components, not '{entity_type}'" + ) + ) + if action not in _LIFECYCLE_ACTIONS: + raise SovdClientError( + message=( + f"Unknown lifecycle action '{action}'; expected one of " + f"{', '.join(sorted(_LIFECYCLE_ACTIONS))}" + ) + ) + fn = _entity_func("lifecycle", action, entity_type) + return await self._call_void(fn, **{_entity_id_kwarg(entity_type): entity_id}) + @asynccontextmanager async def create_client(settings: Settings) -> AsyncIterator[SovdClient]: diff --git a/src/ros2_medkit_mcp/mcp_app.py b/src/ros2_medkit_mcp/mcp_app.py index 0911d66..3b72818 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -82,6 +82,8 @@ RosbagSnapshot, SetConfigurationArgs, SetLogConfigurationArgs, + StatusGetArgs, + StatusSetArgs, SubareasArgs, SubcomponentsArgs, SystemFaultSnapshotsArgs, @@ -690,6 +692,8 @@ async def download_rosbags_for_fault( "ros2_medkit_execute_update": "ros2_medkit_execute_update", "ros2_medkit_automate_update": "ros2_medkit_automate_update", "ros2_medkit_delete_update": "ros2_medkit_delete_update", + "ros2_medkit_status_get": "ros2_medkit_status_get", + "ros2_medkit_status_set": "ros2_medkit_status_set", # Legacy sovd_* aliases (backwards compatibility) "sovd_version": "ros2_medkit_version", "sovd_health": "ros2_medkit_health", @@ -775,6 +779,8 @@ async def download_rosbags_for_fault( "sovd_execute_update": "ros2_medkit_execute_update", "sovd_automate_update": "ros2_medkit_automate_update", "sovd_delete_update": "ros2_medkit_delete_update", + "sovd_status_get": "ros2_medkit_status_get", + "sovd_status_set": "ros2_medkit_status_set", # Dot-notation aliases (legacy) "sovd.version": "ros2_medkit_version", "sovd.entities.list": "ros2_medkit_entities_list", @@ -2601,6 +2607,72 @@ async def list_tools() -> list[Tool]: "required": ["update_id"], }, ), + Tool( + name="ros2_medkit_status_get", + description=( + "Get the lifecycle status of an app or component" + " (e.g. ready / notReady). Lifecycle is only available for" + " apps and components." + ), + inputSchema={ + "type": "object", + "properties": { + "entity_type": { + "type": "string", + "enum": ["apps", "components"], + "description": "Entity type: 'apps' or 'components'", + }, + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + }, + "required": ["entity_type", "entity_id"], + }, + ), + Tool( + name="ros2_medkit_status_set", + description=( + "Trigger a lifecycle transition on an app or component." + " WARNING: shutdown/force-shutdown/restart/force-restart" + " affect the running node or host process. Lifecycle is only" + " available for apps and components. Requires a gateway-side" + " LifecycleProvider plugin for the entity; there is no" + " built-in provider, so a gateway without one answers the" + " transition with 'not-implemented' while status_get still" + " works. On success the gateway returns a body-less 202 and" + " this tool returns an empty JSON object." + ), + inputSchema={ + "type": "object", + "properties": { + "entity_type": { + "type": "string", + "enum": ["apps", "components"], + "description": "Entity type: 'apps' or 'components'", + }, + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "action": { + "type": "string", + "enum": [ + "start", + "restart", + "force-restart", + "shutdown", + "force-shutdown", + ], + "description": ( + "Lifecycle transition: 'start', 'restart'," + " 'force-restart', 'shutdown', or 'force-shutdown'" + ), + }, + }, + "required": ["entity_type", "entity_id", "action"], + }, + ), ] # Append plugin tools if plugins: @@ -3187,6 +3259,24 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: result = await client.delete_update(args.update_id) return format_json_response(result) + # ==================== Lifecycle ==================== + + elif normalized_name == "ros2_medkit_status_get": + status_get_args = StatusGetArgs(**arguments) + result = await client.get_status( + status_get_args.entity_type.value, status_get_args.entity_id + ) + return format_json_response(result) + + elif normalized_name == "ros2_medkit_status_set": + status_set_args = StatusSetArgs(**arguments) + result = await client.set_status( + status_set_args.entity_type.value, + status_set_args.entity_id, + status_set_args.action.value, + ) + return format_json_response(result) + else: # Check plugin tool map before reporting unknown tool plugin = plugin_tool_map.get(normalized_name) diff --git a/src/ros2_medkit_mcp/models.py b/src/ros2_medkit_mcp/models.py index 5144e89..072c7c6 100644 --- a/src/ros2_medkit_mcp/models.py +++ b/src/ros2_medkit_mcp/models.py @@ -4,7 +4,7 @@ They validate input arguments while allowing flexible output from the API. """ -from enum import Enum +from enum import StrEnum from typing import Any from pydantic import BaseModel, Field @@ -443,7 +443,7 @@ class SetConfigurationArgs(BaseModel): # ==================== Fault Response Models ==================== -class FaultStatus(str, Enum): +class FaultStatus(StrEnum): """Fault status values per SOVD specification.""" PENDING = "PENDING" @@ -1190,6 +1190,57 @@ class AutomateUpdateArgs(BaseModel): update_id: str = Field(..., description="The update identifier") +# ==================== Lifecycle Argument Models ==================== + + +class LifecycleEntityType(StrEnum): + """Entity types that support the lifecycle status API. + + The gateway exposes lifecycle only for apps and components (not areas + or functions). + """ + + APPS = "apps" + COMPONENTS = "components" + + +class LifecycleAction(StrEnum): + """Lifecycle transition actions (hyphenated SOVD action names).""" + + START = "start" + RESTART = "restart" + FORCE_RESTART = "force-restart" + SHUTDOWN = "shutdown" + FORCE_SHUTDOWN = "force-shutdown" + + +class StatusGetArgs(BaseModel): + """Arguments for ros2_medkit_status_get tool.""" + + entity_type: LifecycleEntityType = Field( + ..., + description="Entity type: 'apps' or 'components'", + ) + entity_id: str = Field(..., description="The entity identifier") + + +class StatusSetArgs(BaseModel): + """Arguments for ros2_medkit_status_set tool.""" + + entity_type: LifecycleEntityType = Field( + ..., + description="Entity type: 'apps' or 'components'", + ) + entity_id: str = Field(..., description="The entity identifier") + action: LifecycleAction = Field( + ..., + description=( + "Lifecycle transition: 'start', 'restart', 'force-restart', " + "'shutdown', or 'force-shutdown'" + ), + ) + + class ToolResult(BaseModel): """Standard result wrapper for tool responses.""" diff --git a/tests/test_mcp_app.py b/tests/test_mcp_app.py index 1eb95cd..5f6c998 100644 --- a/tests/test_mcp_app.py +++ b/tests/test_mcp_app.py @@ -1,17 +1,36 @@ """Tests for MCP app call_tool dispatcher.""" +import json + import httpx import pytest import respx -from mcp.types import TextContent +from mcp.server import Server +from mcp.types import ( + CallToolRequest, + CallToolRequestParams, + CallToolResult, + ListToolsRequest, + TextContent, +) +from pydantic import ValidationError from ros2_medkit_mcp.client import SovdClient, SovdClientError from ros2_medkit_mcp.config import Settings -from ros2_medkit_mcp.mcp_app import TOOL_ALIASES, format_error, format_json_response +from ros2_medkit_mcp.mcp_app import ( + TOOL_ALIASES, + format_error, + format_json_response, + register_tools, +) from ros2_medkit_mcp.models import ( EntitiesListArgs, FaultsListArgs, + LifecycleAction, + LifecycleEntityType, ListOperationsArgs, + StatusGetArgs, + StatusSetArgs, filter_entities, ) @@ -51,6 +70,13 @@ def test_legacy_sovd_aliases(self) -> None: assert TOOL_ALIASES.get("sovd_version") == "ros2_medkit_version" assert TOOL_ALIASES.get("sovd_entities_list") == "ros2_medkit_entities_list" + def test_lifecycle_aliases(self) -> None: + """Test lifecycle status tool aliases resolve to canonical names.""" + assert TOOL_ALIASES.get("ros2_medkit_status_get") == "ros2_medkit_status_get" + assert TOOL_ALIASES.get("ros2_medkit_status_set") == "ros2_medkit_status_set" + assert TOOL_ALIASES.get("sovd_status_get") == "ros2_medkit_status_get" + assert TOOL_ALIASES.get("sovd_status_set") == "ros2_medkit_status_set" + class TestFormatFunctions: """Tests for response formatting functions.""" @@ -303,3 +329,187 @@ def test_entities_list_args_optional_filter(self) -> None: args_with_filter = EntitiesListArgs(filter="test") assert args_with_filter.filter == "test" + + def test_status_get_args_valid(self) -> None: + """Test StatusGetArgs accepts apps and components entity types.""" + args = StatusGetArgs(entity_type="apps", entity_id="motor") + assert args.entity_type is LifecycleEntityType.APPS + assert args.entity_id == "motor" + + args = StatusGetArgs(entity_type="components", entity_id="ecu") + assert args.entity_type is LifecycleEntityType.COMPONENTS + + def test_status_get_args_rejects_invalid_entity_type(self) -> None: + """Test StatusGetArgs rejects entity types without lifecycle support.""" + with pytest.raises(ValidationError): + StatusGetArgs(entity_type="areas", entity_id="powertrain") + + def test_status_set_args_valid_actions(self) -> None: + """Test StatusSetArgs accepts all five lifecycle transitions.""" + for action, expected in ( + ("start", LifecycleAction.START), + ("restart", LifecycleAction.RESTART), + ("force-restart", LifecycleAction.FORCE_RESTART), + ("shutdown", LifecycleAction.SHUTDOWN), + ("force-shutdown", LifecycleAction.FORCE_SHUTDOWN), + ): + args = StatusSetArgs(entity_type="apps", entity_id="motor", action=action) + assert args.action is expected + + def test_status_set_args_rejects_invalid_action(self) -> None: + """Test StatusSetArgs rejects unknown actions.""" + with pytest.raises(ValidationError): + StatusSetArgs(entity_type="apps", entity_id="motor", action="bogus") + + +async def _call_registered_tool( + client: SovdClient, name: str, arguments: dict[str, object] +) -> CallToolResult: + """Dispatch a tool through the registered MCP handler and return the full result. + + The whole result is returned, not just its text, so callers can assert on + isError: an error the handler renders as a JSON envelope still reaches the + model as a non-error tool result, and a test that reads only the text cannot + tell the two apart. + """ + server: Server = Server("test") + register_tools(server, client) + request = CallToolRequest( + method="tools/call", + params=CallToolRequestParams(name=name, arguments=arguments), + ) + result = await server.request_handlers[CallToolRequest](request) + return result.root + + +def _tool_text(result: CallToolResult) -> str: + content = result.content[0] + assert isinstance(content, TextContent) + return content.text + + +class TestLifecycleToolDispatch: + """Tests that drive the registered MCP handlers, not the client directly. + + The client-level tests cannot see the tool registry or the dispatch chain in + call_tool, so a tool that is never registered - or whose dispatch branch names + the wrong client method - passes all of them. + """ + + async def test_lifecycle_tools_are_registered(self, client: SovdClient) -> None: + server: Server = Server("test") + register_tools(server, client) + listed = await server.request_handlers[ListToolsRequest]( + ListToolsRequest(method="tools/list") + ) + tools = {tool.name: tool for tool in listed.root.tools} + + assert "ros2_medkit_status_get" in tools + assert "ros2_medkit_status_set" in tools + assert tools["ros2_medkit_status_get"].inputSchema["required"] == [ + "entity_type", + "entity_id", + ] + for tool_name in ("ros2_medkit_status_get", "ros2_medkit_status_set"): + schema = tools[tool_name].inputSchema + assert schema["properties"]["entity_type"]["enum"] == ["apps", "components"] + assert schema["properties"]["entity_id"]["type"] == "string" + assert tools["ros2_medkit_status_set"].inputSchema["properties"]["action"]["enum"] == [ + "start", + "restart", + "force-restart", + "shutdown", + "force-shutdown", + ] + assert tools["ros2_medkit_status_set"].inputSchema["required"] == [ + "entity_type", + "entity_id", + "action", + ] + await client.close() + + async def test_status_set_description_mentions_provider_requirement( + self, client: SovdClient + ) -> None: + # A stock gateway rejects every transition, so the model needs to read that + # off the tool description rather than infer it from a failed call. + server: Server = Server("test") + register_tools(server, client) + listed = await server.request_handlers[ListToolsRequest]( + ListToolsRequest(method="tools/list") + ) + description = next( + tool.description or "" + for tool in listed.root.tools + if tool.name == "ros2_medkit_status_set" + ) + assert "Requires a gateway-side LifecycleProvider plugin for the entity" in description + assert "there is no built-in provider" in description + assert "not-implemented" in description + assert "status_get still" in description + await client.close() + + @respx.mock + async def test_status_get_dispatch(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/apps/motor/status").mock( + return_value=httpx.Response(200, json={"status": "ready"}) + ) + result = await _call_registered_tool( + client, "ros2_medkit_status_get", {"entity_type": "apps", "entity_id": "motor"} + ) + assert result.isError is False + assert json.loads(_tool_text(result)) == {"status": "ready"} + await client.close() + + @respx.mock + async def test_status_set_dispatch(self, client: SovdClient) -> None: + route = respx.put("http://test-sovd:8080/api/v1/apps/motor/status/restart").mock( + return_value=httpx.Response(202) + ) + result = await _call_registered_tool( + client, + "ros2_medkit_status_set", + {"entity_type": "apps", "entity_id": "motor", "action": "restart"}, + ) + assert route.call_count == 1 + assert route.calls.last.request.read() == b"" + assert result.isError is False + assert json.loads(_tool_text(result)) == {} + await client.close() + + @respx.mock + async def test_status_set_dispatch_reports_missing_provider(self, client: SovdClient) -> None: + respx.put("http://test-sovd:8080/api/v1/apps/motor/status/shutdown").mock( + return_value=httpx.Response( + 501, + json={ + "error_code": "not-implemented", + "message": "Lifecycle control not available for this entity", + }, + ) + ) + result = await _call_registered_tool( + client, + "ros2_medkit_status_set", + {"entity_type": "apps", "entity_id": "motor", "action": "shutdown"}, + ) + assert json.loads(_tool_text(result)) == { + "success": False, + "data": None, + "error": "[not-implemented] Lifecycle control not available for this entity", + } + await client.close() + + @respx.mock + async def test_sovd_alias_dispatches_to_canonical_tool(self, client: SovdClient) -> None: + route = respx.put("http://test-sovd:8080/api/v1/components/ecu/status/start").mock( + return_value=httpx.Response(202) + ) + result = await _call_registered_tool( + client, + "sovd_status_set", + {"entity_type": "components", "entity_id": "ecu", "action": "start"}, + ) + assert route.call_count == 1 + assert json.loads(_tool_text(result)) == {} + await client.close() diff --git a/tests/test_new_tools.py b/tests/test_new_tools.py index 57a1233..d30f5dd 100644 --- a/tests/test_new_tools.py +++ b/tests/test_new_tools.py @@ -4,7 +4,7 @@ import pytest import respx -from ros2_medkit_mcp.client import SovdClient +from ros2_medkit_mcp.client import SovdClient, SovdClientError from ros2_medkit_mcp.config import Settings from ros2_medkit_mcp.mcp_app import format_json_response @@ -516,6 +516,177 @@ async def test_automate_update(self, client: SovdClient) -> None: await client.close() +class TestLifecycleTools: + """Tests for entity lifecycle status tools (apps and components only). + + The gateway 0.6.0 lifecycle API exposes ``GET /{et}/{id}/status`` (returns a + LifecycleStatusResponse with a required ``status`` enum) and + ``PUT /{et}/{id}/status/{action}`` transitions (202 No Content) for + et in {apps, components}. The action path uses hyphens (force-restart, + force-shutdown). + """ + + STATUS_RESPONSE = { + "status": "ready", + "start": "/apps/motor/status/start", + "restart": "/apps/motor/status/restart", + "force-restart": "/apps/motor/status/force-restart", + "shutdown": "/apps/motor/status/shutdown", + "force-shutdown": "/apps/motor/status/force-shutdown", + } + + @respx.mock + async def test_get_status_apps(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/apps/motor/status").mock( + return_value=httpx.Response(200, json=self.STATUS_RESPONSE) + ) + result = await client.get_status("apps", "motor") + assert result["status"] == "ready" + await client.close() + + @respx.mock + async def test_get_status_components(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/ecu/status").mock( + return_value=httpx.Response(200, json={"status": "notReady"}) + ) + result = await client.get_status("components", "ecu") + assert result["status"] == "notReady" + await client.close() + + async def test_get_status_invalid_entity_type(self, client: SovdClient) -> None: + with pytest.raises(SovdClientError): + await client.get_status("areas", "powertrain") + await client.close() + + # Every entity_type x action pair the client can route, so a wrong entry in + # _ENTITY_FUNC_MAP shows up as a request to the wrong URL rather than passing + # because a sibling action happens to be mapped correctly. + @pytest.mark.parametrize("entity_type,entity_id", [("apps", "motor"), ("components", "ecu")]) + @pytest.mark.parametrize( + "action", + ["start", "restart", "force-restart", "shutdown", "force-shutdown"], + ) + @respx.mock + async def test_set_status_routes_every_action( + self, client: SovdClient, entity_type: str, entity_id: str, action: str + ) -> None: + # The action segment stays hyphenated on the wire even though the generated + # module name uses an underscore (put_apps_status_force_restart). + route = respx.put( + f"http://test-sovd:8080/api/v1/{entity_type}/{entity_id}/status/{action}" + ).mock(return_value=httpx.Response(202)) + result = await client.set_status(entity_type, entity_id, action) + assert result == {} + assert route.call_count == 1 + await client.close() + + @pytest.mark.parametrize("entity_type,entity_id", [("apps", "motor"), ("components", "ecu")]) + @respx.mock + async def test_get_status_routes_every_entity_type( + self, client: SovdClient, entity_type: str, entity_id: str + ) -> None: + route = respx.get(f"http://test-sovd:8080/api/v1/{entity_type}/{entity_id}/status").mock( + return_value=httpx.Response(200, json={"status": "ready"}) + ) + result = await client.get_status(entity_type, entity_id) + assert result["status"] == "ready" + assert route.called + await client.close() + + @pytest.mark.parametrize("entity_type,entity_id", [("apps", "motor"), ("components", "ecu")]) + @pytest.mark.parametrize( + "action", + ["start", "restart", "force-restart", "shutdown", "force-shutdown"], + ) + @respx.mock + async def test_set_status_surfaces_missing_lifecycle_provider( + self, client: SovdClient, entity_type: str, entity_id: str, action: str + ) -> None: + # A gateway with no LifecycleProvider plugin answers every transition with + # 501, a status the generated parser does not enumerate. Keying success off + # a None parsed body would render that as an empty success object, so a + # destructive call would report as accepted while nothing happened. The + # message is asserted in full because the README quotes it verbatim. + respx.put(f"http://test-sovd:8080/api/v1/{entity_type}/{entity_id}/status/{action}").mock( + return_value=httpx.Response( + 501, + json={ + "error_code": "not-implemented", + "message": "Lifecycle control not available for this entity", + }, + ) + ) + with pytest.raises(SovdClientError) as excinfo: + await client.set_status(entity_type, entity_id, action) + assert excinfo.value.status_code == 501 + assert str(excinfo.value) == ( + "[not-implemented] Lifecycle control not available for this entity" + ) + await client.close() + + @pytest.mark.parametrize("status", [200, 202, 204]) + @respx.mock + async def test_set_status_accepts_any_2xx(self, client: SovdClient, status: int) -> None: + respx.put("http://test-sovd:8080/api/v1/apps/motor/status/shutdown").mock( + return_value=httpx.Response(status) + ) + assert await client.set_status("apps", "motor", "shutdown") == {} + await client.close() + + @pytest.mark.parametrize("status", [400, 404, 500]) + @respx.mock + async def test_set_status_keeps_status_when_error_body_is_not_json( + self, client: SovdClient, status: int + ) -> None: + # The generated parser builds its error model inside the API call, so a + # documented error status carrying a proxy's HTML page raises out of the + # call before the response is returned. The status must survive that: + # without it the caller cannot tell a rejected transition from a bug. + respx.put("http://test-sovd:8080/api/v1/apps/motor/status/shutdown").mock( + return_value=httpx.Response(status, html="Bad Gateway") + ) + with pytest.raises(SovdClientError) as excinfo: + await client.set_status("apps", "motor", "shutdown") + assert excinfo.value.status_code == status + assert str(status) in str(excinfo.value) + await client.close() + + @pytest.mark.parametrize("status", [300, 302, 308]) + @respx.mock + async def test_set_status_rejects_redirects(self, client: SovdClient, status: int) -> None: + # No endpoint documents a 3xx and redirects are not followed, so the parsed + # body is None just as it is for a body-less 202. A proxy in front of the + # gateway answering a destructive PUT with a redirect must not read as + # accepted. + respx.put("http://test-sovd:8080/api/v1/apps/motor/status/shutdown").mock( + return_value=httpx.Response(status, headers={"Location": "/elsewhere"}) + ) + with pytest.raises(SovdClientError) as excinfo: + await client.set_status("apps", "motor", "shutdown") + assert excinfo.value.status_code == status + await client.close() + + async def test_set_status_invalid_entity_type(self, client: SovdClient) -> None: + with pytest.raises(SovdClientError): + await client.set_status("functions", "navigation", "start") + await client.close() + + async def test_set_status_invalid_action(self, client: SovdClient) -> None: + with pytest.raises(SovdClientError): + await client.set_status("apps", "motor", "bogus") + await client.close() + + @respx.mock + async def test_get_status_renders_as_json(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/apps/motor/status").mock( + return_value=httpx.Response(200, json=self.STATUS_RESPONSE) + ) + result = await client.get_status("apps", "motor") + formatted = format_json_response(result) + assert "ready" in formatted[0].text + await client.close() + + class TestDataDiscoveryTools: """Tests for data discovery tools (categories and groups)."""