Skip to content
8 changes: 8 additions & 0 deletions nodescraper/plugins/inband/bios/bios_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
# SOFTWARE.
#
###############################################################################
import re
from typing import Optional

from nodescraper.base import InBandDataCollector
Expand All @@ -35,9 +36,11 @@
class BiosCollector(InBandDataCollector[BiosDataModel, None]):
"""Collect BIOS details"""

SUPPORTED_OS_FAMILY: set[OSFamily] = {OSFamily.WINDOWS, OSFamily.LINUX, OSFamily.ESXI}
DATA_MODEL = BiosDataModel
CMD_WINDOWS = "wmic bios get SMBIOSBIOSVersion /Value"
CMD = "sh -c 'cat /sys/devices/virtual/dmi/id/bios_version'"
CMD_ESXI = "smbiosDump | grep -A5 'BIOS Info (Type 0)' | grep 'Version:' | head -1"

def collect_data(
self,
Expand All @@ -57,6 +60,11 @@ def collect_data(
bios = [line for line in res.stdout.splitlines() if "SMBIOSBIOSVersion=" in line][
0
].split("=")[1]
elif self.system_info.os_family == OSFamily.ESXI:
res = self._run_sut_cmd(self.CMD_ESXI)
if res.exit_code == 0:
match = re.search(r'Version:\s*"?([^"]+)"?', res.stdout)
bios = match.group(1).strip() if match else res.stdout.strip()
else:
res = self._run_sut_cmd(self.CMD)
if res.exit_code == 0:
Expand Down
23 changes: 23 additions & 0 deletions nodescraper/plugins/inband/device_enumeration/collector_args.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import Optional

from pydantic import Field

from nodescraper.models import CollectorArgs


class DeviceEnumerationCollectorArgs(CollectorArgs):
"""Collector args for device enumeration.

On ESXi, GPUs and their SR-IOV VFs are counted by PCI device ID (esxcli has no
device filter). Provide the expected PF/VF device IDs here; when unset the ESXi
GPU/VF counts are skipped. The caller populates these (e.g. from the system SKU).
"""

devid_ep: Optional[int] = Field(
default=None,
description="Expected GPU PF PCI device ID (int, e.g. 0x75a3) for ESXi device counting.",
)
devid_ep_vf: Optional[int] = Field(
default=None,
description="Expected GPU VF PCI device ID (int) for ESXi VF counting.",
)
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,16 @@
from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus, OSFamily
from nodescraper.models import TaskResult

from .collector_args import DeviceEnumerationCollectorArgs
from .deviceenumdata import DeviceEnumerationDataModel


class DeviceEnumerationCollector(InBandDataCollector[DeviceEnumerationDataModel, None]):
class DeviceEnumerationCollector(
InBandDataCollector[DeviceEnumerationDataModel, DeviceEnumerationCollectorArgs]
):
"""Collect CPU and GPU count"""

SUPPORTED_OS_FAMILY: set[OSFamily] = {OSFamily.WINDOWS, OSFamily.LINUX, OSFamily.ESXI}
DATA_MODEL = DeviceEnumerationDataModel

CMD_GPU_COUNT_LINUX = (
Expand All @@ -55,6 +59,17 @@ class DeviceEnumerationCollector(InBandDataCollector[DeviceEnumerationDataModel,
'powershell -Command "(Get-VMHostPartitionableGpu | Measure-Object).Count"'
)

# ESXi busybox `lspci -d` dumps hex instead of filtering, so use esxcli. GPUs are
# counted by device ID (PF vs VF), anchored on "Device ID:" to avoid also matching
# "SubDevice ID:". The match is case-insensitive and tolerates zero-padding
# ("0x744C" / "0x0000744c"); the trailing [^0-9a-f]/$ guard stops a shorter ID from
# matching a longer one (e.g. 744c vs 744cd).
CMD_CPU_COUNT_ESXI = "esxcli hardware cpu global get | awk '/CPU Packages:/ {print $NF}'"
CMD_PCI_COUNT_ESXI = (
"esxcli hardware pci list | "
"grep -iE '^ *Device ID: 0x0*{device_id}([^0-9a-f]|$)' | wc -l"
)

def _warning(
self,
description: str,
Expand All @@ -72,12 +87,47 @@ def _warning(
priority=EventPriority.WARNING,
)

def collect_data(self, args=None) -> tuple[TaskResult, Optional[DeviceEnumerationDataModel]]:
def _parse_count(
self,
res: CommandArtifact,
description: str,
category: EventCategory = EventCategory.PLATFORM,
) -> Optional[int]:
"""Parse a numeric count from command stdout, warning (not raising) on a
non-zero exit or non-numeric output (e.g. an unexpected esxcli/awk result)."""
if res.exit_code != 0:
self._warning(description=description, command=res, category=category)
return None
text = (res.stdout or "").strip()
if not text.isdigit():
self._warning(
description=f"{description} (non-numeric output: {text!r})",
command=res,
category=category,
)
return None
return int(text)

def _esxi_device_count(self, device_id: Optional[int]) -> CommandArtifact:
"""Count PCI devices on ESXi whose Device ID matches ``device_id`` (as hex).

A None id produces an unmatched pattern (count 0) so the caller still gets a
valid CommandArtifact to parse.
"""
hex_id = format(device_id, "x") if device_id is not None else "__unset__"
return self._run_sut_cmd(self.CMD_PCI_COUNT_ESXI.format(device_id=hex_id))

def collect_data(
self, args: Optional[DeviceEnumerationCollectorArgs] = None
) -> tuple[TaskResult, Optional[DeviceEnumerationDataModel]]:
"""
Read CPU and GPU count
On Linux, use lscpu and lspci
On ESXi, use esxcli (GPU/VF counts need args.devid_ep / devid_ep_vf)
On Windows, use WMI and hyper-v cmdlets
"""
if args is None:
args = DeviceEnumerationCollectorArgs()
if self.system_info.os_family == OSFamily.LINUX:
lscpu_res = self._run_sut_cmd(self.CMD_LSCPU_LINUX, log_artifact=False)

Expand All @@ -92,6 +142,17 @@ def collect_data(self, args=None) -> tuple[TaskResult, Optional[DeviceEnumeratio

# Collect lshw output
lshw_res = self._run_sut_cmd(self.CMD_LSHW_LINUX, sudo=True, log_artifact=False)
elif self.system_info.os_family == OSFamily.ESXI:
cpu_count_res = self._run_sut_cmd(self.CMD_CPU_COUNT_ESXI)
if args.devid_ep is None:
self._log_event(
category=EventCategory.PLATFORM,
description="devid_ep not set; cannot count GPUs/VFs on ESXi by device ID",
priority=EventPriority.WARNING,
)
# PFs and (SR-IOV) VFs are distinguished by device ID on ESXi.
gpu_count_res = self._esxi_device_count(args.devid_ep)
vf_count_res = self._esxi_device_count(args.devid_ep_vf)
else:
cpu_count_res = self._run_sut_cmd(self.CMD_CPU_COUNT_WINDOWS)
gpu_count_res = self._run_sut_cmd(self.CMD_GPU_COUNT_WINDOWS)
Expand Down Expand Up @@ -121,24 +182,19 @@ def collect_data(self, args=None) -> tuple[TaskResult, Optional[DeviceEnumeratio
else:
self._warning(description="Cannot collect lscpu output", command=lscpu_res)
else:
if cpu_count_res.exit_code == 0:
device_enum.cpu_count = int(cpu_count_res.stdout)
else:
self._warning(description="Cannot determine CPU count", command=cpu_count_res)
cpu_count = self._parse_count(cpu_count_res, "Cannot determine CPU count")
if cpu_count is not None:
device_enum.cpu_count = cpu_count

if gpu_count_res.exit_code == 0:
device_enum.gpu_count = int(gpu_count_res.stdout)
else:
self._warning(description="Cannot determine GPU count", command=gpu_count_res)
gpu_count = self._parse_count(gpu_count_res, "Cannot determine GPU count")
if gpu_count is not None:
device_enum.gpu_count = gpu_count

if vf_count_res.exit_code == 0:
device_enum.vf_count = int(vf_count_res.stdout)
else:
self._warning(
description="Cannot determine VF count",
command=vf_count_res,
category=EventCategory.SW_DRIVER,
)
vf_count = self._parse_count(
vf_count_res, "Cannot determine VF count", category=EventCategory.SW_DRIVER
)
if vf_count is not None:
device_enum.vf_count = vf_count

# Collect lshw output on Linux
if self.system_info.os_family == OSFamily.LINUX:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,27 @@
from nodescraper.base import InBandDataPlugin

from .analyzer_args import DeviceEnumerationAnalyzerArgs
from .collector_args import DeviceEnumerationCollectorArgs
from .device_enumeration_analyzer import DeviceEnumerationAnalyzer
from .device_enumeration_collector import DeviceEnumerationCollector
from .deviceenumdata import DeviceEnumerationDataModel


class DeviceEnumerationPlugin(
InBandDataPlugin[DeviceEnumerationDataModel, None, DeviceEnumerationAnalyzerArgs]
InBandDataPlugin[
DeviceEnumerationDataModel,
DeviceEnumerationCollectorArgs,
DeviceEnumerationAnalyzerArgs,
]
):
"""Plugin for collection and analysis of BIOS data"""

DATA_MODEL = DeviceEnumerationDataModel

COLLECTOR = DeviceEnumerationCollector

COLLECTOR_ARGS = DeviceEnumerationCollectorArgs

ANALYZER = DeviceEnumerationAnalyzer

ANALYZER_ARGS = DeviceEnumerationAnalyzerArgs
47 changes: 24 additions & 23 deletions nodescraper/plugins/inband/dimm/dimm_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,31 @@
class DimmCollector(InBandDataCollector[DimmDataModel, DimmCollectorArgs]):
"""Collect data on installed DIMMs"""

SUPPORTED_OS_FAMILY: set[OSFamily] = {OSFamily.WINDOWS, OSFamily.LINUX, OSFamily.ESXI}
DATA_MODEL = DimmDataModel

CMD_WINDOWS = "wmic memorychip get Capacity"
CMD = """sh -c 'dmidecode -t 17 | tr -s " " | grep -v "Volatile\\|None\\|Module" | grep Size' 2>/dev/null"""
CMD_ESXI = "smbiosDump | grep -A15 'Memory Device (Type 17)' | grep 'Size:'"
CMD_DMIDECODE_FULL = "dmidecode"

def _parse_dmi_sizes(self, stdout: str) -> str:
"""Build the DIMM summary from 'Size: <n> <unit>' lines (dmidecode on Linux,
smbiosDump on ESXi — both emit the same field format)."""
total = 0
topology: dict[str, int] = {}
size = ""
dimm_size_pattern = re.compile(r"Size:\s+(\d+)\s+([A-Za-z]+)")
for num, unit in dimm_size_pattern.findall(stdout):
size = unit
total += int(num)
key = num + unit
topology[key] = topology.get(key, 0) + 1
if total == 0:
return "0 GB"
dimm_entries = [f"{v} x {k}" for k, v in topology.items()]
return f"{total}{size} @ {' '.join(dimm_entries)}"

def collect_data(
self,
args: Optional[DimmCollectorArgs] = None,
Expand All @@ -70,6 +89,10 @@ def collect_data(
dimm_str = f"{total / 1024 / 1024:.2f}GB @ "
for capacity, count in capacities.items():
dimm_str += f"{count} x {capacity / 1024 / 1024:.2f}GB "
elif self.system_info.os_family == OSFamily.ESXI:
res = self._run_sut_cmd(self.CMD_ESXI)
if res.exit_code == 0:
dimm_str = self._parse_dmi_sizes(res.stdout)
else:
if args.skip_sudo:
self.result.message = "Skipping sudo plugin"
Expand All @@ -96,29 +119,7 @@ def collect_data(

res = self._run_sut_cmd(self.CMD, sudo=True)
if res.exit_code == 0:
total = 0
topology = {}
size = ""
dimm_size_pattern = re.compile(r"Size:\s+(\d+)\s+([A-Za-z]+)")
matches = dimm_size_pattern.findall(res.stdout)
if matches:
for match in matches:
size = match[1]
total += int(match[0])
key = match[0] + match[1]
if not topology.get(key, None):
topology[key] = 1
else:
topology[key] += 1
topology["total"] = total
topology["size"] = size
total_gb = topology.pop("total")
size = topology.pop("size")
if total_gb == 0:
dimm_str = "0 GB"
else:
dimm_entries = [f"{v} x {k}" for k, v in topology.items()]
dimm_str = f"{total_gb}{size} @ {' '.join(dimm_entries)}"
dimm_str = self._parse_dmi_sizes(res.stdout)
if res.exit_code != 0:
self._log_event(
category=EventCategory.OS,
Expand Down
Loading
Loading