Skip to content

Commit 861c319

Browse files
Merge branch 'development' into bargajda_cursor_test
2 parents fe8f1d6 + a5eb699 commit 861c319

4 files changed

Lines changed: 369 additions & 25 deletions

File tree

nodescraper/plugins/inband/nvme/nvme_collector.py

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
# SOFTWARE.
2424
#
2525
###############################################################################
26+
import json
2627
import os
2728
import re
2829
from typing import Optional
@@ -32,14 +33,16 @@
3233
from nodescraper.base import InBandDataCollector
3334
from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus, OSFamily
3435
from nodescraper.models import TaskResult
36+
from nodescraper.utils import bytes_to_human_readable, str_or_none
3537

36-
from .nvmedata import NvmeDataModel
38+
from .nvmedata import NvmeDataModel, NvmeListEntry
3739

3840

3941
class NvmeCollector(InBandDataCollector[NvmeDataModel, None]):
4042
"""Collect NVMe details from the system."""
4143

4244
DATA_MODEL = NvmeDataModel
45+
CMD_LINUX_LIST_JSON = "nvme list -o json"
4346
CMD_LINUX = {
4447
"smart_log": "nvme smart-log {dev}",
4548
"error_log": "nvme error-log {dev} --log-entries=256",
@@ -54,6 +57,15 @@ class NvmeCollector(InBandDataCollector[NvmeDataModel, None]):
5457

5558
TELEMETRY_FILENAME = "telemetry_log.bin"
5659

60+
def _check_nvme_cli_installed(self) -> bool:
61+
"""Check if the nvme CLI is installed on the system.
62+
63+
Returns:
64+
bool: True if nvme is available, False otherwise.
65+
"""
66+
res = self._run_sut_cmd("which nvme")
67+
return res.exit_code == 0 and bool(res.stdout.strip())
68+
5769
def collect_data(
5870
self,
5971
args=None,
@@ -73,6 +85,18 @@ def collect_data(
7385
self.result.status = ExecutionStatus.NOT_RAN
7486
return self.result, None
7587

88+
if not self._check_nvme_cli_installed():
89+
self._log_event(
90+
category=EventCategory.SW_DRIVER,
91+
description="nvme CLI not found; install nvme-cli to collect NVMe data",
92+
priority=EventPriority.WARNING,
93+
)
94+
self.result.message = "nvme CLI not found; NVMe collection skipped"
95+
self.result.status = ExecutionStatus.NOT_RAN
96+
return self.result, None
97+
98+
nvme_list_entries = self._collect_nvme_list_entries()
99+
76100
nvme_devices = self._get_nvme_devices()
77101
if not nvme_devices:
78102
self._log_event(
@@ -115,7 +139,7 @@ def collect_data(
115139

116140
if all_device_data:
117141
try:
118-
nvme_data = NvmeDataModel(devices=all_device_data)
142+
nvme_data = NvmeDataModel(nvme_list=nvme_list_entries, devices=all_device_data)
119143
except ValidationError as exp:
120144
self._log_event(
121145
category=EventCategory.SW_DRIVER,
@@ -130,7 +154,10 @@ def collect_data(
130154
self._log_event(
131155
category=EventCategory.SW_DRIVER,
132156
description="Collected NVMe data",
133-
data=nvme_data.model_dump(),
157+
data={
158+
"devices": list(nvme_data.devices.keys()),
159+
"nvme_list_entries": len(nvme_data.nvme_list or []),
160+
},
134161
priority=EventPriority.INFO,
135162
)
136163
self.result.message = "NVMe data successfully collected"
@@ -147,6 +174,140 @@ def collect_data(
147174
self.result.status = ExecutionStatus.ERROR
148175
return self.result, None
149176

177+
def _collect_nvme_list_entries(self) -> Optional[list[NvmeListEntry]]:
178+
"""Run 'nvme list -o json' and parse output into list of NvmeListEntry."""
179+
res = self._run_sut_cmd(self.CMD_LINUX_LIST_JSON, sudo=False)
180+
if res.exit_code == 0 and res.stdout:
181+
entries = self._parse_nvme_list_json(res.stdout.strip())
182+
if not entries:
183+
self._log_event(
184+
category=EventCategory.SW_DRIVER,
185+
description="Parsing of 'nvme list -o json' output failed (no entries from nested or flat format)",
186+
priority=EventPriority.WARNING,
187+
)
188+
return entries
189+
return None
190+
191+
def _parse_nvme_list_json(self, raw: str) -> list[NvmeListEntry]:
192+
"""Parse 'nvme list -o json' output into NvmeListEntry list.
193+
194+
Supports two formats:
195+
- Nested: Devices[] -> Subsystems[] -> Controllers[] -> Namespaces[].
196+
- Flat: Devices[] where each element has DevicePath, SerialNumber, ModelNumber, etc.
197+
"""
198+
try:
199+
data = json.loads(raw)
200+
except json.JSONDecodeError:
201+
return []
202+
devices = data.get("Devices", []) if isinstance(data, dict) else []
203+
if not isinstance(devices, list):
204+
return []
205+
entries = self._parse_nvme_list_nested(devices)
206+
if not entries and devices:
207+
entries = self._parse_nvme_list_flat(devices)
208+
return entries
209+
210+
def _parse_nvme_list_flat(self, devices: list) -> list[NvmeListEntry]:
211+
"""Parse flat 'nvme list -o json' format (one object per namespace in Devices[])."""
212+
entries = []
213+
for dev in devices:
214+
if not isinstance(dev, dict):
215+
continue
216+
if dev.get("DevicePath") is None and dev.get("SerialNumber") is None:
217+
continue
218+
node = str_or_none(dev.get("DevicePath"))
219+
generic_path = str_or_none(dev.get("GenericPath"))
220+
serial_number = str_or_none(dev.get("SerialNumber"))
221+
model = str_or_none(dev.get("ModelNumber"))
222+
fw_rev = str_or_none(dev.get("Firmware"))
223+
name_space = dev.get("NameSpace") or dev.get("NameSpaceId")
224+
nsid = name_space if name_space is not None else dev.get("NSID")
225+
namespace_id = (
226+
f"0x{int(nsid):x}" if isinstance(nsid, (int, float)) else str_or_none(nsid)
227+
)
228+
used_bytes = dev.get("UsedBytes")
229+
physical_size = dev.get("PhysicalSize")
230+
sector_size = dev.get("SectorSize")
231+
if isinstance(used_bytes, (int, float)) and isinstance(physical_size, (int, float)):
232+
usage = (
233+
f"{bytes_to_human_readable(int(used_bytes))} / "
234+
f"{bytes_to_human_readable(int(physical_size))}"
235+
)
236+
else:
237+
usage = None
238+
format_lba = f"{sector_size} B + 0 B" if sector_size is not None else None
239+
entries.append(
240+
NvmeListEntry(
241+
node=node,
242+
generic=generic_path,
243+
serial_number=serial_number,
244+
model=model,
245+
namespace_id=namespace_id,
246+
usage=usage,
247+
format_lba=format_lba,
248+
fw_rev=fw_rev,
249+
)
250+
)
251+
return entries
252+
253+
def _parse_nvme_list_nested(self, devices: list) -> list[NvmeListEntry]:
254+
"""Parse nested 'nvme list -o json' format (Devices -> Subsystems -> Controllers -> Namespaces)."""
255+
entries = []
256+
for dev in devices:
257+
if not isinstance(dev, dict):
258+
continue
259+
subsystems = dev.get("Subsystems") or []
260+
for subsys in subsystems:
261+
if not isinstance(subsys, dict):
262+
continue
263+
controllers = subsys.get("Controllers") or []
264+
for ctrl in controllers:
265+
if not isinstance(ctrl, dict):
266+
continue
267+
serial_number = str_or_none(ctrl.get("SerialNumber"))
268+
model = str_or_none(ctrl.get("ModelNumber"))
269+
fw_rev = str_or_none(ctrl.get("Firmware"))
270+
namespaces = ctrl.get("Namespaces") or []
271+
for ns in namespaces:
272+
if not isinstance(ns, dict):
273+
continue
274+
name_space = ns.get("NameSpace") or ns.get("NameSpaceId")
275+
generic = ns.get("Generic")
276+
nsid = ns.get("NSID")
277+
used_bytes = ns.get("UsedBytes")
278+
physical_size = ns.get("PhysicalSize")
279+
sector_size = ns.get("SectorSize")
280+
node = f"/dev/{name_space}" if name_space else None
281+
generic_path = (
282+
f"/dev/{generic}" if (generic and str(generic).strip()) else None
283+
)
284+
namespace_id = f"0x{nsid:x}" if isinstance(nsid, int) else str_or_none(nsid)
285+
if isinstance(used_bytes, (int, float)) and isinstance(
286+
physical_size, (int, float)
287+
):
288+
usage = (
289+
f"{bytes_to_human_readable(int(used_bytes))} / "
290+
f"{bytes_to_human_readable(int(physical_size))}"
291+
)
292+
else:
293+
usage = None
294+
format_lba = (
295+
f"{sector_size} B + 0 B" if sector_size is not None else None
296+
)
297+
entries.append(
298+
NvmeListEntry(
299+
node=str_or_none(node),
300+
generic=str_or_none(generic_path),
301+
serial_number=serial_number,
302+
model=model,
303+
namespace_id=namespace_id,
304+
usage=usage,
305+
format_lba=format_lba,
306+
fw_rev=fw_rev,
307+
)
308+
)
309+
return entries
310+
150311
def _get_nvme_devices(self) -> list[str]:
151312
nvme_devs = []
152313

nodescraper/plugins/inband/nvme/nvmedata.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,28 @@
2525
###############################################################################
2626
from typing import Optional
2727

28-
from pydantic import BaseModel
28+
from pydantic import BaseModel, Field
2929

3030
from nodescraper.models import DataModel
3131

3232

33+
class NvmeListEntry(BaseModel):
34+
"""One row from 'nvme list': a single NVMe device/namespace line."""
35+
36+
node: Optional[str] = Field(default=None, description="Device node path (e.g. /dev/nvme0n1).")
37+
generic: Optional[str] = Field(
38+
default=None, description="Generic device node (e.g. /dev/ng0n1)."
39+
)
40+
serial_number: Optional[str] = Field(default=None, description="Serial number (SN).")
41+
model: Optional[str] = Field(default=None, description="Model name.")
42+
namespace_id: Optional[str] = Field(default=None, description="Namespace ID.")
43+
usage: Optional[str] = Field(default=None, description="Usage (e.g. capacity).")
44+
format_lba: Optional[str] = Field(
45+
default=None, description="LBA format (sector size + metadata)."
46+
)
47+
fw_rev: Optional[str] = Field(default=None, description="Firmware revision.")
48+
49+
3350
class DeviceNvmeData(BaseModel):
3451
smart_log: Optional[str] = None
3552
error_log: Optional[str] = None
@@ -42,4 +59,10 @@ class DeviceNvmeData(BaseModel):
4259

4360

4461
class NvmeDataModel(DataModel):
45-
devices: dict[str, DeviceNvmeData]
62+
"""NVMe collection output: parsed 'nvme list' entries and per-device command outputs."""
63+
64+
nvme_list: Optional[list[NvmeListEntry]] = Field(
65+
default=None,
66+
description="Parsed list of NVMe devices from 'nvme list'.",
67+
)
68+
devices: dict[str, DeviceNvmeData] = Field(default_factory=dict)

nodescraper/utils.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,25 @@ def get_exception_details(exception: Exception) -> dict:
7171
}
7272

7373

74+
def str_or_none(val: object) -> Optional[str]:
75+
"""Return a stripped string or None.
76+
77+
None input, or a string that is empty/whitespace after stripping, becomes None.
78+
Non-string values are converted to string then stripped. Useful for normalizing
79+
values from JSON, dicts, or user input into Optional[str] for model fields.
80+
81+
Args:
82+
val: Any value (e.g. str, int, None).
83+
84+
Returns:
85+
Stripped non-empty string, or None.
86+
"""
87+
if val is None:
88+
return None
89+
s = val.strip() if isinstance(val, str) else str(val).strip()
90+
return s if s else None
91+
92+
7493
def convert_to_bytes(value: str, si=False) -> int:
7594
"""
7695
Convert human-readable memory sizes (like GB, MB) to bytes.
@@ -150,26 +169,23 @@ def pascal_to_snake(input_str: str) -> str:
150169

151170

152171
def bytes_to_human_readable(input_bytes: int) -> str:
153-
"""converts a bytes int to a human readable sting in KB, MB, or GB
172+
"""Converts a bytes int to a human-readable string in B, KB, MB, GB, TB, or PB (decimal).
154173
155174
Args:
156175
input_bytes (int): bytes integer
157176
158177
Returns:
159-
str: human readable string
178+
str: human-readable string (e.g. "8.25KB", "7.68TB")
160179
"""
161-
kb = round(float(input_bytes) / 1000, 2)
162-
163-
if kb < 1000:
164-
return f"{kb}KB"
165-
166-
mb = round(kb / 1000, 2)
167-
168-
if mb < 1000:
169-
return f"{mb}MB"
170-
171-
gb = round(mb / 1000, 2)
172-
return f"{gb}GB"
180+
if input_bytes < 0:
181+
return "0B"
182+
if input_bytes == 0:
183+
return "0B"
184+
units = [(10**12, "TB"), (10**9, "GB"), (10**6, "MB"), (10**3, "KB"), (1, "B")]
185+
for scale, label in units:
186+
if input_bytes >= scale:
187+
return f"{round(float(input_bytes) / scale, 2)}{label}"
188+
return "0B"
173189

174190

175191
def find_annotation_in_container(

0 commit comments

Comments
 (0)