Skip to content
11 changes: 11 additions & 0 deletions nodescraper/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,14 @@ def _add_cli_root_globals(
help="Skip plugins that require sudo permissions",
)

parser.add_argument(
"--html-artifact",
dest="html_artifact",
action="store_true",
help="Generate browsable HTML artifacts (command_artifacts.html) from "
"command_artifacts.json files in the run directory. Disabled by default.",
)


def build_global_argument_parser(*, add_help: bool = True) -> argparse.ArgumentParser:
"""Globals only (no subcommands), for host CLIs."""
Expand Down Expand Up @@ -633,6 +641,9 @@ def main(
"skip_sudo"
] = True

if getattr(parsed_args, "html_artifact", False) and plugin_config_inst_list:
plugin_config_inst_list[-1].result_collators.setdefault("CommandArtifactHtml", {})

except Exception as e:
parser.error(str(e))

Expand Down
34 changes: 29 additions & 5 deletions nodescraper/connection/inband/inbandmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from .inband import InBandConnection
from .inbandlocal import LocalShell
from .inbandremote import RemoteShell, SSHConnectionError
from .osdetection import NetworkOsDetection, detect_network_os
from .sshparams import SSHConnectionParams


Expand All @@ -68,6 +69,18 @@ def __init__(
**kwargs,
)

@staticmethod
def _apply_network_os_detection(
system_info: SystemInfo,
detection: NetworkOsDetection,
) -> None:
"""Apply network OS probe results to system info."""
system_info.os_family = detection.os_family
system_info.platform = detection.platform
if system_info.metadata is None:
system_info.metadata = {}
system_info.metadata.update(detection.metadata)

def _check_os_family(self):
"""Check the OS family of the system under test (SUT)

Expand All @@ -84,12 +97,23 @@ def _check_os_family(self):
elif res.exit_code == 0:
self.system_info.os_family = OSFamily.LINUX
else:
self._log_event(
category=EventCategory.UNKNOWN,
description="Unable to determine SUT OS",
priority=EventPriority.WARNING,
detection = detect_network_os(self.connection)
if detection is not None:
self._apply_network_os_detection(self.system_info, detection)
else:
self._log_event(
category=EventCategory.UNKNOWN,
description="Unable to determine SUT OS",
priority=EventPriority.WARNING,
)
if self.system_info.platform:
self.logger.info(
"OS Family: %s (%s)",
self.system_info.os_family.name,
self.system_info.platform,
)
self.logger.info("OS Family: %s", self.system_info.os_family.name)
else:
self.logger.info("OS Family: %s", self.system_info.os_family.name)

def connect(
self,
Expand Down
152 changes: 152 additions & 0 deletions nodescraper/connection/inband/osdetection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
###############################################################################
#
# MIT License
#
# Copyright (c) 2026 Advanced Micro Devices, Inc.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
###############################################################################
import json
import re
from dataclasses import dataclass
from typing import Optional

from nodescraper.enums import OSFamily

from .inband import InBandConnection

ARISTA_VERSION_CMD = "show version | json | no-more"
DELL_VERSION_CMD = 'sonic-cli -c "show version | no-more"'

_DELL_VERSION_PATTERNS = (
re.compile(r"SONiC Software Version:\s*(.+)", re.IGNORECASE),
re.compile(r"SONiC OS Version:\s*(.+)", re.IGNORECASE),
)
_DELL_MODEL_PATTERNS = (
re.compile(r"HwSKU:\s*(.+)", re.IGNORECASE),
re.compile(r"Model Number:\s*(.+)", re.IGNORECASE),
re.compile(r"Platform:\s*(.+)", re.IGNORECASE),
)


@dataclass(frozen=True)
class NetworkOsDetection:
"""Detected network operating system details."""

os_family: OSFamily
platform: str
metadata: dict[str, str]


def _first_regex_match(patterns: tuple[re.Pattern[str], ...], text: str) -> Optional[str]:
"""Return the first captured group from the first matching regex pattern."""
for pattern in patterns:
match = pattern.search(text)
if match:
return match.group(1).strip()
return None


def parse_arista_version_output(stdout: str) -> Optional[NetworkOsDetection]:
"""Parse Arista EOS ``show version | json`` output into detection details.

Args:
stdout: Command stdout containing JSON version data.

Returns:
NetworkOsDetection when the output identifies an Arista device, else None.
"""
try:
data = json.loads(stdout)
except json.JSONDecodeError:
return None

if not isinstance(data, dict):
return None

mfg_name = str(data.get("mfgName") or data.get("mfg_name") or "")
if "arista" not in mfg_name.lower():
return None

metadata: dict[str, str] = {}
version = data.get("version")
if version:
metadata["os_version"] = str(version)
model_name = data.get("modelName") or data.get("model_name")
if model_name:
metadata["device_model"] = str(model_name)

return NetworkOsDetection(
os_family=OSFamily.EOS,
platform="Arista EOS",
metadata=metadata,
)


def parse_dell_sonic_version_output(stdout: str) -> Optional[NetworkOsDetection]:
"""Parse Dell SONiC ``show version`` text output into detection details.

Args:
stdout: Command stdout containing version text.

Returns:
NetworkOsDetection when the output identifies a Dell SONiC device, else None.
"""
lowered = stdout.lower()
if not all(marker in lowered for marker in ("dell", "sonic")):
return None

metadata: dict[str, str] = {}
version = _first_regex_match(_DELL_VERSION_PATTERNS, stdout)
if version:
metadata["os_version"] = version
model = _first_regex_match(_DELL_MODEL_PATTERNS, stdout)
if model:
metadata["device_model"] = model

return NetworkOsDetection(
os_family=OSFamily.SONIC,
platform="Dell SONiC",
metadata=metadata,
)


def detect_network_os(connection: InBandConnection) -> Optional[NetworkOsDetection]:
"""Probe a network device for Arista EOS or Dell SONiC after uname fails.

Args:
connection: Active in-band connection to the target device.

Returns:
NetworkOsDetection when a supported network OS is identified, else None.
"""
arista_res = connection.run_command(ARISTA_VERSION_CMD, timeout=30)
if arista_res.exit_code == 0:
detection = parse_arista_version_output(arista_res.stdout)
if detection is not None:
return detection

dell_res = connection.run_command(DELL_VERSION_CMD, timeout=30)
if dell_res.exit_code == 0:
detection = parse_dell_sonic_version_output(dell_res.stdout)
if detection is not None:
return detection

return None
3 changes: 3 additions & 0 deletions nodescraper/enums/eventcategory.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ class EventCategory(AutoNameStrEnum):
Network, IT issues, Downtime
- NETWORK
Network configuration, interfaces, routing, neighbors, ethtool data
- SWITCH
Switch configuration, switch OS, command issues
- TELEMETRY
Telemetry / monitored data checks (e.g. Redfish endpoint constraint violations)
- RUNTIME
Expand All @@ -87,6 +89,7 @@ class EventCategory(AutoNameStrEnum):
BIOS = auto()
INFRASTRUCTURE = auto()
NETWORK = auto()
SWITCH = auto()
TELEMETRY = auto()
RUNTIME = auto()
UNKNOWN = auto()
2 changes: 2 additions & 0 deletions nodescraper/enums/osfamily.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ class OSFamily(enum.Enum):
WINDOWS = enum.auto()
UNKNOWN = enum.auto()
LINUX = enum.auto()
EOS = enum.auto()
SONIC = enum.auto()
6 changes: 5 additions & 1 deletion nodescraper/interfaces/dataplugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,11 @@ def collect(
):
self.connection_manager.connect()

if self.connection_manager.result.status != ExecutionStatus.OK:
# Proceed as long as a connection was established.
if (
self.connection_manager.connection is None
or self.connection_manager.result.status >= ExecutionStatus.ERROR
):
self.collection_result = TaskResult(
task=primary_collector.__name__,
parent=self.__class__.__name__,
Expand Down
29 changes: 29 additions & 0 deletions nodescraper/plugins/inband/switch/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
###############################################################################
#
# MIT License
#
# Copyright (c) 2026 Advanced Micro Devices, Inc.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
###############################################################################
from .scale_out_arista import ScaleOutAristaPlugin
from .scale_out_dell import ScaleOutDellPlugin

__all__ = ["ScaleOutAristaPlugin", "ScaleOutDellPlugin"]
28 changes: 28 additions & 0 deletions nodescraper/plugins/inband/switch/scale_out_arista/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
###############################################################################
#
# MIT License
#
# Copyright (c) 2026 Advanced Micro Devices, Inc.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
###############################################################################
from .scale_out_arista_plugin import ScaleOutAristaPlugin

__all__ = ["ScaleOutAristaPlugin"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
###############################################################################
#
# MIT License
#
# Copyright (c) 2026 Advanced Micro Devices, Inc.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
###############################################################################
from typing import List, Optional

from pydantic import Field

from nodescraper.models import AnalyzerArgs


class ScaleOutAristaAnalyzerArgs(AnalyzerArgs):
"""Arguments for the Arista switch analyzer."""

analysis_ports: Optional[List[str]] = Field(
default=None,
description=(
"Restrict per-port analysis to the given ports. Ports are "
"S/P/[SP] where subport is optional (e.g. ['1/1', '1/31', '1/1/1']) "
"When omitted, every port present in the data is analyzed."
"Independent of any collection-time filter."
),
)
expected_port_bandwidth: int = Field(
default=400000000000,
description=(
"Expected interface bandwidth (bps) from show interfaces status "
"(AristaPortStatus.bandwidth). Ports with a different bandwidth are flagged."
),
)
Loading
Loading