From 73c32e0359bdcc7ad624d26fea15d0595acb61f9 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Tue, 24 Feb 2026 09:16:26 -0600 Subject: [PATCH 1/8] skipping check if nvme cli not installed --- .../plugins/inband/nvme/nvme_collector.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/nodescraper/plugins/inband/nvme/nvme_collector.py b/nodescraper/plugins/inband/nvme/nvme_collector.py index 19463493..0b0f1598 100644 --- a/nodescraper/plugins/inband/nvme/nvme_collector.py +++ b/nodescraper/plugins/inband/nvme/nvme_collector.py @@ -54,6 +54,15 @@ class NvmeCollector(InBandDataCollector[NvmeDataModel, None]): TELEMETRY_FILENAME = "telemetry_log.bin" + def _check_nvme_cli_installed(self) -> bool: + """Check if the nvme CLI is installed on the system. + + Returns: + bool: True if nvme is available, False otherwise. + """ + res = self._run_sut_cmd("which nvme") + return res.exit_code == 0 and bool(res.stdout.strip()) + def collect_data( self, args=None, @@ -73,6 +82,16 @@ def collect_data( self.result.status = ExecutionStatus.NOT_RAN return self.result, None + if not self._check_nvme_cli_installed(): + self._log_event( + category=EventCategory.SW_DRIVER, + description="nvme CLI not found; install nvme-cli to collect NVMe data", + priority=EventPriority.WARNING, + ) + self.result.message = "nvme CLI not found; NVMe collection skipped" + self.result.status = ExecutionStatus.NOT_RAN + return self.result, None + nvme_devices = self._get_nvme_devices() if not nvme_devices: self._log_event( From 525b077366dbf11b6e63639eebc6512c24e7872b Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Tue, 24 Feb 2026 09:24:09 -0600 Subject: [PATCH 2/8] utest --- test/unit/plugin/test_nvme_collector.py | 34 ++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/test/unit/plugin/test_nvme_collector.py b/test/unit/plugin/test_nvme_collector.py index f66bc9d7..033c7cb3 100644 --- a/test/unit/plugin/test_nvme_collector.py +++ b/test/unit/plugin/test_nvme_collector.py @@ -57,10 +57,27 @@ def test_skips_on_windows(collector): assert "Windows" in collector._log_event.call_args.kwargs["description"] +def test_not_ran_when_nvme_cli_not_found(collector): + collector.system_info = MagicMock(os_family=OSFamily.LINUX) + collector._run_sut_cmd.return_value = MagicMock(exit_code=1, stdout="") + + result, data = collector.collect_data() + + assert result.status == ExecutionStatus.NOT_RAN + assert data is None + assert "nvme CLI not found" in result.message + collector._run_sut_cmd.assert_called_once_with("which nvme") + collector._log_event.assert_called_once() + + @pytest.mark.skip(reason="No NVME device in testing infrastructure") def test_successful_collection(collector): collector.system_info = MagicMock(os_family=OSFamily.LINUX) - collector._run_sut_cmd.return_value = MagicMock(exit_code=0, stdout="output") + cmd_ok = MagicMock(exit_code=0, stdout="output") + collector._run_sut_cmd.side_effect = [ + MagicMock(exit_code=0, stdout="/usr/bin/nvme"), + MagicMock(exit_code=0, stdout="nvme0\nnvme0n1\nsda"), + ] + [cmd_ok] * 8 fake_artifact = MagicMock() fake_artifact.filename = "telemetry_log" @@ -73,7 +90,7 @@ def test_successful_collection(collector): assert result.status == ExecutionStatus.OK assert result.message == "NVMe data successfully collected" assert isinstance(data, NvmeDataModel) - assert collector._run_sut_cmd.call_count == 8 + assert collector._run_sut_cmd.call_count == 10 collector._read_sut_file.assert_called_once_with(filename="telemetry_log", encoding=None) @@ -81,10 +98,15 @@ def test_successful_collection(collector): def test_partial_failures(collector): collector.system_info = MagicMock(os_family=OSFamily.LINUX) - def fake_cmd(cmd, sudo): + def fake_cmd(cmd, sudo=False): + if cmd == "which nvme": + return MagicMock(exit_code=0, stdout="/usr/bin/nvme") + if cmd == "ls /dev": + return MagicMock(exit_code=0, stdout="nvme0\nnvme0n1\nnvme1\nsda") return MagicMock(exit_code=0 if "smart-log" in cmd else 1, stdout="out") collector._run_sut_cmd.side_effect = fake_cmd + collector._read_sut_file = MagicMock(return_value=MagicMock(contents=b"")) result, data = collector.collect_data() @@ -96,7 +118,11 @@ def fake_cmd(cmd, sudo): def test_no_data_collected(collector): collector.system_info = MagicMock(os_family=OSFamily.LINUX) - collector._run_sut_cmd.return_value = MagicMock(exit_code=1, stdout="") + collector._run_sut_cmd.side_effect = [ + MagicMock(exit_code=0, stdout="/usr/bin/nvme"), + MagicMock(exit_code=0, stdout="nvme0\nnvme0n1\nnvme1\nsda"), + ] + [MagicMock(exit_code=1, stdout="")] * 16 + collector._read_sut_file = MagicMock(side_effect=FileNotFoundError()) result, data = collector.collect_data() From 0cdaaa7f71728655f6b200369f71e45850527e0d Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Tue, 24 Feb 2026 14:45:03 -0600 Subject: [PATCH 3/8] nvme list -o json added --- .../plugins/inband/nvme/nvme_collector.py | 85 ++++++++++++++++++- nodescraper/plugins/inband/nvme/nvmedata.py | 27 +++++- nodescraper/utils.py | 44 +++++++--- 3 files changed, 138 insertions(+), 18 deletions(-) diff --git a/nodescraper/plugins/inband/nvme/nvme_collector.py b/nodescraper/plugins/inband/nvme/nvme_collector.py index 0b0f1598..e4338c63 100644 --- a/nodescraper/plugins/inband/nvme/nvme_collector.py +++ b/nodescraper/plugins/inband/nvme/nvme_collector.py @@ -23,6 +23,7 @@ # SOFTWARE. # ############################################################################### +import json import os import re from typing import Optional @@ -32,14 +33,16 @@ from nodescraper.base import InBandDataCollector from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus, OSFamily from nodescraper.models import TaskResult +from nodescraper.utils import bytes_to_human_readable, str_or_none -from .nvmedata import NvmeDataModel +from .nvmedata import NvmeDataModel, NvmeListEntry class NvmeCollector(InBandDataCollector[NvmeDataModel, None]): """Collect NVMe details from the system.""" DATA_MODEL = NvmeDataModel + CMD_LINUX_LIST_JSON = "nvme list -o json" CMD_LINUX = { "smart_log": "nvme smart-log {dev}", "error_log": "nvme error-log {dev} --log-entries=256", @@ -92,6 +95,8 @@ def collect_data( self.result.status = ExecutionStatus.NOT_RAN return self.result, None + nvme_list_entries = self._collect_nvme_list_entries() + nvme_devices = self._get_nvme_devices() if not nvme_devices: self._log_event( @@ -134,7 +139,7 @@ def collect_data( if all_device_data: try: - nvme_data = NvmeDataModel(devices=all_device_data) + nvme_data = NvmeDataModel(nvme_list=nvme_list_entries, devices=all_device_data) except ValidationError as exp: self._log_event( category=EventCategory.SW_DRIVER, @@ -166,6 +171,82 @@ def collect_data( self.result.status = ExecutionStatus.ERROR return self.result, None + def _collect_nvme_list_entries(self) -> Optional[list[NvmeListEntry]]: + """Run 'nvme list -o json' and parse output into list of NvmeListEntry.""" + res = self._run_sut_cmd(self.CMD_LINUX_LIST_JSON, sudo=False) + if res.exit_code == 0 and res.stdout: + return self._parse_nvme_list_json(res.stdout.strip()) + return None + + def _parse_nvme_list_json(self, raw: str) -> list[NvmeListEntry]: + """Parse 'nvme list -o json' output into NvmeListEntry list. + + Walks structure: Devices[] -> Subsystems[] -> Controllers[] -> Namespaces[]. + One NvmeListEntry per namespace (matches table: Node, Generic, SN, Model, etc.). + """ + try: + data = json.loads(raw) + except json.JSONDecodeError: + return [] + devices = data.get("Devices", []) if isinstance(data, dict) else [] + if not isinstance(devices, list): + return [] + entries = [] + for dev in devices: + if not isinstance(dev, dict): + continue + subsystems = dev.get("Subsystems") or [] + for subsys in subsystems: + if not isinstance(subsys, dict): + continue + controllers = subsys.get("Controllers") or [] + for ctrl in controllers: + if not isinstance(ctrl, dict): + continue + serial_number = str_or_none(ctrl.get("SerialNumber")) + model = str_or_none(ctrl.get("ModelNumber")) + fw_rev = str_or_none(ctrl.get("Firmware")) + namespaces = ctrl.get("Namespaces") or [] + for ns in namespaces: + if not isinstance(ns, dict): + continue + name_space = ns.get("NameSpace") or ns.get("NameSpaceId") + generic = ns.get("Generic") + nsid = ns.get("NSID") + used_bytes = ns.get("UsedBytes") + physical_size = ns.get("PhysicalSize") + sector_size = ns.get("SectorSize") + node = f"/dev/{name_space}" if name_space else None + generic_path = ( + f"/dev/{generic}" if (generic and str(generic).strip()) else None + ) + namespace_id = f"0x{nsid:x}" if isinstance(nsid, int) else str_or_none(nsid) + if isinstance(used_bytes, (int, float)) and isinstance( + physical_size, (int, float) + ): + usage = ( + f"{bytes_to_human_readable(int(used_bytes))} / " + f"{bytes_to_human_readable(int(physical_size))}" + ) + else: + usage = None + format_lba = ( + f"{sector_size} B + 0 B" if sector_size is not None else None + ) + entries.append( + NvmeListEntry( + node=str_or_none(node), + generic=str_or_none(generic_path), + serial_number=serial_number, + model=model, + namespace_id=namespace_id, + usage=usage, + format_lba=format_lba, + fw_rev=fw_rev, + ) + ) + return entries + def _get_nvme_devices(self) -> list[str]: nvme_devs = [] diff --git a/nodescraper/plugins/inband/nvme/nvmedata.py b/nodescraper/plugins/inband/nvme/nvmedata.py index 10452fa3..a76e6b41 100644 --- a/nodescraper/plugins/inband/nvme/nvmedata.py +++ b/nodescraper/plugins/inband/nvme/nvmedata.py @@ -25,11 +25,28 @@ ############################################################################### from typing import Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field from nodescraper.models import DataModel +class NvmeListEntry(BaseModel): + """One row from 'nvme list': a single NVMe device/namespace line.""" + + node: Optional[str] = Field(default=None, description="Device node path (e.g. /dev/nvme0n1).") + generic: Optional[str] = Field( + default=None, description="Generic device node (e.g. /dev/ng0n1)." + ) + serial_number: Optional[str] = Field(default=None, description="Serial number (SN).") + model: Optional[str] = Field(default=None, description="Model name.") + namespace_id: Optional[str] = Field(default=None, description="Namespace ID.") + usage: Optional[str] = Field(default=None, description="Usage (e.g. capacity).") + format_lba: Optional[str] = Field( + default=None, description="LBA format (sector size + metadata)." + ) + fw_rev: Optional[str] = Field(default=None, description="Firmware revision.") + + class DeviceNvmeData(BaseModel): smart_log: Optional[str] = None error_log: Optional[str] = None @@ -42,4 +59,10 @@ class DeviceNvmeData(BaseModel): class NvmeDataModel(DataModel): - devices: dict[str, DeviceNvmeData] + """NVMe collection output: parsed 'nvme list' entries and per-device command outputs.""" + + nvme_list: Optional[list[NvmeListEntry]] = Field( + default=None, + description="Parsed list of NVMe devices from 'nvme list'.", + ) + devices: dict[str, DeviceNvmeData] = Field(default_factory=dict) diff --git a/nodescraper/utils.py b/nodescraper/utils.py index de3a0956..96dd093a 100644 --- a/nodescraper/utils.py +++ b/nodescraper/utils.py @@ -71,6 +71,25 @@ def get_exception_details(exception: Exception) -> dict: } +def str_or_none(val: object) -> Optional[str]: + """Return a stripped string or None. + + None input, or a string that is empty/whitespace after stripping, becomes None. + Non-string values are converted to string then stripped. Useful for normalizing + values from JSON, dicts, or user input into Optional[str] for model fields. + + Args: + val: Any value (e.g. str, int, None). + + Returns: + Stripped non-empty string, or None. + """ + if val is None: + return None + s = val.strip() if isinstance(val, str) else str(val).strip() + return s if s else None + + def convert_to_bytes(value: str, si=False) -> int: """ Convert human-readable memory sizes (like GB, MB) to bytes. @@ -150,26 +169,23 @@ def pascal_to_snake(input_str: str) -> str: def bytes_to_human_readable(input_bytes: int) -> str: - """converts a bytes int to a human readable sting in KB, MB, or GB + """Converts a bytes int to a human-readable string in B, KB, MB, GB, TB, or PB (decimal). Args: input_bytes (int): bytes integer Returns: - str: human readable string + str: human-readable string (e.g. "8.25KB", "7.68TB") """ - kb = round(float(input_bytes) / 1000, 2) - - if kb < 1000: - return f"{kb}KB" - - mb = round(kb / 1000, 2) - - if mb < 1000: - return f"{mb}MB" - - gb = round(mb / 1000, 2) - return f"{gb}GB" + if input_bytes < 0: + return "0B" + if input_bytes == 0: + return "0B" + units = [(10**12, "TB"), (10**9, "GB"), (10**6, "MB"), (10**3, "KB"), (1, "B")] + for scale, label in units: + if input_bytes >= scale: + return f"{round(float(input_bytes) / scale, 2)}{label}" + return "0B" def find_annotation_in_container( From 5a45382b0d3330168771c55a093360f482df4f21 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Tue, 24 Feb 2026 17:37:29 -0600 Subject: [PATCH 4/8] truncate message --- nodescraper/plugins/inband/nvme/nvme_collector.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nodescraper/plugins/inband/nvme/nvme_collector.py b/nodescraper/plugins/inband/nvme/nvme_collector.py index e4338c63..65970768 100644 --- a/nodescraper/plugins/inband/nvme/nvme_collector.py +++ b/nodescraper/plugins/inband/nvme/nvme_collector.py @@ -154,7 +154,10 @@ def collect_data( self._log_event( category=EventCategory.SW_DRIVER, description="Collected NVMe data", - data=nvme_data.model_dump(), + data={ + "devices": list(nvme_data.devices.keys()), + "nvme_list_entries": len(nvme_data.nvme_list or []), + }, priority=EventPriority.INFO, ) self.result.message = "NVMe data successfully collected" From 5580e362ef8dadd1458cb99ecd83d2441e0e02c4 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Wed, 25 Feb 2026 10:35:58 -0600 Subject: [PATCH 5/8] nested+flat parsing support for diff versions of nvme-cli --- .../plugins/inband/nvme/nvme_collector.py | 55 ++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/nodescraper/plugins/inband/nvme/nvme_collector.py b/nodescraper/plugins/inband/nvme/nvme_collector.py index 65970768..f1d6e876 100644 --- a/nodescraper/plugins/inband/nvme/nvme_collector.py +++ b/nodescraper/plugins/inband/nvme/nvme_collector.py @@ -184,8 +184,9 @@ def _collect_nvme_list_entries(self) -> Optional[list[NvmeListEntry]]: def _parse_nvme_list_json(self, raw: str) -> list[NvmeListEntry]: """Parse 'nvme list -o json' output into NvmeListEntry list. - Walks structure: Devices[] -> Subsystems[] -> Controllers[] -> Namespaces[]. - One NvmeListEntry per namespace (matches table: Node, Generic, SN, Model, etc.). + Supports two formats: + - Nested: Devices[] -> Subsystems[] -> Controllers[] -> Namespaces[]. + - Flat: Devices[] where each element has DevicePath, SerialNumber, ModelNumber, etc. """ try: data = json.loads(raw) @@ -194,6 +195,56 @@ def _parse_nvme_list_json(self, raw: str) -> list[NvmeListEntry]: devices = data.get("Devices", []) if isinstance(data, dict) else [] if not isinstance(devices, list): return [] + entries = self._parse_nvme_list_nested(devices) + if not entries and devices: + entries = self._parse_nvme_list_flat(devices) + return entries + + def _parse_nvme_list_flat(self, devices: list) -> list[NvmeListEntry]: + """Parse flat 'nvme list -o json' format (one object per namespace in Devices[]).""" + entries = [] + for dev in devices: + if not isinstance(dev, dict): + continue + if dev.get("DevicePath") is None and dev.get("SerialNumber") is None: + continue + node = str_or_none(dev.get("DevicePath")) + generic_path = str_or_none(dev.get("GenericPath")) + serial_number = str_or_none(dev.get("SerialNumber")) + model = str_or_none(dev.get("ModelNumber")) + fw_rev = str_or_none(dev.get("Firmware")) + name_space = dev.get("NameSpace") or dev.get("NameSpaceId") + nsid = name_space if name_space is not None else dev.get("NSID") + namespace_id = ( + f"0x{int(nsid):x}" if isinstance(nsid, (int, float)) else str_or_none(nsid) + ) + used_bytes = dev.get("UsedBytes") + physical_size = dev.get("PhysicalSize") + sector_size = dev.get("SectorSize") + if isinstance(used_bytes, (int, float)) and isinstance(physical_size, (int, float)): + usage = ( + f"{bytes_to_human_readable(int(used_bytes))} / " + f"{bytes_to_human_readable(int(physical_size))}" + ) + else: + usage = None + format_lba = f"{sector_size} B + 0 B" if sector_size is not None else None + entries.append( + NvmeListEntry( + node=node, + generic=generic_path, + serial_number=serial_number, + model=model, + namespace_id=namespace_id, + usage=usage, + format_lba=format_lba, + fw_rev=fw_rev, + ) + ) + return entries + + def _parse_nvme_list_nested(self, devices: list) -> list[NvmeListEntry]: + """Parse nested 'nvme list -o json' format (Devices -> Subsystems -> Controllers -> Namespaces).""" entries = [] for dev in devices: if not isinstance(dev, dict): From a5bdf5c792397d303666028425591f45dcc25d2f Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Wed, 25 Feb 2026 10:56:00 -0600 Subject: [PATCH 6/8] event log if parsing fails --- nodescraper/plugins/inband/nvme/nvme_collector.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/nodescraper/plugins/inband/nvme/nvme_collector.py b/nodescraper/plugins/inband/nvme/nvme_collector.py index f1d6e876..ea47c0ce 100644 --- a/nodescraper/plugins/inband/nvme/nvme_collector.py +++ b/nodescraper/plugins/inband/nvme/nvme_collector.py @@ -178,7 +178,14 @@ def _collect_nvme_list_entries(self) -> Optional[list[NvmeListEntry]]: """Run 'nvme list -o json' and parse output into list of NvmeListEntry.""" res = self._run_sut_cmd(self.CMD_LINUX_LIST_JSON, sudo=False) if res.exit_code == 0 and res.stdout: - return self._parse_nvme_list_json(res.stdout.strip()) + entries = self._parse_nvme_list_json(res.stdout.strip()) + if not entries: + self._log_event( + category=EventCategory.SW_DRIVER, + description="Parsing of 'nvme list -o json' output failed (no entries from nested or flat format)", + priority=EventPriority.WARNING, + ) + return entries return None def _parse_nvme_list_json(self, raw: str) -> list[NvmeListEntry]: From 687b357df4d6f02618b4a42b2991979d3abb706e Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Wed, 25 Feb 2026 11:06:05 -0600 Subject: [PATCH 7/8] utest enhanced --- test/unit/plugin/test_nvme_collector.py | 122 +++++++++++++++++++++++- 1 file changed, 120 insertions(+), 2 deletions(-) diff --git a/test/unit/plugin/test_nvme_collector.py b/test/unit/plugin/test_nvme_collector.py index 033c7cb3..44a9e649 100644 --- a/test/unit/plugin/test_nvme_collector.py +++ b/test/unit/plugin/test_nvme_collector.py @@ -27,11 +27,11 @@ import pytest -from nodescraper.enums import EventPriority, ExecutionStatus, OSFamily +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus, OSFamily from nodescraper.enums.systeminteraction import SystemInteractionLevel from nodescraper.models import TaskResult from nodescraper.plugins.inband.nvme.nvme_collector import NvmeCollector -from nodescraper.plugins.inband.nvme.nvmedata import NvmeDataModel +from nodescraper.plugins.inband.nvme.nvmedata import NvmeDataModel, NvmeListEntry @pytest.fixture @@ -142,3 +142,121 @@ def test_get_nvme_devices_filters_partitions(collector): devices = collector._get_nvme_devices() assert devices == ["/dev/nvme0", "/dev/nvme1", "/dev/nvme2"] + + +FLAT_NVME_LIST_JSON = """{ + "Devices": [ + { + "NameSpace": 1, + "DevicePath": "/dev/nvme0n1", + "GenericPath": "/dev/ng0n1", + "Firmware": "FW-DUMMY-01", + "ModelNumber": "TEST_MODEL_A", + "SerialNumber": "SN-DUMMY-001", + "UsedBytes": 2097152, + "MaximumLBA": 15002931888, + "PhysicalSize": 7681501126656, + "SectorSize": 512 + }, + { + "NameSpace": 1, + "DevicePath": "/dev/nvme1n1", + "GenericPath": "/dev/ng1n1", + "Firmware": "FW-DUMMY-01", + "ModelNumber": "TEST_MODEL_A", + "SerialNumber": "SN-DUMMY-002", + "UsedBytes": 2097152, + "MaximumLBA": 15002931888, + "PhysicalSize": 7681501126656, + "SectorSize": 512 + } + ] +}""" + + +def test_parse_nvme_list_json_flat_format(collector): + entries = collector._parse_nvme_list_json(FLAT_NVME_LIST_JSON) + assert len(entries) == 2 + e0, e1 = entries + assert isinstance(e0, NvmeListEntry) + assert e0.node == "/dev/nvme0n1" + assert e0.generic == "/dev/ng0n1" + assert e0.serial_number == "SN-DUMMY-001" + assert e0.model == "TEST_MODEL_A" + assert e0.fw_rev == "FW-DUMMY-01" + assert e0.namespace_id == "0x1" + assert "2.00 MiB" in (e0.usage or "") + assert "7.00 TiB" in (e0.usage or "") + assert e0.format_lba == "512 B + 0 B" + assert e1.node == "/dev/nvme1n1" + assert e1.serial_number == "SN-DUMMY-002" + + +def test_parse_nvme_list_json_nested_format(collector): + nested_json = """{ + "Devices": [{ + "Subsystems": [{ + "Controllers": [{ + "SerialNumber": "SN-DUMMY-NESTED", + "ModelNumber": "TEST_MODEL_NESTED", + "Firmware": "FW-DUMMY", + "Namespaces": [{ + "NameSpace": "nvme0n1", + "Generic": "ng0n1", + "NSID": 1, + "UsedBytes": 1000, + "PhysicalSize": 2000, + "SectorSize": 512 + }] + }] + }] + }] + }""" + entries = collector._parse_nvme_list_json(nested_json) + assert len(entries) == 1 + e = entries[0] + assert e.serial_number == "SN-DUMMY-NESTED" + assert e.model == "TEST_MODEL_NESTED" + assert e.fw_rev == "FW-DUMMY" + assert e.namespace_id == "0x1" + assert e.node == "/dev/nvme0n1" + assert e.generic == "/dev/ng0n1" + + +def test_parse_nvme_list_json_invalid_returns_empty(collector): + assert collector._parse_nvme_list_json("not json") == [] + assert collector._parse_nvme_list_json("{}") == [] + assert collector._parse_nvme_list_json('{"Devices": null}') == [] + assert collector._parse_nvme_list_json('{"Devices": []}') == [] + + +def test_collect_nvme_list_entries_calls_nvme_list_json(collector): + collector._run_sut_cmd.return_value = MagicMock( + exit_code=0, + stdout=FLAT_NVME_LIST_JSON.strip(), + ) + entries = collector._collect_nvme_list_entries() + assert entries is not None + assert len(entries) == 2 + collector._run_sut_cmd.assert_called_once_with("nvme list -o json", sudo=False) + + +def test_collect_nvme_list_entries_parsing_failed_logs_event(collector): + collector._run_sut_cmd.return_value = MagicMock( + exit_code=0, + stdout='{"Devices": []}', + ) + entries = collector._collect_nvme_list_entries() + assert entries == [] + collector._log_event.assert_called_once() + call = collector._log_event.call_args + assert call.kwargs["category"] == EventCategory.SW_DRIVER + assert "Parsing of 'nvme list -o json' output failed" in call.kwargs["description"] + assert call.kwargs["priority"] == EventPriority.WARNING + + +def test_collect_nvme_list_entries_fail_or_empty_stdout_returns_none(collector): + collector._run_sut_cmd.return_value = MagicMock(exit_code=1, stdout="") + assert collector._collect_nvme_list_entries() is None + collector._run_sut_cmd.return_value = MagicMock(exit_code=0, stdout="") + assert collector._collect_nvme_list_entries() is None From 63b62a4bba07355c216dc266151c34b3cef0293b Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Thu, 26 Feb 2026 10:41:03 -0600 Subject: [PATCH 8/8] utest fix --- test/unit/plugin/test_nvme_collector.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/plugin/test_nvme_collector.py b/test/unit/plugin/test_nvme_collector.py index 44a9e649..1d0e819b 100644 --- a/test/unit/plugin/test_nvme_collector.py +++ b/test/unit/plugin/test_nvme_collector.py @@ -185,8 +185,8 @@ def test_parse_nvme_list_json_flat_format(collector): assert e0.model == "TEST_MODEL_A" assert e0.fw_rev == "FW-DUMMY-01" assert e0.namespace_id == "0x1" - assert "2.00 MiB" in (e0.usage or "") - assert "7.00 TiB" in (e0.usage or "") + assert e0.usage and "/" in e0.usage + assert "2.1MB" in (e0.usage or "") and "7.68TB" in (e0.usage or "") assert e0.format_lba == "512 B + 0 B" assert e1.node == "/dev/nvme1n1" assert e1.serial_number == "SN-DUMMY-002"