diff --git a/nodescraper/plugins/inband/switch/__init__.py b/nodescraper/plugins/inband/switch/__init__.py index ad8bbd8d..41405fe6 100644 --- a/nodescraper/plugins/inband/switch/__init__.py +++ b/nodescraper/plugins/inband/switch/__init__.py @@ -25,5 +25,6 @@ ############################################################################### from .scale_out_arista import ScaleOutAristaPlugin from .scale_out_dell import ScaleOutDellPlugin +from .scale_up_sonic import ScaleUpSonicPlugin -__all__ = ["ScaleOutAristaPlugin", "ScaleOutDellPlugin"] +__all__ = ["ScaleOutAristaPlugin", "ScaleOutDellPlugin", "ScaleUpSonicPlugin"] diff --git a/nodescraper/plugins/inband/switch/scale_up_sonic/__init__.py b/nodescraper/plugins/inband/switch/scale_up_sonic/__init__.py new file mode 100644 index 00000000..bee20831 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_up_sonic/__init__.py @@ -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_up_sonic_plugin import ScaleUpSonicPlugin + +__all__ = ["ScaleUpSonicPlugin"] diff --git a/nodescraper/plugins/inband/switch/scale_up_sonic/analyzer_args.py b/nodescraper/plugins/inband/switch/scale_up_sonic/analyzer_args.py new file mode 100644 index 00000000..4985d2ee --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_up_sonic/analyzer_args.py @@ -0,0 +1,47 @@ +############################################################################### +# +# 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 ScaleUpSonicAnalyzerArgs(AnalyzerArgs): + """Arguments for the scale up SONiC switch analyzer.""" + + analysis_ports: Optional[List[str]] = Field( + default=None, + description=( + "Restrict per-port analysis to the given full port names " + "(e.g. ['Ethernet1', 'Ethernet31']). " + "When omitted, every port present in the data is analyzed." + ), + ) + expected_port_speed: str = Field( + default="400G", + description=("Expected interface speed from show interface status "), + ) diff --git a/nodescraper/plugins/inband/switch/scale_up_sonic/collector_args.py b/nodescraper/plugins/inband/switch/scale_up_sonic/collector_args.py new file mode 100644 index 00000000..4222cd5c --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_up_sonic/collector_args.py @@ -0,0 +1,40 @@ +############################################################################### +# +# 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 pydantic import Field + +from nodescraper.models import CollectorArgs + + +class ScaleUpSonicCollectorArgs(CollectorArgs): + """Arguments for the scale up SONiC switch collector.""" + + html_view: bool = Field( + default=True, + description=( + "When true, include logged command artifacts in command_artifacts.html " + "using human-readable output." + ), + ) diff --git a/nodescraper/plugins/inband/switch/scale_up_sonic/port_names.py b/nodescraper/plugins/inband/switch/scale_up_sonic/port_names.py new file mode 100644 index 00000000..ad9541d6 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_up_sonic/port_names.py @@ -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. +# +############################################################################### + +import re + +PORT_TOKEN_RE = re.compile(r"^(Ethernet\d+)$") diff --git a/nodescraper/plugins/inband/switch/scale_up_sonic/scale_up_sonic_analyzer.py b/nodescraper/plugins/inband/switch/scale_up_sonic/scale_up_sonic_analyzer.py new file mode 100644 index 00000000..d751dd79 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_up_sonic/scale_up_sonic_analyzer.py @@ -0,0 +1,77 @@ +############################################################################### +# +# 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 re +from typing import Any, ClassVar + +from pydantic import BaseModel + +from nodescraper.interfaces import DataAnalyzer + +from ..switch_analyzer_base import SwitchAnalyzerBase +from .analyzer_args import ScaleUpSonicAnalyzerArgs +from .port_names import PORT_TOKEN_RE +from .scaleupsonicdata import ScaleUpSonicDataModel, SonicPortData + + +class ScaleUpSonicAnalyzer( + SwitchAnalyzerBase[ScaleUpSonicDataModel], + DataAnalyzer[ScaleUpSonicDataModel, ScaleUpSonicAnalyzerArgs], +): + """Check scale up SONiC switch data for errors and warnings. + + Walks every model in the collected :class:`ScaleUpSonicDataModel` and checks + each ``error_fields`` / ``warning_fields`` ClassVar against an optional + ``ports`` filter. + """ + + VENDOR_NAME: ClassVar[str] = "SONiC" + DATA_MODEL = ScaleUpSonicDataModel + + PORT_NAME_RE: ClassVar[re.Pattern] = PORT_TOKEN_RE + PORT_FORMAT_HINT: ClassVar[str] = "expected full port name 'Ethernet' (e.g. 'Ethernet1')" + + def _extra_port_findings(self, port_name: str, port_data: BaseModel) -> list[dict[str, Any]]: + if not isinstance(port_data, SonicPortData): + return [] + + args = self._analyzer_args + if not isinstance(args, ScaleUpSonicAnalyzerArgs): + args = ScaleUpSonicAnalyzerArgs() + + status = port_data.interface_status + if status is None: + return [] + + finding = self._port_field_mismatch( + port_name, + "interface_status", + "speed", + status.speed, + args.expected_port_speed, + "SonicInterfaceStatus", + ) + return [finding] if finding else [] diff --git a/nodescraper/plugins/inband/switch/scale_up_sonic/scale_up_sonic_collector.py b/nodescraper/plugins/inband/switch/scale_up_sonic/scale_up_sonic_collector.py new file mode 100644 index 00000000..5930b3d2 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_up_sonic/scale_up_sonic_collector.py @@ -0,0 +1,490 @@ +############################################################################### +# +# 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 re +from typing import Dict, List, Optional, TypedDict + +from pydantic import ValidationError + +from nodescraper.base import InBandDataCollector +from nodescraper.connection.inband import CommandArtifact +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus, OSFamily +from nodescraper.models import TaskResult +from nodescraper.utils import get_exception_details, get_exception_traceback + +from .collector_args import ScaleUpSonicCollectorArgs +from .scaleupsonicdata import ( + ScaleUpSonicDataModel, + SonicFecStatus, + SonicInterfaceCounters, + SonicInterfaceStatus, + SonicPortData, + SonicQueueCounter, +) + + +class _ParsedInterfaceStatusLine(TypedDict): + name: str + lanes: str + speed: str + mtu: Optional[int] + fec: str + alias: str + vlan: str + oper: str + admin: str + type: str + asym_pfc: str + + +# Counter columns are comma grouped and may be reported as ``N/A`` +_COUNT = r"[\d,]+|N/A" + + +class ScaleUpSonicCollector(InBandDataCollector[ScaleUpSonicDataModel, ScaleUpSonicCollectorArgs]): + """Collect scale up SONiC switch data. + + Runs SONiC ``show`` commands over SSH and parses their text output into a + :class:`ScaleUpSonicDataModel`. Unlike the Dell collector, commands are + issued directly and are **not** wrapped in ``sonic-cli -c``. + """ + + SUPPORTED_OS_FAMILY: set[OSFamily] = {OSFamily.SONIC, OSFamily.LINUX, OSFamily.UNKNOWN} + + DATA_MODEL = ScaleUpSonicDataModel + + CMD_VERSION = "show version" + CMD_INTERFACE_STATUS = "show interface status" + CMD_INTERFACE_COUNTERS = "show interface counters" + CMD_FEC_STATUS = "show interface fec status" + CMD_QUEUE_COUNTERS = "show queue counters" + + # Commands run for diagnostics, not parsed into a data model. + CMD_CLOCK = "show clock" + CMD_PLATFORM_SYSEEPROM = "show platform syseeprom" + CMD_PLATFORM_FIRMWARE_DETAIL = "show platform firmware version" + CMD_IP_INTERFACES = "show ip interfaces" + CMD_LLDP_TABLE = "show lldp table" + CMD_LLDP_NEIGHBOR = "show lldp neighbors" + CMD_QUEUE_WATERMARK_UNICAST = "show queue watermark unicast" + CMD_QUEUE_WATERMARK_MULTICAST = "show queue watermark multicast" + CMD_QUEUE_PERSISTENT_WATERMARK_UNICAST = "show queue persistent-watermark unicast" + CMD_QUEUE_PERSISTENT_WATERMARK_MULTICAST = "show queue persistent-watermark multicast" + CMD_PLATFORM_ENVIRONMENT = "show platform summary" + + _INTERFACE_STATUS_LINE_RE = re.compile( + r"^" + r"(?PEthernet\S*)" + r"\s+(?P\S+)" + r"\s+(?P\S+)" + r"\s+(?P\S+)" + r"\s+(?P\S+)" + r"\s+(?P\S+)" + r"\s+(?P\S+)" + r"\s+(?P\S+)" + r"\s+(?P\S+)" + r"\s+(?P\S+)" + r"\s+(?P\S+)" + r"\s*$" + ) + + # Aggregate of the diagnostic CMD_* commands above + ARTIFACT_COMMANDS: list[str] = [ + CMD_CLOCK, + CMD_PLATFORM_SYSEEPROM, + CMD_PLATFORM_FIRMWARE_DETAIL, + CMD_IP_INTERFACES, + CMD_LLDP_TABLE, + CMD_LLDP_NEIGHBOR, + CMD_QUEUE_WATERMARK_UNICAST, + CMD_QUEUE_WATERMARK_MULTICAST, + CMD_QUEUE_PERSISTENT_WATERMARK_UNICAST, + CMD_QUEUE_PERSISTENT_WATERMARK_MULTICAST, + CMD_PLATFORM_ENVIRONMENT, + ] + + # helpers + @staticmethod + def _is_sonic_output(text: str) -> bool: + return "sonic" in text.lower() + + @staticmethod + def _to_int(value: str) -> Optional[int]: + """Convert a comma grouped counter value to an int, or ``None`` if not numeric.""" + try: + return int(value.replace(",", "")) + except ValueError: + return None + + def _run_sonic_command(self, command: str) -> Optional[str]: + """Run a SONiC CLI command directly, without a ``sonic-cli -c`` wrapper. + + Args: + command: The full CLI command to run. + + Returns: + The command stdout, or ``None`` on error. + """ + cmd_ret: CommandArtifact = self._run_sut_cmd(command) + if cmd_ret.exit_code != 0: + self._log_event( + category=EventCategory.SWITCH, + description=f"Error running SONiC command: `{command}`", + data={ + "command": command, + "exit_code": cmd_ret.exit_code, + "stderr": cmd_ret.stderr, + }, + priority=EventPriority.ERROR, + console_log=True, + ) + return None + return cmd_ret.stdout or "" + + # sub-collectors + @classmethod + def _parse_interface_status_line(cls, line: str) -> Optional[_ParsedInterfaceStatusLine]: + """Parse one ``show interface status`` row.""" + stripped = line.strip() + if not stripped.startswith("Ethernet"): + return None + match = cls._INTERFACE_STATUS_LINE_RE.match(stripped) + if not match: + return None + return { + "name": match.group("name"), + "lanes": match.group("lanes"), + "speed": match.group("speed"), + "mtu": cls._to_int(match.group("mtu")), + "fec": match.group("fec"), + "alias": match.group("alias"), + "vlan": match.group("vlan"), + "oper": match.group("oper").lower(), + "admin": match.group("admin").lower(), + "type": match.group("type"), + "asym_pfc": match.group("asym_pfc"), + } + + def get_interface_status(self) -> Optional[Dict[str, SonicInterfaceStatus]]: + """Parse ``show interface status`` into per-port status models. + + Returns: + Mapping of port name to :class:`SonicInterfaceStatus`, or ``None``. + """ + text = self._run_sonic_command(self.CMD_INTERFACE_STATUS) + if text is None: + return None + result: Dict[str, SonicInterfaceStatus] = {} + for line in text.splitlines(): + parsed = self._parse_interface_status_line(line) + if parsed is None: + continue + name = str(parsed["name"]) + try: + result[name] = SonicInterfaceStatus(**parsed) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build SonicInterfaceStatus for {name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + def get_interface_counters(self) -> Optional[Dict[str, SonicInterfaceCounters]]: + """Parse ``show interface counters`` into per-port counter models. + + Returns: + Mapping of port name to :class:`SonicInterfaceCounters`, or ``None``. + """ + text = self._run_sonic_command(self.CMD_INTERFACE_COUNTERS) + if text is None: + return None + line_pattern = re.compile( + r"^(?PEthernet\S*)" + r"\s+(?P\S+)" + rf"\s+(?P{_COUNT})" + r"\s+(?P[\d.,]+\s+\S*B/s)" + r"\s+(?P\S+)" + rf"\s+(?P{_COUNT})" + rf"\s+(?P{_COUNT})" + rf"\s+(?P{_COUNT})" + rf"\s+(?P{_COUNT})" + r"\s+(?P[\d.,]+\s+\S*B/s)" + r"\s+(?P\S+)" + rf"\s+(?P{_COUNT})" + rf"\s+(?P{_COUNT})" + rf"\s+(?P{_COUNT})" + r"\s*$" + ) + result: Dict[str, SonicInterfaceCounters] = {} + for line in text.splitlines(): + match = line_pattern.match(line.strip()) + if not match: + continue + name = match.group("name") + try: + result[name] = SonicInterfaceCounters( + state=match.group("state"), + rx_ok=self._to_int(match.group("rx_ok")), + rx_bps=match.group("rx_bps"), + rx_util=match.group("rx_util"), + rx_err=self._to_int(match.group("rx_err")), + rx_drp=self._to_int(match.group("rx_drp")), + rx_ovr=self._to_int(match.group("rx_ovr")), + tx_ok=self._to_int(match.group("tx_ok")), + tx_bps=match.group("tx_bps"), + tx_util=match.group("tx_util"), + tx_err=self._to_int(match.group("tx_err")), + tx_drp=self._to_int(match.group("tx_drp")), + tx_ovr=self._to_int(match.group("tx_ovr")), + ) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build SonicInterfaceCounters for {name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + def get_fec_status(self) -> Optional[Dict[str, SonicFecStatus]]: + """Parse ``show interface fec status`` into per-port FEC models. + + Returns: + Mapping of port name to :class:`SonicFecStatus`, or ``None``. + """ + text = self._run_sonic_command(self.CMD_FEC_STATUS) + if text is None: + return None + line_pattern = re.compile(r"^(?PEthernet\S*)\s+(?P\S+)\s+(?P\S+)\s*$") + result: Dict[str, SonicFecStatus] = {} + for line in text.splitlines(): + match = line_pattern.match(line.strip()) + if not match: + continue + name = match.group("name") + try: + result[name] = SonicFecStatus( + oper=match.group("oper"), + admin=match.group("admin"), + ) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build SonicFecStatus for {name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + def get_queue_counters(self) -> Optional[Dict[str, List[SonicQueueCounter]]]: + """Parse ``show queue counters`` (``Eth*`` rows only) per port. + + Returns: + Mapping of port name to a list of :class:`SonicQueueCounter`, + or ``None``. + """ + text = self._run_sonic_command(self.CMD_QUEUE_COUNTERS) + if text is None: + return None + line_pattern = re.compile( + r"^(?PEthernet\S*)" + r"\s+(?P\S+)" + rf"\s+(?P{_COUNT})" + rf"\s+(?P{_COUNT})" + rf"\s+(?P{_COUNT})" + rf"\s+(?P{_COUNT})" + r"\s*$" + ) + result: Dict[str, List[SonicQueueCounter]] = {} + for line in text.splitlines(): + match = line_pattern.match(line.strip()) + if not match: + continue + name = match.group("port") + try: + entry = SonicQueueCounter( + txq=match.group("txq"), + counter_pkts=self._to_int(match.group("pkts")), + counter_bytes=self._to_int(match.group("bytes")), + drop_pkts=self._to_int(match.group("drop_pkts")), + drop_bytes=self._to_int(match.group("drop_bytes")), + ) + result.setdefault(name, []).append(entry) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build SonicQueueCounter for {name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + # artifact-only collectors + + def collect_artifact_commands(self) -> None: + """Run diagnostic commands so their output is captured in ``command_artifacts.json``. + + Each command is executed via :meth:`_run_sut_cmd`, which records the + command, stdout, stderr and exit code as a ``CommandArtifact`` in + ``command_artifacts.json``. No separate per-command ``.log`` files are + written. Failures are logged but do **not** cause the overall + collection to fail. + """ + for command in self.ARTIFACT_COMMANDS: + try: + cmd_ret = self._run_sut_cmd(command) + if cmd_ret.exit_code != 0: + self._log_event( + category=EventCategory.SWITCH, + description=f"Error running artifact command: `{command}`", + data={ + "command": command, + "exit_code": cmd_ret.exit_code, + "stderr": cmd_ret.stderr, + }, + priority=EventPriority.ERROR, + console_log=True, + ) + continue + except Exception as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Error collecting artifact for command: `{command}`", + data={ + "command": command, + "exception": get_exception_traceback(e), + }, + priority=EventPriority.WARNING, + console_log=True, + ) + + def _preflight_check(self) -> bool: + """Verify the device is a reachable SONiC switch. + + Ensures the device responds and identifies as SONiC. + + On failure this sets ``self.result.status`` to + :attr:`ExecutionStatus.NOT_RAN` and returns ``False``. + + Returns: + ``True`` if the pre-flight check passed, ``False`` otherwise. + """ + version_text = self._run_sonic_command(self.CMD_VERSION) + if version_text is None: + self._log_event( + category=EventCategory.SWITCH, + description="ScaleUpSonicCollector pre-flight check failed", + priority=EventPriority.ERROR, + console_log=True, + ) + self.result.status = ExecutionStatus.NOT_RAN + return False + + if not self._is_sonic_output(version_text): + self._log_event( + category=EventCategory.SWITCH, + description="Not a SONiC switch", + data={"raw_output": version_text}, + priority=EventPriority.ERROR, + console_log=True, + ) + self.result.status = ExecutionStatus.NOT_RAN + return False + + return True + + # main entry point + + def collect_data( + self, args: Optional[ScaleUpSonicCollectorArgs] = None + ) -> tuple[TaskResult, Optional[ScaleUpSonicDataModel]]: + """Run all SONiC collectors and assemble the switch data model. + + Args: + args: Optional :class:`ScaleUpSonicCollectorArgs`. + + Returns: + Tuple of ``(TaskResult, ScaleUpSonicDataModel | None)``. + """ + + if not self._preflight_check(): + return self.result, None + + try: + interface_status = self.get_interface_status() + interface_counters = self.get_interface_counters() + fec_status = self.get_fec_status() + queue_counters = self.get_queue_counters() + + self.collect_artifact_commands() + except Exception as e: + self._log_event( + category=EventCategory.SWITCH, + description="Error running SONiC collector sub commands", + data={"exception": get_exception_traceback(e)}, + priority=EventPriority.ERROR, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + return self.result, None + + all_port_names: set[str] = set() + for source in (interface_status, interface_counters, fec_status, queue_counters): + if source: + all_port_names.update(source.keys()) + + port_data: Optional[Dict[str, SonicPortData]] = None + if all_port_names: + port_data = {} + for name in sorted(all_port_names): + port_data[name] = SonicPortData( + interface_status=interface_status.get(name) if interface_status else None, + interface_counters=( + interface_counters.get(name) if interface_counters else None + ), + fec_status=fec_status.get(name) if fec_status else None, + queue_counters=queue_counters.get(name) if queue_counters else None, + ) + + try: + sonic_data = ScaleUpSonicDataModel( + port_list=sorted(all_port_names) if all_port_names else None, + port=port_data, + ) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description="Failed to build ScaleUpSonicDataModel", + data=get_exception_details(e), + priority=EventPriority.ERROR, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + return self.result, None + + self.result.message = "SONiC switch data collected" + return self.result, sonic_data diff --git a/nodescraper/plugins/inband/switch/scale_up_sonic/scale_up_sonic_plugin.py b/nodescraper/plugins/inband/switch/scale_up_sonic/scale_up_sonic_plugin.py new file mode 100644 index 00000000..10a8ee09 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_up_sonic/scale_up_sonic_plugin.py @@ -0,0 +1,48 @@ +############################################################################### +# +# 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 nodescraper.base import InBandDataPlugin + +from .analyzer_args import ScaleUpSonicAnalyzerArgs +from .collector_args import ScaleUpSonicCollectorArgs +from .scale_up_sonic_analyzer import ScaleUpSonicAnalyzer +from .scale_up_sonic_collector import ScaleUpSonicCollector +from .scaleupsonicdata import ScaleUpSonicDataModel + + +class ScaleUpSonicPlugin( + InBandDataPlugin[ScaleUpSonicDataModel, ScaleUpSonicCollectorArgs, ScaleUpSonicAnalyzerArgs] +): + """Plugin for collection and analysis of scale up SONiC switch data""" + + DATA_MODEL = ScaleUpSonicDataModel + + COLLECTOR = ScaleUpSonicCollector + + COLLECTOR_ARGS = ScaleUpSonicCollectorArgs + + ANALYZER = ScaleUpSonicAnalyzer + + ANALYZER_ARGS = ScaleUpSonicAnalyzerArgs diff --git a/nodescraper/plugins/inband/switch/scale_up_sonic/scaleupsonicdata.py b/nodescraper/plugins/inband/switch/scale_up_sonic/scaleupsonicdata.py new file mode 100644 index 00000000..f283c292 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_up_sonic/scaleupsonicdata.py @@ -0,0 +1,132 @@ +############################################################################### +# +# 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 ClassVar, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from nodescraper.models import DataModel + + +class SonicInterfaceStatus(BaseModel): + """Per-port info from ``show interface status``""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + name: Optional[str] = None + lanes: Optional[str] = None + speed: Optional[str] = None + mtu: Optional[int] = None + fec: Optional[str] = None + alias: Optional[str] = None + vlan: Optional[str] = None + oper: Optional[str] = None + admin: Optional[str] = None + type: Optional[str] = None + asym_pfc: Optional[str] = None + + error_fields: ClassVar[dict[str, List[str]]] = { + "oper": ["up"], + } + + +class SonicFecStatus(BaseModel): + """Per-port FEC status from ``show interface fec status``""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + oper: Optional[str] = None + admin: Optional[str] = None + + +class SonicInterfaceCounters(BaseModel): + """Per-port summary counters from ``show interface counters``""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + state: Optional[str] = None + rx_ok: Optional[int] = None + rx_bps: Optional[str] = None + rx_util: Optional[str] = None + rx_err: Optional[int] = None + rx_drp: Optional[int] = None + rx_ovr: Optional[int] = None + tx_ok: Optional[int] = None + tx_bps: Optional[str] = None + tx_util: Optional[str] = None + tx_err: Optional[int] = None + tx_drp: Optional[int] = None + tx_ovr: Optional[int] = None + + error_fields: ClassVar[dict[str, List[str]]] = { + "state": ["U"], + "rx_err": ["0"], + "rx_ovr": ["0"], + "tx_err": ["0"], + "tx_ovr": ["0"], + } + + warning_fields: ClassVar[dict[str, List[str]]] = { + "rx_drp": ["0"], + "tx_drp": ["0"], + } + + +class SonicQueueCounter(BaseModel): + """Single queue counter entry from ``show queue counters``""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + txq: Optional[str] = None + counter_pkts: Optional[int] = None + counter_bytes: Optional[int] = None + drop_pkts: Optional[int] = None + drop_bytes: Optional[int] = None + + warning_fields: ClassVar[dict[str, List[str]]] = { + "drop_pkts": ["0"], + "drop_bytes": ["0"], + } + + +class SonicPortData(BaseModel): + """All collected per-port data for a SONiC switch""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + interface_status: Optional[SonicInterfaceStatus] = None + interface_counters: Optional[SonicInterfaceCounters] = None + fec_status: Optional[SonicFecStatus] = None + queue_counters: Optional[List[SonicQueueCounter]] = None + + +class ScaleUpSonicDataModel(DataModel): + """Collected output of SONiC switch commands""" + + port_list: Optional[List[str]] = None + + port: Optional[Dict[str, SonicPortData]] = None