From 0523f1e36ac014b0d6783618d9dc757ace5ed5e7 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 23 Jul 2025 20:05:26 +0000 Subject: [PATCH 1/2] Add nvme status action --- aiohasupervisor/client.py | 11 +- aiohasupervisor/host.py | 30 +++++ aiohasupervisor/models/__init__.py | 182 ++++++++++++++------------- aiohasupervisor/models/host.py | 22 ++++ pyproject.toml | 19 +-- tests/fixtures/host_nvme_status.json | 21 ++++ tests/test_host.py | 71 +++++++++++ 7 files changed, 256 insertions(+), 100 deletions(-) create mode 100644 tests/fixtures/host_nvme_status.json diff --git a/aiohasupervisor/client.py b/aiohasupervisor/client.py index fdca4ec..b1dbf9d 100644 --- a/aiohasupervisor/client.py +++ b/aiohasupervisor/client.py @@ -86,10 +86,11 @@ async def _request( json: dict[str, Any] | None = None, data: Any = None, timeout: ClientTimeout | None = DEFAULT_TIMEOUT, + uri_encoded: bool = False, ) -> Response: """Handle a request to Supervisor.""" try: - url = URL(self.api_host).joinpath(uri) + url = URL(self.api_host).joinpath(uri, encoded=uri_encoded) except ValueError as err: raise SupervisorError from err @@ -158,6 +159,7 @@ async def get( params: dict[str, str] | MultiDict[str] | None = None, response_type: ResponseType = ResponseType.JSON, timeout: ClientTimeout | None = DEFAULT_TIMEOUT, + uri_encoded: bool = False, ) -> Response: """Handle a GET request to Supervisor.""" return await self._request( @@ -166,6 +168,7 @@ async def get( params=params, response_type=response_type, timeout=timeout, + uri_encoded=uri_encoded, ) async def post( @@ -177,6 +180,7 @@ async def post( json: dict[str, Any] | None = None, data: Any = None, timeout: ClientTimeout | None = DEFAULT_TIMEOUT, + uri_encoded: bool = False, ) -> Response: """Handle a POST request to Supervisor.""" return await self._request( @@ -187,6 +191,7 @@ async def post( json=json, data=data, timeout=timeout, + uri_encoded=uri_encoded, ) async def put( @@ -196,6 +201,7 @@ async def put( params: dict[str, str] | MultiDict[str] | None = None, json: dict[str, Any] | None = None, timeout: ClientTimeout | None = DEFAULT_TIMEOUT, + uri_encoded: bool = False, ) -> Response: """Handle a PUT request to Supervisor.""" return await self._request( @@ -205,6 +211,7 @@ async def put( response_type=ResponseType.NONE, json=json, timeout=timeout, + uri_encoded=uri_encoded, ) async def delete( @@ -214,6 +221,7 @@ async def delete( params: dict[str, str] | MultiDict[str] | None = None, json: dict[str, Any] | None = None, timeout: ClientTimeout | None = DEFAULT_TIMEOUT, + uri_encoded: bool = False, ) -> Response: """Handle a DELETE request to Supervisor.""" return await self._request( @@ -223,6 +231,7 @@ async def delete( response_type=ResponseType.NONE, json=json, timeout=timeout, + uri_encoded=uri_encoded, ) async def close(self) -> None: diff --git a/aiohasupervisor/host.py b/aiohasupervisor/host.py index 558dce4..3b73a9e 100644 --- a/aiohasupervisor/host.py +++ b/aiohasupervisor/host.py @@ -1,16 +1,23 @@ """Host client for supervisor.""" +import re +from urllib.parse import quote + from .client import _SupervisorComponentClient from .const import TIMEOUT_60_SECONDS +from .exceptions import SupervisorError from .models.host import ( HostInfo, HostOptions, + NVMeStatus, RebootOptions, Service, ServiceList, ShutdownOptions, ) +RE_NVME_DEVICE = re.compile(r"^(?:[-A-Fa-f0-9]+|\/dev\/[-_a-z0-9]+)$") + class HostClient(_SupervisorComponentClient): """Handles host access in supervisor.""" @@ -47,4 +54,27 @@ async def services(self) -> list[Service]: result = await self._client.get("host/services") return ServiceList.from_dict(result.data).services + async def nvme_status(self, device: str | None = None) -> NVMeStatus: + """Get NVMe status for a device. + + Device can be the Host ID or device path (e.g. /dev/nvme0n1). + If omitted, returns status of datadisk if it is an nvme device. + """ + if device is not None: + # Encoding must be done here because something like /dev/nvme0n1 is + # valid and that won't work in the resource path. But that means we + # bypass part of the safety check that would normally raise on any + # encoded chars. So strict validation needs to be done here rather + # then letting Supervisor handle it like normal. + if not RE_NVME_DEVICE.match(device): + raise SupervisorError(f"Invalid device: {device}") + + encoded = quote(device, safe="") + result = await self._client.get( + f"host/nvme/{encoded}/status", uri_encoded=True + ) + else: + result = await self._client.get("host/nvme/status") + return NVMeStatus.from_dict(result.data) + # Omitted for now - Log endpoints diff --git a/aiohasupervisor/models/__init__.py b/aiohasupervisor/models/__init__.py index afcfd8d..0efbe80 100644 --- a/aiohasupervisor/models/__init__.py +++ b/aiohasupervisor/models/__init__.py @@ -65,6 +65,7 @@ from aiohasupervisor.models.host import ( HostInfo, HostOptions, + NVMeStatus, RebootOptions, Service, ServiceState, @@ -152,130 +153,131 @@ ) __all__ = [ - "HostFeature", - "SupervisorState", - "UpdateChannel", - "LogLevel", - "UpdateType", - "RootInfo", - "AvailableUpdate", - "AddonStage", - "AddonStartup", + "LOCATION_CLOUD_BACKUP", + "LOCATION_LOCAL_STORAGE", + "AccessPoint", "AddonBoot", "AddonBootConfig", - "CpuArch", - "Capability", - "AppArmor", - "SupervisorRole", + "AddonSet", + "AddonStage", + "AddonStartup", "AddonState", - "StoreAddon", - "StoreAddonComplete", - "InstalledAddon", - "InstalledAddonComplete", - "AddonsOptions", "AddonsConfigValidate", + "AddonsOptions", "AddonsRebuild", "AddonsSecurityOptions", "AddonsStats", "AddonsUninstall", - "Repository", - "StoreInfo", - "StoreAddonUpdate", - "StoreAddRepository", - "Check", - "CheckOptions", - "CheckType", - "ContextType", - "Issue", - "IssueType", - "ResolutionInfo", - "Suggestion", - "SuggestionType", - "UnhealthyReason", - "UnsupportedReason", - "SupervisorInfo", - "SupervisorOptions", - "SupervisorStats", - "SupervisorUpdateOptions", - "HomeAssistantInfo", - "HomeAssistantOptions", - "HomeAssistantRebuildOptions", - "HomeAssistantRestartOptions", - "HomeAssistantStats", - "HomeAssistantStopOptions", - "HomeAssistantUpdateOptions", - "RaucState", - "BootSlotName", - "BootSlot", - "OSInfo", - "OSUpdate", - "MigrateDataOptions", - "DataDisk", - "SetBootSlotOptions", - "GreenInfo", - "GreenOptions", - "YellowInfo", - "YellowOptions", - "LOCATION_CLOUD_BACKUP", - "LOCATION_LOCAL_STORAGE", - "AddonSet", + "AppArmor", + "AuthMethod", + "AvailableUpdate", "Backup", "BackupAddon", "BackupComplete", "BackupContent", "BackupJob", "BackupLocationAttributes", + "BackupType", "BackupsInfo", "BackupsOptions", - "BackupType", + "BootSlot", + "BootSlotName", + "CIFSMountRequest", + "CIFSMountResponse", + "Capability", + "Check", + "CheckOptions", + "CheckType", + "ContextType", + "CpuArch", + "DataDisk", + "Discovery", + "DiscoveryConfig", + "DockerNetwork", "DownloadBackupOptions", "Folder", "FreezeOptions", "FullBackupOptions", "FullRestoreOptions", - "NewBackup", - "PartialBackupOptions", - "PartialRestoreOptions", - "RemoveBackupOptions", - "UploadBackupOptions", - "Discovery", - "DiscoveryConfig", - "AccessPoint", - "AuthMethod", - "DockerNetwork", - "InterfaceMethod", - "InterfaceType", + "GreenInfo", + "GreenOptions", + "HomeAssistantInfo", + "HomeAssistantOptions", + "HomeAssistantRebuildOptions", + "HomeAssistantRestartOptions", + "HomeAssistantStats", + "HomeAssistantStopOptions", + "HomeAssistantUpdateOptions", + "HostFeature", + "HostInfo", + "HostOptions", "IPv4", "IPv4Config", "IPv6", "IPv6Config", - "NetworkInfo", - "NetworkInterface", - "NetworkInterfaceConfig", - "Vlan", - "VlanConfig", - "Wifi", - "WifiConfig", - "WifiMode", - "HostInfo", - "HostOptions", - "RebootOptions", - "Service", - "ServiceState", - "ShutdownOptions", + "InstalledAddon", + "InstalledAddonComplete", + "InterfaceMethod", + "InterfaceType", + "Issue", + "IssueType", "Job", "JobCondition", "JobError", "JobsInfo", "JobsOptions", - "CIFSMountRequest", - "CIFSMountResponse", + "LogLevel", + "MigrateDataOptions", "MountCifsVersion", - "MountsInfo", - "MountsOptions", "MountState", "MountType", "MountUsage", + "MountsInfo", + "MountsOptions", "NFSMountRequest", "NFSMountResponse", + "NVMeStatus", + "NetworkInfo", + "NetworkInterface", + "NetworkInterfaceConfig", + "NewBackup", + "OSInfo", + "OSUpdate", + "PartialBackupOptions", + "PartialRestoreOptions", + "RaucState", + "RebootOptions", + "RemoveBackupOptions", + "Repository", + "ResolutionInfo", + "RootInfo", + "Service", + "ServiceState", + "SetBootSlotOptions", + "ShutdownOptions", + "StoreAddRepository", + "StoreAddon", + "StoreAddonComplete", + "StoreAddonUpdate", + "StoreInfo", + "Suggestion", + "SuggestionType", + "SupervisorInfo", + "SupervisorOptions", + "SupervisorRole", + "SupervisorState", + "SupervisorStats", + "SupervisorUpdateOptions", + "UnhealthyReason", + "UnsupportedReason", + "UpdateChannel", + "UpdateType", + "UploadBackupOptions", + "Vlan", + "VlanConfig", + "Wifi", + "WifiConfig", + "WifiMode", + "YellowInfo", + "YellowOptions", ] diff --git a/aiohasupervisor/models/host.py b/aiohasupervisor/models/host.py index 3f393ca..5e97ba1 100644 --- a/aiohasupervisor/models/host.py +++ b/aiohasupervisor/models/host.py @@ -96,3 +96,25 @@ class ServiceList(ResponseData): """ServiceList model.""" services: list[Service] + + +@dataclass(frozen=True, slots=True) +class NVMeStatus(ResponseData): + """NVMeStatus model.""" + + available_spare: int + critical_warning: int + data_units_read: int + data_units_written: int + percent_used: int + temperature_kelvin: int + host_read_commands: int + host_write_commands: int + controller_busy_minutes: int + power_cycles: int + power_on_hours: int + unsafe_shutdowns: int + media_errors: int + number_error_log_entries: int + warning_temp_minutes: int + critical_composite_temp_minutes: int diff --git a/pyproject.toml b/pyproject.toml index 1c480c8..5d6a111 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,15 +75,16 @@ filterwarnings = [ [tool.ruff] lint.select = ["ALL"] lint.ignore = [ - "ANN401", # Opinionated warning on disallowing dynamically typed expressions - "D203", # Conflicts with other rules - "D213", # Conflicts with other rules - "EM", # flake8-errmsg, more frustration then value - "PLR0911", # Too many return statements ({returns} > {max_returns}) - "PLR0912", # Too many branches ({branches} > {max_branches}) - "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) - "PLR0915", # Too many statements ({statements} > {max_statements}) - "TRY003", # Avoid specifying long messages outside the exception class + "ANN401", # Opinionated warning on disallowing dynamically typed expressions + "ASYNC109", # Opinionated warning on disallowing timeout parameters + "D203", # Conflicts with other rules + "D213", # Conflicts with other rules + "EM", # flake8-errmsg, more frustration then value + "PLR0911", # Too many return statements ({returns} > {max_returns}) + "PLR0912", # Too many branches ({branches} > {max_branches}) + "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) + "PLR0915", # Too many statements ({statements} > {max_statements}) + "TRY003", # Avoid specifying long messages outside the exception class # Recommended to disable due to conflicts with formatter # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules diff --git a/tests/fixtures/host_nvme_status.json b/tests/fixtures/host_nvme_status.json new file mode 100644 index 0000000..320147f --- /dev/null +++ b/tests/fixtures/host_nvme_status.json @@ -0,0 +1,21 @@ +{ + "result": "ok", + "data": { + "available_spare": 100, + "critical_warning": 0, + "data_units_read": 44707691, + "data_units_written": 54117388, + "percent_used": 1, + "temperature_kelvin": 312, + "host_read_commands": 428871098, + "host_write_commands": 900245782, + "controller_busy_minutes": 2678, + "power_cycles": 652, + "power_on_hours": 3192, + "unsafe_shutdowns": 107, + "media_errors": 0, + "number_error_log_entries": 1069, + "warning_temp_minutes": 0, + "critical_composite_temp_minutes": 0 + } +} diff --git a/tests/test_host.py b/tests/test_host.py index 1dd4cb0..13065e0 100644 --- a/tests/test_host.py +++ b/tests/test_host.py @@ -1,12 +1,14 @@ """Test host supervisor client.""" from datetime import UTC, datetime +from urllib.parse import quote from aioresponses import aioresponses import pytest from yarl import URL from aiohasupervisor import SupervisorClient +from aiohasupervisor.exceptions import SupervisorError from aiohasupervisor.models import HostOptions, RebootOptions, ShutdownOptions from . import load_fixture @@ -111,3 +113,72 @@ async def test_host_services( assert result[-1].name == "systemd-resolved.service" assert result[-1].description == "Network Name Resolution" assert result[-1].state == "active" + + +async def test_host_nvme_status( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test host nvme_status API.""" + responses.get( + f"{SUPERVISOR_URL}/host/nvme/status", + status=200, + body=load_fixture("host_nvme_status.json"), + ) + result = await supervisor_client.host.nvme_status() + assert result.available_spare == 100 + assert result.critical_warning == 0 + assert result.data_units_read == 44707691 + assert result.data_units_written == 54117388 + assert result.percent_used == 1 + assert result.temperature_kelvin == 312 + assert result.host_read_commands == 428871098 + assert result.host_write_commands == 900245782 + assert result.controller_busy_minutes == 2678 + assert result.power_cycles == 652 + assert result.power_on_hours == 3192 + assert result.unsafe_shutdowns == 107 + assert result.media_errors == 0 + assert result.number_error_log_entries == 1069 + assert result.warning_temp_minutes == 0 + assert result.critical_composite_temp_minutes == 0 + + +@pytest.mark.parametrize("device", ["1234-5678", "/dev/nvme0n1"]) +async def test_host_nvme_status_device( + responses: aioresponses, supervisor_client: SupervisorClient, device: str +) -> None: + """Test host nvme_status API with device argument.""" + encoded = quote(device, safe="") + responses.get( + f"{SUPERVISOR_URL}/host/nvme/{encoded}/status", + status=200, + body=load_fixture("host_nvme_status.json"), + ) + result = await supervisor_client.host.nvme_status(device) + assert result.available_spare == 100 + assert result.critical_warning == 0 + assert result.data_units_read == 44707691 + assert result.data_units_written == 54117388 + assert result.percent_used == 1 + assert result.temperature_kelvin == 312 + assert result.host_read_commands == 428871098 + assert result.host_write_commands == 900245782 + assert result.controller_busy_minutes == 2678 + assert result.power_cycles == 652 + assert result.power_on_hours == 3192 + assert result.unsafe_shutdowns == 107 + assert result.media_errors == 0 + assert result.number_error_log_entries == 1069 + assert result.warning_temp_minutes == 0 + assert result.critical_composite_temp_minutes == 0 + + +@pytest.mark.parametrize( + "device", ["/test/../bad", "test/../bad", "test/%2E%2E/bad", "/dev/../bad"] +) +async def test_host_nvme_status_path_manipulation_blocked( + supervisor_client: SupervisorClient, device: str +) -> None: + """Test path manipulation prevented.""" + with pytest.raises(SupervisorError, match=r"^Invalid device: "): + await supervisor_client.host.nvme_status(device) From f1971f11a3d3a7e13dced1232d22a54120b453a8 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 23 Jul 2025 20:24:35 +0000 Subject: [PATCH 2/2] Add nvme_devices field to host info --- aiohasupervisor/models/host.py | 10 ++++++++++ tests/fixtures/host_info.json | 8 +++++++- tests/test_host.py | 3 +++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/aiohasupervisor/models/host.py b/aiohasupervisor/models/host.py index 5e97ba1..d58d55d 100644 --- a/aiohasupervisor/models/host.py +++ b/aiohasupervisor/models/host.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from datetime import datetime from enum import StrEnum +from pathlib import PurePath from .base import Request, ResponseData from .root import HostFeature @@ -32,6 +33,14 @@ class ServiceState(StrEnum): # --- OBJECTS ---- +@dataclass(frozen=True, slots=True) +class HostInfoNVMeDevice(ResponseData): + """HostInfoNVMeDevice model.""" + + id: str + path: PurePath + + @dataclass(frozen=True, slots=True) class HostInfo(ResponseData): """HostInfo model.""" @@ -59,6 +68,7 @@ class HostInfo(ResponseData): boot_timestamp: int | None broadcast_llmnr: bool | None broadcast_mdns: bool | None + nvme_devices: list[HostInfoNVMeDevice] @dataclass(frozen=True, slots=True) diff --git a/tests/fixtures/host_info.json b/tests/fixtures/host_info.json index b96d0b2..71abeb2 100644 --- a/tests/fixtures/host_info.json +++ b/tests/fixtures/host_info.json @@ -36,6 +36,12 @@ "startup_time": 1.966311, "boot_timestamp": 1716927644219811, "broadcast_llmnr": true, - "broadcast_mdns": true + "broadcast_mdns": true, + "nvme_devices": [ + { + "id": "00000000-0000-0000-0000-000000000000", + "path": "/dev/nvme0n1" + } + ] } } diff --git a/tests/test_host.py b/tests/test_host.py index 13065e0..853714e 100644 --- a/tests/test_host.py +++ b/tests/test_host.py @@ -1,6 +1,7 @@ """Test host supervisor client.""" from datetime import UTC, datetime +from pathlib import PurePath from urllib.parse import quote from aioresponses import aioresponses @@ -47,6 +48,8 @@ async def test_host_info( assert result.dt_utc == datetime(2024, 10, 3, 0, 0, 0, 0, UTC) assert result.dt_synchronized is True assert result.startup_time == 1.966311 + assert result.nvme_devices[0].id == "00000000-0000-0000-0000-000000000000" + assert result.nvme_devices[0].path == PurePath("/dev/nvme0n1") @pytest.mark.parametrize("options", [None, RebootOptions(force=True)])