diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fe7709..fe3d395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.14.1] - 2026-10-30 + +### Fixed + +- **Unraid API 4.37.8+ nullable `docker`/`vms` roots** — When the Docker or VM service fails, these servers return `{"docker": null}` with errors ([unraid/api#2123](https://github.com/unraid/api/pull/2123)). `query()` now raises `UnraidAPIError` (carrying the GraphQL errors) when every returned root is null and errors are present, and the container, VM, network, update-status, port-conflict, log and organizer readers no longer fail with `AttributeError`. They never return an empty list for a failed service ([#135](https://github.com/tinkerologie/unraid-api/issues/135)). +- **Introspection disabled on Unraid API 4.37.8+** — These servers refuse schema introspection with `INTROSPECTION_DISABLED` ([unraid/api#2095](https://github.com/unraid/api/pull/2095), [#2129](https://github.com/unraid/api/pull/2129)). `get_capabilities()` now recognises that code and uses `ServerCapabilities.introspection_disabled()`: every current field, minus `RETIRED_FIELDS` (direct array disk removal). VM UUIDs are requested again, and `remove_array_disk()` refuses without a request. Other introspection failures still fall back to `permissive()` ([#135](https://github.com/tinkerologie/unraid-api/issues/135)). +- **GraphQL errors on HTTP 4xx** — A 4xx response with a GraphQL `errors` body raises `UnraidAPIError("GraphQL request rejected (HTTP )")` with those errors, so their messages and `extensions.code` are kept instead of a bare "HTTP error 400" ([#135](https://github.com/tinkerologie/unraid-api/issues/135)). + ## [1.14.0] - 2026-10-24 ### Added @@ -535,7 +543,9 @@ Return dict/list for flexible access: - `PhysicalDisk` - `Notification`, `NotificationOverview` -[Unreleased]: https://github.com/tinkerologie/unraid-api/compare/v1.13.2...HEAD +[Unreleased]: https://github.com/tinkerologie/unraid-api/compare/v1.14.1...HEAD +[1.14.1]: https://github.com/tinkerologie/unraid-api/compare/v1.14.0...v1.14.1 +[1.14.0]: https://github.com/tinkerologie/unraid-api/compare/v1.13.2...v1.14.0 [1.13.2]: https://github.com/tinkerologie/unraid-api/compare/v1.13.1...v1.13.2 [1.8.0]: https://github.com/tinkerologie/unraid-api/compare/v1.7.1...v1.8.0 [1.7.1]: https://github.com/tinkerologie/unraid-api/compare/v1.7.0...v1.7.1 diff --git a/README.md b/README.md index 39bf153..14a0c0a 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,13 @@ the fallback. If introspection fails, permissive capabilities optimistically attempt native restart. `typed_get_vms()` requests `uuid` only when introspection confirms support; unknown schemas omit it to preserve VM listing. +Unraid API 4.37.8 and later refuse schema introspection unless the developer +sandbox is enabled. The client recognises the `INTROSPECTION_DISABLED` response +and treats the server as current: every field is available except those in +`RETIRED_FIELDS`, so VM UUIDs are still requested. On these servers a failed +Docker or VM service raises `UnraidAPIError`; the readers never return an empty +list for it. + Plugin and language installs modify the server. Follow their returned operation with `subscribe_plugin_install_updates(operation.id)`. `remove_array_disk()` emits `DeprecationWarning` and rejects servers without the retired mutation; diff --git a/src/unraid_api/_version.py b/src/unraid_api/_version.py index 93c9f52..da67f2a 100644 --- a/src/unraid_api/_version.py +++ b/src/unraid_api/_version.py @@ -1,3 +1,3 @@ """Version information for unraid-api.""" -__version__ = "1.14.0" +__version__ = "1.14.1" diff --git a/src/unraid_api/capabilities.py b/src/unraid_api/capabilities.py index 34432bc..ac0b2b6 100644 --- a/src/unraid_api/capabilities.py +++ b/src/unraid_api/capabilities.py @@ -35,6 +35,16 @@ ) +# Fields removed upstream. A server that refuses introspection (Unraid API +# 4.37.8+) is newer than every removal listed here. +RETIRED_FIELDS: frozenset[str] = frozenset( + { + # Direct array disk removal, retired after v4.36.0 (unraid/api#2068). + "ArrayMutations.removeDiskFromArray", + } +) + + def build_introspection_query() -> str: """Single aliased __type query covering every capability-relevant type.""" fragments = [] @@ -51,7 +61,7 @@ def build_introspection_query() -> str: class ServerCapabilities: """Typed view over which GraphQL fields a specific server exposes.""" - __slots__ = ("_fields", "_permissive") + __slots__ = ("_current", "_fields", "_permissive") def __init__( self, @@ -61,6 +71,7 @@ def __init__( ) -> None: self._fields: dict[str, frozenset[str]] = dict(fields) self._permissive = permissive + self._current = False @property def is_permissive(self) -> bool: @@ -71,6 +82,18 @@ def permissive(cls) -> ServerCapabilities: """Fallback when introspection fails — assume every feature is present.""" return cls({}, permissive=True) + @classmethod + def introspection_disabled(cls) -> ServerCapabilities: + """Capabilities of a server that refuses introspection (API 4.37.8+). + + Such a server has every field this client knows except those retired + upstream. Unlike `permissive()` it is a known, current server, so + optional fields such as `VmDomain.uuid` are requested. + """ + caps = cls({}) + caps._current = True + return caps + @classmethod def from_introspection_response( cls, response: Mapping[str, Any] @@ -90,6 +113,8 @@ def has(self, path: str) -> bool: """Check whether a dotted path (`TypeName.fieldName`) exists on the server.""" if self._permissive: return True + if self._current: + return path not in RETIRED_FIELDS if "." not in path: raise ValueError( f"Capability path must be dotted (TypeName.fieldName), got {path!r}" diff --git a/src/unraid_api/client.py b/src/unraid_api/client.py index 2ffa324..f4da6d1 100644 --- a/src/unraid_api/client.py +++ b/src/unraid_api/client.py @@ -83,12 +83,24 @@ # HTTP status codes HTTP_OK = 200 +HTTP_BAD_REQUEST = 400 HTTP_UNAUTHORIZED = 401 HTTP_FORBIDDEN = 403 +HTTP_SERVER_ERROR = 500 DEFAULT_HTTP_PORT = 80 DEFAULT_HTTPS_PORT = 443 +def _is_introspection_disabled(err: UnraidAPIError) -> bool: + """Return whether the server refused a request as schema introspection.""" + return any( + isinstance(e, dict) + and isinstance(e.get("extensions"), dict) + and e["extensions"].get("code") == "INTROSPECTION_DISABLED" + for e in err.errors + ) + + class UnraidClient: """Async client for interacting with Unraid GraphQL API. @@ -678,6 +690,14 @@ async def _make_request( allow_redirects=False, **request_options, ) as redirect_response: + if ( + HTTP_BAD_REQUEST + <= redirect_response.status + < HTTP_SERVER_ERROR + ): + await self._raise_for_graphql_rejection( + redirect_response + ) redirect_response.raise_for_status() result: dict[str, Any] = await redirect_response.json() return result @@ -690,6 +710,8 @@ async def _make_request( "Invalid API key or insufficient permissions" ) + if HTTP_BAD_REQUEST <= response.status < HTTP_SERVER_ERROR: + await self._raise_for_graphql_rejection(response) response.raise_for_status() json_result: dict[str, Any] = await response.json() return json_result @@ -712,6 +734,27 @@ async def _make_request( except aiohttp.ClientError as err: raise UnraidConnectionError(f"Connection failed: {err}") from err + @staticmethod + async def _raise_for_graphql_rejection(response: aiohttp.ClientResponse) -> None: + """Raise the GraphQL errors of a 4xx response, keeping their codes. + + Unraid API 4.37.8+ rejects some operations before execution, e.g. + schema introspection with ``extensions.code`` ``INTROSPECTION_DISABLED``. + Returns without raising when the body has no GraphQL errors, or for + 401/403, so the caller's authentication and HTTP error handling applies. + """ + if response.status in (HTTP_UNAUTHORIZED, HTTP_FORBIDDEN): + return + try: + body = await response.json(content_type=None) + except (aiohttp.ContentTypeError, ValueError): + return + errors = body.get("errors") if isinstance(body, dict) else None + if isinstance(errors, list) and errors: + raise UnraidAPIError( + f"GraphQL request rejected (HTTP {response.status})", errors=errors + ) + async def query( self, query: str, variables: dict[str, Any] | None = None ) -> dict[str, Any]: @@ -765,7 +808,10 @@ async def _execute_query( _LOGGER.debug("GraphQL returned %d error(s)", len(errors)) - if data: + # Since Unraid API 4.37.8 the docker and vms roots are nullable, so a + # failed service returns {"docker": null} with errors instead of + # data: null. Nothing usable came back when every root is null. + if isinstance(data, dict) and any(v is not None for v in data.values()): # Partial failure - log and return data _LOGGER.debug( "Some optional features unavailable: %s", @@ -838,6 +884,23 @@ async def get_capabilities(self) -> ServerCapabilities: response = await self.query(build_introspection_query()) except UnraidAuthenticationError: raise + except UnraidAPIError as err: + if not _is_introspection_disabled(err): + _LOGGER.debug( + "Capability introspection failed (%s); " + "falling back to permissive", + err, + ) + self._capabilities = ServerCapabilities.permissive() + return self._capabilities + # Only Unraid API 4.37.8+ refuses introspection, so the server + # has every current field; fields retired upstream are absent. + _LOGGER.debug( + "Server disables introspection (Unraid API 4.37.8+); " + "assuming current capabilities" + ) + self._capabilities = ServerCapabilities.introspection_disabled() + return self._capabilities except Exception as err: _LOGGER.debug( "Capability introspection failed (%s); falling back to permissive", @@ -1434,7 +1497,7 @@ async def typed_get_containers(self) -> list[DockerContainer]: caps = await self.get_capabilities() query_str = self._build_containers_query(caps) result = await self.query(query_str) - containers = result.get("docker", {}).get("containers", []) or [] + containers = (result.get("docker") or {}).get("containers", []) or [] return [DockerContainer.from_api_response(c) for c in containers] def _build_containers_query(self, caps: ServerCapabilities) -> str: @@ -1566,7 +1629,7 @@ async def typed_get_containers_safe(self) -> list[DockerContainer]: caps = await self.get_capabilities() query_str = self._build_containers_query_safe(caps) result = await self.query(query_str) - containers = result.get("docker", {}).get("containers", []) or [] + containers = (result.get("docker") or {}).get("containers", []) or [] return [DockerContainer.from_api_response(c) for c in containers] def _build_containers_query_safe(self, caps: ServerCapabilities) -> str: @@ -1644,7 +1707,7 @@ async def typed_get_vms(self) -> list[VmDomain]: }} """ result = await self.query(query_str) - domains = result.get("vms", {}).get("domains", []) or [] + domains = (result.get("vms") or {}).get("domains", []) or [] return [VmDomain(**vm) for vm in domains] async def typed_get_ups_devices(self) -> list[UPSDevice]: @@ -1803,7 +1866,7 @@ async def get_container_update_statuses(self) -> list[ContainerUpdateStatus]: } """ result = await self.query(query_str) - statuses = result.get("docker", {}).get("containerUpdateStatuses", []) or [] + statuses = (result.get("docker") or {}).get("containerUpdateStatuses", []) or [] return [ContainerUpdateStatus(**s) for s in statuses] async def get_ups_configuration(self) -> UPSConfiguration: @@ -1895,7 +1958,7 @@ async def get_docker_port_conflicts(self) -> DockerPortConflicts: } """ result = await self.query(query_str) - conflicts_data = result.get("docker", {}).get("portConflicts", {}) or {} + conflicts_data = (result.get("docker") or {}).get("portConflicts", {}) or {} return DockerPortConflicts(**conflicts_data) async def get_notification_overview(self) -> NotificationOverview: @@ -2458,7 +2521,7 @@ async def get_containers(self) -> list[dict[str, Any]]: } """ result = await self.query(query_str) - return list(result.get("docker", {}).get("containers", [])) + return list((result.get("docker") or {}).get("containers", [])) # ========================================================================= # Additional VM Methods @@ -2559,7 +2622,7 @@ async def get_vms(self) -> list[dict[str, Any]]: } """ result = await self.query(query_str) - return list(result.get("vms", {}).get("domains", []) or []) + return list((result.get("vms") or {}).get("domains", []) or []) # ========================================================================= # System Metrics Methods @@ -3378,7 +3441,7 @@ async def get_docker_networks(self) -> list[dict[str, Any]]: } """ result = await self.query(query_str) - return list(result.get("docker", {}).get("networks", [])) + return list((result.get("docker") or {}).get("networks", [])) async def typed_get_docker_networks(self) -> list[DockerNetwork]: """Get Docker networks as Pydantic models. @@ -3932,7 +3995,7 @@ async def get_container_logs( if since is not None: variables["since"] = since result = await self.query(query, variables) - logs: dict[str, Any] = dict(result.get("docker", {}).get("logs", {})) + logs: dict[str, Any] = dict((result.get("docker") or {}).get("logs", {})) return logs async def typed_get_container_logs( diff --git a/tests/test_api_4378_compat.py b/tests/test_api_4378_compat.py new file mode 100644 index 0000000..f2d3d5c --- /dev/null +++ b/tests/test_api_4378_compat.py @@ -0,0 +1,223 @@ +"""Unraid API 4.37.8+ compatibility (#135). + +4.37.8 made the ``docker`` and ``vms`` query roots nullable (unraid/api#2123) +and refuses schema introspection unless the developer sandbox is enabled +(unraid/api#2095, #2129). +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from aiointercept import aiointercept + +from unraid_api import UnraidClient +from unraid_api.capabilities import RETIRED_FIELDS, ServerCapabilities +from unraid_api.exceptions import UnraidAPIError, UnraidAuthenticationError + +INTROSPECTION_DISABLED = { + "errors": [ + { + "message": "GraphQL introspection is not allowed, but the current " + "request is for introspection.", + "extensions": {"code": "INTROSPECTION_DISABLED"}, + } + ] +} + + +def _client(response: dict[str, Any]) -> tuple[UnraidClient, AsyncMock]: + client = UnraidClient("unraid.test", "test-key", verify_ssl=False) + client._capabilities = ServerCapabilities.introspection_disabled() + request = AsyncMock(return_value=response) + client._make_request = request # type: ignore[method-assign] + return client, request + + +def _null_root(root: str, message: str = "Service is not running") -> dict[str, Any]: + return {"data": {root: None}, "errors": [{"message": message, "path": [root]}]} + + +# -------------------------------------------------------------------------- +# Nullable docker / vms roots +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("method", "args", "root"), + [ + ("typed_get_containers", (), "docker"), + ("typed_get_containers_safe", (), "docker"), + ("get_containers", (), "docker"), + ("get_container_update_statuses", (), "docker"), + ("get_docker_port_conflicts", (), "docker"), + ("get_docker_networks", (), "docker"), + ("get_container_logs", ("abc",), "docker"), + ("get_docker_organizer", (), "docker"), + ("typed_get_vms", (), "vms"), + ("get_vms", (), "vms"), + ], +) +async def test_null_service_root_raises_api_error( + method: str, args: tuple[Any, ...], root: str +) -> None: + """A failed service is an UnraidAPIError, never AttributeError or [].""" + client, _ = _client(_null_root(root, f"{root} is not running")) + with pytest.raises(UnraidAPIError) as exc_info: + await getattr(client, method)(*args) + assert exc_info.value.errors[0]["path"] == [root] + assert f"{root} is not running" in str(exc_info.value) + + +async def test_partial_result_with_one_healthy_root_is_returned() -> None: + """Healthy roots still come back when another root failed.""" + client, _ = _client( + { + "data": {"docker": None, "online": True}, + "errors": [{"message": "Docker is not running", "path": ["docker"]}], + } + ) + assert await client.query("query { docker { id } online }") == { + "docker": None, + "online": True, + } + + +async def test_null_root_without_errors_is_returned() -> None: + """A root that is legitimately null without errors is data, not failure.""" + client, _ = _client({"data": {"customization": None}}) + assert await client.query("query { customization { theme { name } } }") == { + "customization": None + } + + +# -------------------------------------------------------------------------- +# GraphQL errors on HTTP 4xx +# -------------------------------------------------------------------------- + + +async def test_rejected_request_keeps_graphql_errors() -> None: + """The INTROSPECTION_DISABLED code survives the HTTP 400.""" + async with aiointercept(mock_external_urls=True) as m: + m.get("http://unraid.test/graphql", status=400) + m.post("http://unraid.test/graphql", status=400, payload=INTROSPECTION_DISABLED) + async with UnraidClient("unraid.test", "test-key", verify_ssl=False) as client: + with pytest.raises(UnraidAPIError) as exc_info: + await client.query('{ __type(name: "VmDomain") { name } }') + assert exc_info.value.message == "GraphQL request rejected (HTTP 400)" + assert exc_info.value.errors[0]["extensions"]["code"] == "INTROSPECTION_DISABLED" + + +async def test_rejected_request_after_redirect_keeps_graphql_errors() -> None: + """Servers that redirect the POST (SSL modes) keep the code too.""" + async with aiointercept(mock_external_urls=True) as m: + m.get("http://unraid.test/graphql", status=400) + m.post( + "http://unraid.test/graphql", + status=302, + headers={"Location": "https://unraid.test/graphql"}, + ) + m.post( + "https://unraid.test/graphql", status=400, payload=INTROSPECTION_DISABLED + ) + async with UnraidClient("unraid.test", "test-key", verify_ssl=False) as client: + caps = await client.get_capabilities() + assert not caps.is_permissive + assert caps.has("VmDomain.uuid") + + +@pytest.mark.parametrize("status", [401, 403]) +async def test_auth_failure_after_redirect_stays_an_auth_error(status: int) -> None: + """A revoked key is never mistaken for a GraphQL rejection.""" + async with aiointercept(mock_external_urls=True) as m: + m.get("http://unraid.test/graphql", status=400) + m.post( + "http://unraid.test/graphql", + status=302, + headers={"Location": "https://unraid.test/graphql"}, + ) + m.post( + "https://unraid.test/graphql", + status=status, + payload={"errors": [{"message": "Unauthorized"}]}, + ) + async with UnraidClient("unraid.test", "test-key", verify_ssl=False) as client: + with pytest.raises(UnraidAuthenticationError): + await client.get_capabilities() + + +@pytest.mark.parametrize( + "body", + ["not json", '{"message": "no errors key"}', '{"errors": []}', "[1, 2]"], +) +async def test_rejected_request_without_graphql_errors_keeps_http_error( + body: str, +) -> None: + async with aiointercept(mock_external_urls=True) as m: + m.get("http://unraid.test/graphql", status=400) + m.post("http://unraid.test/graphql", status=400, body=body) + async with UnraidClient("unraid.test", "test-key", verify_ssl=False) as client: + with pytest.raises(UnraidAPIError, match="HTTP error 400"): + await client.query("query { online }") + + +# -------------------------------------------------------------------------- +# Capabilities when introspection is disabled +# -------------------------------------------------------------------------- + + +async def test_introspection_disabled_gives_current_capabilities() -> None: + client = UnraidClient("unraid.test", "test-key", verify_ssl=False) + client._make_request = AsyncMock( # type: ignore[method-assign] + side_effect=UnraidAPIError( + "GraphQL request rejected (HTTP 400)", + errors=INTROSPECTION_DISABLED["errors"], + ) + ) + caps = await client.get_capabilities() + assert not caps.is_permissive + assert caps.has("VmDomain.uuid") + assert caps.has("DockerMutations.restart") + assert not caps.has("ArrayMutations.removeDiskFromArray") + assert await client.get_capabilities() is caps # cached + + +async def test_other_introspection_failures_stay_permissive() -> None: + client = UnraidClient("unraid.test", "test-key", verify_ssl=False) + client._make_request = AsyncMock( # type: ignore[method-assign] + side_effect=UnraidAPIError("HTTP error 400: Bad Request") + ) + caps = await client.get_capabilities() + assert caps.is_permissive + assert caps.has("ArrayMutations.removeDiskFromArray") + + +async def test_vm_uuid_is_requested_when_introspection_is_disabled() -> None: + """The regression: permissive capabilities dropped VM UUIDs.""" + client, request = _client( + {"data": {"vms": {"domains": [{"id": "1", "name": "HA", "state": "RUNNING"}]}}} + ) + await client.typed_get_vms() + assert "uuid" in request.call_args.args[0]["query"] + + +async def test_retired_disk_removal_is_refused_without_a_request() -> None: + client, request = _client({"data": {}}) + with ( + pytest.warns(DeprecationWarning), + pytest.raises(UnraidAPIError, match="no longer supports direct array disk"), + ): + await client.remove_array_disk("disk1") + request.assert_not_called() + + +def test_introspection_disabled_capabilities() -> None: + caps = ServerCapabilities.introspection_disabled() + assert not caps.is_permissive + assert caps.has("Query.anything") + assert caps.has_all("Query.docker", "Mutation.notifyIfUnique") + assert not any(caps.has(path) for path in RETIRED_FIELDS) + with pytest.raises(ValueError, match="dotted"): + ServerCapabilities.from_introspection_response({}).has("nodot") diff --git a/tests/test_client_async.py b/tests/test_client_async.py index 673fba0..40bc31b 100644 --- a/tests/test_client_async.py +++ b/tests/test_client_async.py @@ -8500,6 +8500,7 @@ async def test_ordinary_query_still_caches_post_redirect_destination() -> None: redirect.status = 307 redirect.headers = {"Location": "https://unraid.test/graphql"} response = MagicMock() + response.status = 200 response.json = AsyncMock(return_value={"data": {"online": True}}) first = MagicMock() first.__aenter__ = AsyncMock(return_value=redirect)