diff --git a/nodescraper/cli/cli.py b/nodescraper/cli/cli.py index 30dc8792..87541b1e 100644 --- a/nodescraper/cli/cli.py +++ b/nodescraper/cli/cli.py @@ -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.""" @@ -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)) diff --git a/nodescraper/connection/inband/inbandmanager.py b/nodescraper/connection/inband/inbandmanager.py index f9220ea9..c1c6bea5 100644 --- a/nodescraper/connection/inband/inbandmanager.py +++ b/nodescraper/connection/inband/inbandmanager.py @@ -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 @@ -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) @@ -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, diff --git a/nodescraper/connection/inband/osdetection.py b/nodescraper/connection/inband/osdetection.py new file mode 100644 index 00000000..9353439d --- /dev/null +++ b/nodescraper/connection/inband/osdetection.py @@ -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 diff --git a/nodescraper/enums/eventcategory.py b/nodescraper/enums/eventcategory.py index 42aa6c98..2c5d5514 100644 --- a/nodescraper/enums/eventcategory.py +++ b/nodescraper/enums/eventcategory.py @@ -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 @@ -87,6 +89,7 @@ class EventCategory(AutoNameStrEnum): BIOS = auto() INFRASTRUCTURE = auto() NETWORK = auto() + SWITCH = auto() TELEMETRY = auto() RUNTIME = auto() UNKNOWN = auto() diff --git a/nodescraper/enums/osfamily.py b/nodescraper/enums/osfamily.py index bf9c1cd1..bce34550 100644 --- a/nodescraper/enums/osfamily.py +++ b/nodescraper/enums/osfamily.py @@ -32,3 +32,5 @@ class OSFamily(enum.Enum): WINDOWS = enum.auto() UNKNOWN = enum.auto() LINUX = enum.auto() + EOS = enum.auto() + SONIC = enum.auto() diff --git a/nodescraper/interfaces/dataplugin.py b/nodescraper/interfaces/dataplugin.py index 19820b31..21d81b8e 100644 --- a/nodescraper/interfaces/dataplugin.py +++ b/nodescraper/interfaces/dataplugin.py @@ -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__, diff --git a/nodescraper/plugins/inband/switch/__init__.py b/nodescraper/plugins/inband/switch/__init__.py new file mode 100644 index 00000000..ad8bbd8d --- /dev/null +++ b/nodescraper/plugins/inband/switch/__init__.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. +# +############################################################################### +from .scale_out_arista import ScaleOutAristaPlugin +from .scale_out_dell import ScaleOutDellPlugin + +__all__ = ["ScaleOutAristaPlugin", "ScaleOutDellPlugin"] diff --git a/nodescraper/plugins/inband/switch/scale_out_arista/__init__.py b/nodescraper/plugins/inband/switch/scale_out_arista/__init__.py new file mode 100644 index 00000000..6d790f1d --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_arista/__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_out_arista_plugin import ScaleOutAristaPlugin + +__all__ = ["ScaleOutAristaPlugin"] diff --git a/nodescraper/plugins/inband/switch/scale_out_arista/analyzer_args.py b/nodescraper/plugins/inband/switch/scale_out_arista/analyzer_args.py new file mode 100644 index 00000000..7986a67d --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_arista/analyzer_args.py @@ -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." + ), + ) diff --git a/nodescraper/plugins/inband/switch/scale_out_arista/collector_args.py b/nodescraper/plugins/inband/switch/scale_out_arista/collector_args.py new file mode 100644 index 00000000..7c642294 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_arista/collector_args.py @@ -0,0 +1,41 @@ +############################################################################### +# +# 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 ScaleOutAristaCollectorArgs(CollectorArgs): + """Arguments for the Arista switch collector.""" + + html_view: bool = Field( + default=False, + description=( + "When true, add a second command_artifacts entry per '| json' command " + "using pretty-printed JSON from the same SSH run (labeled with the " + "non-json command name) for HTML reports. Does not re-run commands on the switch." + ), + ) diff --git a/nodescraper/plugins/inband/switch/scale_out_arista/scale_out_arista_analyzer.py b/nodescraper/plugins/inband/switch/scale_out_arista/scale_out_arista_analyzer.py new file mode 100644 index 00000000..2db976ed --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_arista/scale_out_arista_analyzer.py @@ -0,0 +1,115 @@ +############################################################################### +# +# 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 ScaleOutAristaAnalyzerArgs +from .scaleoutaristadata import PortData, ScaleOutAristaDataModel + + +class ScaleOutAristaAnalyzer( + SwitchAnalyzerBase[ScaleOutAristaDataModel], + DataAnalyzer[ScaleOutAristaDataModel, ScaleOutAristaAnalyzerArgs], +): + """Check Arista switch data for errors and warnings. + + Walks every model in the collected :class:`ScaleOutAristaDataModel` and checks + each ``error_fields`` / ``warning_fields`` ClassVar against an optional + ``ports`` filter. + """ + + VENDOR_NAME: ClassVar[str] = "Arista" + DATA_MODEL = ScaleOutAristaDataModel + + PORT_NAME_RE: ClassVar[re.Pattern] = re.compile(r"^(?:Ethernet)?(\d+(?:/\d+)*)$", re.IGNORECASE) + PORT_FORMAT_HINT: ClassVar[str] = "expected slash-separated decimals (e.g. 'M/S', 'A/B/C')" + + def _walk_system(self, switch_data: ScaleOutAristaDataModel) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + + if switch_data.system_env is None: + return findings + + findings.extend( + self._check_model( + switch_data.system_env, + context={"section": "system_env"}, + ) + ) + + for idx, psu in enumerate(switch_data.system_env.power_supply_slots or []): + findings.extend( + self._check_model( + psu, + context={ + "section": "power_supply_slots", + "index": idx, + "label": psu.label, + }, + ) + ) + + for idx, fan in enumerate(switch_data.system_env.fan_tray_slots or []): + findings.extend( + self._check_model( + fan, + context={ + "section": "fan_tray_slots", + "index": idx, + "label": fan.label, + }, + ) + ) + + return findings + + def _extra_port_findings(self, port_name: str, port_data: BaseModel) -> list[dict[str, Any]]: + if not isinstance(port_data, PortData): + return [] + + args = self._analyzer_args + if not isinstance(args, ScaleOutAristaAnalyzerArgs): + args = ScaleOutAristaAnalyzerArgs() + + status = port_data.port_status + if status is None: + return [] + + finding = self._port_field_mismatch( + port_name, + "port_status", + "bandwidth", + status.bandwidth, + args.expected_port_bandwidth, + "AristaPortStatus", + ) + return [finding] if finding else [] diff --git a/nodescraper/plugins/inband/switch/scale_out_arista/scale_out_arista_collector.py b/nodescraper/plugins/inband/switch/scale_out_arista/scale_out_arista_collector.py new file mode 100644 index 00000000..922560d4 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_arista/scale_out_arista_collector.py @@ -0,0 +1,910 @@ +############################################################################### +# +# 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 typing import Dict, List, Optional, Union + +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 ScaleOutAristaCollectorArgs +from .scaleoutaristadata import ( + AristaBinsCounters, + AristaCountersErrors, + AristaDroppedPacketCounters, + AristaDropPrecedenceCounters, + AristaEcnCounters, + AristaIpCounters, + AristaNeighbors, + AristaPacketCounters, + AristaPauseFrameCounters, + AristaPerQueueCounters, + AristaPfcCounters, + AristaPortStatus, + AristaRatesCounters, + AristaSystemEnv, + AristaVersion, + PortData, + ScaleOutAristaDataModel, +) + + +class ScaleOutAristaCollector( + InBandDataCollector[ScaleOutAristaDataModel, ScaleOutAristaCollectorArgs] +): + """Collect Arista switch data. + + Runs Arista EOS ``show`` commands (JSON and text) and parses their + output into a :class:`ScaleOutAristaDataModel`. + """ + + SUPPORTED_OS_FAMILY: set[OSFamily] = {OSFamily.EOS, OSFamily.LINUX, OSFamily.UNKNOWN} + + DATA_MODEL = ScaleOutAristaDataModel + + # When set (via the ``html_view`` collector arg), each ``| json`` command + # is followed by its non-JSON version for human-readable artifact output. + _html_view: bool = False + + CMD_VERSION = "show version | json | no-more" + CMD_LLDP_NEIGHBORS = "show lldp neighbors | json | no-more" + CMD_SYSTEM_ENV = "show system environment cooling | json | no-more" + CMD_PORT_STATUS = "show interfaces status | json | no-more" + CMD_ERROR_COUNTERS = "show interfaces counters errors | json | no-more" + CMD_PACKET_COUNTERS = "show interfaces counters | json | no-more" + CMD_BINS_COUNTERS = "show interfaces counters bins | json | no-more" + CMD_IP_COUNTERS = "show interfaces counters ip | json | no-more" + CMD_RATES_COUNTERS = "show interfaces counters rates | json | no-more" + CMD_PFC_COUNTERS = "show priority-flow-control counters | json | no-more" + CMD_DROPPED_PACKET_COUNTERS = "show interfaces counters queue | no-more" + CMD_DROP_PRECEDENCE_COUNTERS = "show interfaces counters queue drop-precedence | no-more" + CMD_PER_QUEUE_COUNTERS = "show interfaces counters queue detail | no-more" + CMD_PAUSE_FRAME_COUNTERS = "show interfaces flow-control | json | no-more" + CMD_ECN_COUNTERS = "show qos interfaces ecn counters queue | json | no-more" + + # Commands run for diagnostics, not parsed into a data model. + CMD_RUNNING_CONFIG = "show running-config | no-more" + CMD_STARTUP_CONFIG = "show startup-config | no-more" + CMD_IP_INTERFACE = "show ip interface | no-more" + CMD_INTERFACES_PHY = "show interfaces phy | no-more" + CMD_INTERFACES_PHY_DETAIL = "show interfaces phy detail | no-more" + CMD_QOS_PROFILE = "show qos profile | no-more" + CMD_QOS_PROFILE_SUMMARY = "show qos profile summary | no-more" + CMD_QOS_MAPS = "show qos maps | no-more" + CMD_QOS_INTERFACES = "show qos interfaces | no-more" + CMD_QOS_INTERFACES_TRUST = "show qos interfaces trust | no-more" + CMD_PFC_STATUS = "show priority-flow-control status | no-more" + CMD_QOS_INTERFACES_ECN = "show qos interfaces ecn | no-more" + CMD_LLDP = "show lldp | no-more" + CMD_TRIDENT_MMU_QUEUE_STATUS = "show platform trident mmu queue status | no-more" + + # Aggregate of the diagnostic CMD_* commands above. + ARTIFACT_COMMANDS: list[str] = [ + CMD_RUNNING_CONFIG, + CMD_STARTUP_CONFIG, + CMD_IP_INTERFACE, + CMD_INTERFACES_PHY, + CMD_INTERFACES_PHY_DETAIL, + CMD_QOS_PROFILE, + CMD_QOS_PROFILE_SUMMARY, + CMD_QOS_MAPS, + CMD_QOS_INTERFACES, + CMD_QOS_INTERFACES_TRUST, + CMD_PFC_STATUS, + CMD_QOS_INTERFACES_ECN, + CMD_LLDP, + CMD_TRIDENT_MMU_QUEUE_STATUS, + ] + + # helpers + def _run_arista_json(self, command: str) -> Optional[Union[dict, list]]: + """Run an Arista EOS command returning JSON. + + Args: + command: The full EOS command (already including ``| json | no-more``). + + Returns: + Parsed JSON (dict or list), or ``None`` if the call failed. + """ + cmd_ret: CommandArtifact = self._run_sut_cmd(command) + if cmd_ret.exit_code != 0: + self._log_event( + category=EventCategory.SWITCH, + description=f"Error running Arista command: `{command}`", + data={ + "command": command, + "exit_code": cmd_ret.exit_code, + "stderr": cmd_ret.stderr, + }, + priority=EventPriority.ERROR, + console_log=True, + ) + return None + try: + parsed = json.loads(cmd_ret.stdout) + except json.JSONDecodeError as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Error parsing JSON from Arista command: `{command}`", + data={ + "command": command, + "exception": get_exception_traceback(e), + }, + priority=EventPriority.ERROR, + console_log=True, + ) + return None + + self._append_html_view_artifact(command, cmd_ret, parsed) + return parsed + + def _run_arista_text(self, command: str) -> Optional[str]: + """Run an Arista EOS command returning text. + + Args: + command: The full EOS command (already including ``| no-more``). + + Returns: + The stdout text, or ``None`` if the call failed. + """ + cmd_ret: CommandArtifact = self._run_sut_cmd(command) + if cmd_ret.exit_code != 0: + self._log_event( + category=EventCategory.SWITCH, + description=f"Error running Arista 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 None + + # sub-collectors + + def get_version(self) -> Optional[AristaVersion]: + """Collect version information via ``show version | json``.""" + data = self._run_arista_json(self.CMD_VERSION) + if not isinstance(data, dict): + return None + try: + return AristaVersion(**data) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description="Failed to build AristaVersion model", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return None + + @staticmethod + def _expand_port_name(short_name: str) -> str: + """Expand abbreviated port names like ``Et1/1`` to ``Ethernet1/1``. + + If the name already starts with ``Ethernet``, it is returned as-is. + """ + if short_name.startswith("Et") and not short_name.startswith("Ethernet"): + return "Ethernet" + short_name[2:] + return short_name + + @staticmethod + def _is_ethernet_port(port_name: str) -> bool: + """Return True for physical Ethernet interfaces (not Port-Channel, Management, etc.).""" + return port_name.startswith("Ethernet") + + def get_port_status(self) -> Optional[Dict[str, AristaPortStatus]]: + """Collect per-port status via ``show interfaces status | json | no-more``. + + Returns: + Mapping of port name to :class:`AristaPortStatus`, or ``None``. + """ + data = self._run_arista_json(self.CMD_PORT_STATUS) + if not isinstance(data, dict): + return None + interfaces = data.get("interfaceStatuses", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.SWITCH, + description="Unexpected format for 'show interfaces status' output", + priority=EventPriority.WARNING, + ) + return None + result: Dict[str, AristaPortStatus] = {} + for port_name, port_data in interfaces.items(): + if not isinstance(port_data, dict) or not self._is_ethernet_port(port_name): + continue + try: + result[port_name] = AristaPortStatus(**port_data) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build AristaPortStatus for {port_name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + def get_lldp_neighbors(self) -> Optional[AristaNeighbors]: + """Collect LLDP neighbor info via ``show lldp neighbors | json | no-more``.""" + data = self._run_arista_json(self.CMD_LLDP_NEIGHBORS) + if not isinstance(data, dict): + return None + try: + return AristaNeighbors(**data) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description="Failed to build AristaNeighbors model", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return None + + def get_system_env(self) -> Optional[AristaSystemEnv]: + """Collect system environment via ``show system environment cooling | json | no-more``.""" + data = self._run_arista_json(self.CMD_SYSTEM_ENV) + if not isinstance(data, dict): + return None + # Extract inner fan configurations from slot wrappers. + # Each slot has a "fans" list of individual fan config dicts. + ps_fans: list = [] + for slot in data.get("powerSupplySlots", []) or []: + if not isinstance(slot, dict): + continue + for fan in slot.get("fans", []) or []: + if isinstance(fan, dict): + ps_fans.append(fan) + data["powerSupplySlots"] = ps_fans + + ft_fans: list = [] + for slot in data.get("fanTraySlots", []) or []: + if not isinstance(slot, dict): + continue + for fan in slot.get("fans", []) or []: + if isinstance(fan, dict): + ft_fans.append(fan) + data["fanTraySlots"] = ft_fans + + try: + return AristaSystemEnv(**data) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description="Failed to build AristaSystemEnv model", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return None + + def get_error_counters(self) -> Optional[Dict[str, AristaCountersErrors]]: + """Collect error counters via ``show interfaces counters errors | json | no-more``.""" + data = self._run_arista_json(self.CMD_ERROR_COUNTERS) + if not isinstance(data, dict): + return None + interfaces = data.get("interfaceErrorCounters", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.SWITCH, + description="Unexpected format for 'show interfaces counters errors' output", + priority=EventPriority.WARNING, + ) + return None + result: Dict[str, AristaCountersErrors] = {} + for port_name, counters in interfaces.items(): + if not self._is_ethernet_port(port_name): + continue + try: + result[port_name] = AristaCountersErrors(**counters) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build AristaCountersErrors for {port_name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + def get_packet_counters(self) -> Optional[Dict[str, AristaPacketCounters]]: + """Collect packet counters via ``show interfaces counters | json | no-more``.""" + data = self._run_arista_json(self.CMD_PACKET_COUNTERS) + if not isinstance(data, dict): + return None + interfaces = data.get("interfaces", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.SWITCH, + description="Unexpected format for 'show interfaces counters' output", + priority=EventPriority.WARNING, + ) + return None + result: Dict[str, AristaPacketCounters] = {} + for port_name, counters in interfaces.items(): + if not self._is_ethernet_port(port_name): + continue + try: + result[port_name] = AristaPacketCounters(**counters) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build AristaPacketCounters for {port_name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + def get_bins_counters( + self, + ) -> tuple[Optional[Dict[str, AristaBinsCounters]], Optional[Dict[str, AristaBinsCounters]]]: + """Collect bins counters via ``show interfaces counters bins | json | no-more``. + + Returns: + Tuple of ``(out_bins, in_bins)`` dicts keyed by port name. + """ + data = self._run_arista_json(self.CMD_BINS_COUNTERS) + if not isinstance(data, dict): + return None, None + interfaces = data.get("interfaces", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.SWITCH, + description="Unexpected format for 'show interfaces counters bins' output", + priority=EventPriority.WARNING, + ) + return None, None + out_bins: Dict[str, AristaBinsCounters] = {} + in_bins: Dict[str, AristaBinsCounters] = {} + for port_name, counters in interfaces.items(): + if not self._is_ethernet_port(port_name) or not isinstance(counters, dict): + continue + out_data = counters.get("outBinsCounters") + in_data = counters.get("inBinsCounters") + if out_data: + try: + out_bins[port_name] = AristaBinsCounters(**out_data) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build out AristaBinsCounters for {port_name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + if in_data: + try: + in_bins[port_name] = AristaBinsCounters(**in_data) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build in AristaBinsCounters for {port_name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return out_bins or None, in_bins or None + + def get_ip_counters(self) -> Optional[Dict[str, AristaIpCounters]]: + """Collect IP counters via ``show interfaces counters ip | json | no-more``.""" + data = self._run_arista_json(self.CMD_IP_COUNTERS) + if not isinstance(data, dict): + return None + interfaces = data.get("interfaces", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.SWITCH, + description="Unexpected format for 'show interfaces counters ip' output", + priority=EventPriority.WARNING, + ) + return None + result: Dict[str, AristaIpCounters] = {} + for port_name, counters in interfaces.items(): + if not self._is_ethernet_port(port_name): + continue + try: + result[port_name] = AristaIpCounters(**counters) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build AristaIpCounters for {port_name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + def get_rates_counters(self) -> Optional[Dict[str, AristaRatesCounters]]: + """Collect rates counters via ``show interfaces counters rates | json | no-more``.""" + data = self._run_arista_json(self.CMD_RATES_COUNTERS) + if not isinstance(data, dict): + return None + interfaces = data.get("interfaces", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.SWITCH, + description="Unexpected format for 'show interfaces counters rates' output", + priority=EventPriority.WARNING, + ) + return None + result: Dict[str, AristaRatesCounters] = {} + for port_name, counters in interfaces.items(): + if not self._is_ethernet_port(port_name): + continue + try: + result[port_name] = AristaRatesCounters(**counters) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build AristaRatesCounters for {port_name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + def get_pfc_counters(self) -> Optional[Dict[str, AristaPfcCounters]]: + """Collect PFC counters via ``show priority-flow-control counters``. + + Returns: + Mapping of port name to :class:`AristaPfcCounters`, or ``None``. + """ + data = self._run_arista_json(self.CMD_PFC_COUNTERS) + if not isinstance(data, dict): + return None + interfaces = data.get("interfaceCounters", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.SWITCH, + description="Unexpected format for 'show priority-flow-control counters' output", + priority=EventPriority.WARNING, + ) + return None + result: Dict[str, AristaPfcCounters] = {} + for port_name, counters in interfaces.items(): + if not self._is_ethernet_port(port_name): + continue + try: + result[port_name] = AristaPfcCounters(**counters) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build AristaPfcCounters for {port_name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + def get_dropped_packet_counters( + self, + ) -> Optional[Dict[str, AristaDroppedPacketCounters]]: + """Collect dropped packet counters via ``show interfaces counters queue``. + + Returns: + Mapping of port name to :class:`AristaDroppedPacketCounters`, + or ``None``. + """ + text = self._run_arista_text(self.CMD_DROPPED_PACKET_COUNTERS) + if text is None: + return None + line_pattern = re.compile( + r"(?PEt\S+)" + r"\s+(?P\d+)" + r"\s+(?P\d+)" + r"\s+(?P\d+)" + ) + result: Dict[str, AristaDroppedPacketCounters] = {} + for line in text.splitlines(): + match = line_pattern.match(line.strip()) + if not match: + continue + port_name = self._expand_port_name(match.group("port")) + if not self._is_ethernet_port(port_name): + continue + try: + result[port_name] = AristaDroppedPacketCounters( + in_dropped_pkts=int(match.group("in_dropped")), + out_uc_dropped_pkts=int(match.group("out_uc_dropped")), + out_mc_dropped_pkts=int(match.group("out_mc_dropped")), + ) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build AristaDroppedPacketCounters for {port_name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + def get_drop_precedence_counters( + self, + ) -> Optional[Dict[str, AristaDropPrecedenceCounters]]: + """Collect drop precedence counters via ``... queue drop-precedence``. + + Returns: + Mapping of port name to :class:`AristaDropPrecedenceCounters`, + or ``None``. + """ + text = self._run_arista_text(self.CMD_DROP_PRECEDENCE_COUNTERS) + if text is None: + return None + line_pattern = re.compile( + r"(?PEthernet\S+)" r"\s+(?P\d+)" r"\s+(?P\d+)" r"\s+(?P\d+)" + ) + result: Dict[str, AristaDropPrecedenceCounters] = {} + for line in text.splitlines(): + match = line_pattern.match(line.strip()) + if not match: + continue + port_name = match.group("port") + if not self._is_ethernet_port(port_name): + continue + try: + result[port_name] = AristaDropPrecedenceCounters( + dp0_dropped_pkts=int(match.group("dp0")), + dp1_dropped_pkts=int(match.group("dp1")), + dp2_dropped_pkts=int(match.group("dp2")), + ) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build AristaDropPrecedenceCounters for {port_name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + def get_per_queue_counters( + self, + ) -> Optional[Dict[str, List[AristaPerQueueCounters]]]: + """Collect per-queue counters via ``show interfaces counters queue detail``. + + Returns: + Mapping of port name to a list of :class:`AristaPerQueueCounters`, + or ``None``. + """ + text = self._run_arista_text(self.CMD_PER_QUEUE_COUNTERS) + if text is None: + return None + line_pattern = re.compile( + r"(?PEt\S+)" + r"\s+(?P\S+)" + r"\s+(?P\d+)" + r"\s+(?P\d+)" + r"\s+(?P\d+)" + r"\s+(?P\d+)" + ) + result: Dict[str, List[AristaPerQueueCounters]] = {} + for line in text.splitlines(): + match = line_pattern.match(line.strip()) + if not match: + continue + port_name = self._expand_port_name(match.group("port")) + if not self._is_ethernet_port(port_name): + continue + try: + entry = AristaPerQueueCounters( + txq=match.group("txq"), + pkts_counter=int(match.group("pkts_counter")), + bytes_counter=int(match.group("bytes_counter")), + pkts_drop=int(match.group("pkts_drop")), + bytes_drop=int(match.group("bytes_drop")), + ) + result.setdefault(port_name, []).append(entry) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build AristaPerQueueCounters for {port_name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + def get_pause_frame_counters( + self, + ) -> Optional[Dict[str, AristaPauseFrameCounters]]: + """Collect pause frame counters via ``show interfaces flow-control | json | no-more``.""" + data = self._run_arista_json(self.CMD_PAUSE_FRAME_COUNTERS) + if not isinstance(data, dict): + return None + interfaces = data.get("interfaceFlowControls", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.SWITCH, + description="Unexpected format for 'show interfaces flow-control' output", + priority=EventPriority.WARNING, + ) + return None + result: Dict[str, AristaPauseFrameCounters] = {} + for port_name, counters in interfaces.items(): + if not self._is_ethernet_port(port_name): + continue + try: + result[port_name] = AristaPauseFrameCounters(**counters) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build AristaPauseFrameCounters for {port_name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + def get_ecn_counters( + self, + ) -> Optional[Dict[str, List[AristaEcnCounters]]]: + """Collect ECN counters via ``show qos interfaces ecn counters queue | json | no-more``. + + Returns: + A dict mapping port name to a list of per-queue ECN counter entries. + """ + data = self._run_arista_json(self.CMD_ECN_COUNTERS) + if not isinstance(data, dict): + return None + interfaces = data.get("intfQueueCounters", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.SWITCH, + description="Unexpected format for 'show qos interfaces ecn counters queue' output", + priority=EventPriority.WARNING, + ) + return None + result: Dict[str, List[AristaEcnCounters]] = {} + for port_name, port_data in interfaces.items(): + if not self._is_ethernet_port(port_name) or not isinstance(port_data, dict): + continue + queue_counters = port_data.get("queueCounters", {}) + if not isinstance(queue_counters, dict): + continue + entries: List[AristaEcnCounters] = [] + for queue_id, marked_packets in queue_counters.items(): + try: + entries.append( + AristaEcnCounters( + txq=queue_id, + marked_packets=str(marked_packets), + ) + ) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build AristaEcnCounters for {port_name} queue {queue_id}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + if entries: + result[port_name] = entries + 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``.""" + 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 _append_html_view_artifact( + self, + json_command: str, + cmd_ret: CommandArtifact, + parsed: Union[dict, list], + ) -> None: + """Add a human-readable artifact for HTML reports without a second SSH command.""" + if not self._html_view or "| json" not in json_command: + return + text_command = json_command.replace(" | json", "") + self.result.artifacts.append( + CommandArtifact( + command=text_command, + stdout=json.dumps(parsed, indent=2, sort_keys=True), + stderr=cmd_ret.stderr or "", + exit_code=cmd_ret.exit_code, + ) + ) + + def _preflight_check(self) -> Optional[AristaVersion]: + """Verify the switch is a reachable Arista EOS device. + + Verifies the switch responds to the basic ``show version`` command + before running the rest of the collector + + On failure this sets ``self.result.status`` to + :attr:`ExecutionStatus.NOT_RAN` and returns ``None``. + + Returns: + The collected :class:`AristaVersion` on success, or ``None`` if the + pre-flight check failed. + """ + version = self.get_version() + if version is None: + self._log_event( + category=EventCategory.SWITCH, + description=("ScaleOutAristaCollector pre-flight check failed"), + priority=EventPriority.ERROR, + console_log=True, + ) + self.result.status = ExecutionStatus.NOT_RAN + return None + + mfg_name = version.mfg_name or "" + if "arista" not in mfg_name.lower(): + self._log_event( + category=EventCategory.SWITCH, + description=("Not Arista switch"), + data={"mfg_name": mfg_name}, + priority=EventPriority.ERROR, + console_log=True, + ) + self.result.status = ExecutionStatus.NOT_RAN + return None + + return version + + # main entry point + + def collect_data( + self, args: Optional[ScaleOutAristaCollectorArgs] = None + ) -> tuple[TaskResult, Optional[ScaleOutAristaDataModel]]: + """Run all Arista collectors and assemble the switch data model. + + Args: + args: Optional :class:`ScaleOutAristaCollectorArgs`. + + Returns: + Tuple of ``(TaskResult, ScaleOutAristaDataModel | None)``. + """ + self._html_view = bool(args and args.html_view) + + version = self._preflight_check() + if version is None: + return self.result, None + + try: + lldp_neighbors = self.get_lldp_neighbors() + system_env = self.get_system_env() + + port_status = self.get_port_status() + error_counters = self.get_error_counters() + packet_counters = self.get_packet_counters() + out_bins, in_bins = self.get_bins_counters() + ip_counters = self.get_ip_counters() + rates_counters = self.get_rates_counters() + pfc_counters = self.get_pfc_counters() + dropped_packet_counters = self.get_dropped_packet_counters() + drop_precedence_counters = self.get_drop_precedence_counters() + per_queue_counters = self.get_per_queue_counters() + pause_frame_counters = self.get_pause_frame_counters() + ecn_counters = self.get_ecn_counters() + + self.collect_artifact_commands() + except Exception as e: + self._log_event( + category=EventCategory.SWITCH, + description="Error running Arista 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 + + # Canonical port list from interface status; fall back to filtered union. + per_port_dicts = ( + error_counters, + packet_counters, + out_bins, + in_bins, + ip_counters, + rates_counters, + pfc_counters, + dropped_packet_counters, + drop_precedence_counters, + per_queue_counters, + pause_frame_counters, + ecn_counters, + ) + if port_status: + all_port_names = set(port_status.keys()) + else: + all_port_names = set() + for d in per_port_dicts: + if d: + all_port_names.update(name for name in d.keys() if self._is_ethernet_port(name)) + + port_data: Optional[Dict[str, PortData]] = None + if all_port_names: + port_data = {} + for name in sorted(all_port_names): + port_data[name] = PortData( + port_status=port_status.get(name) if port_status else None, + error_counters=error_counters.get(name) if error_counters else None, + packet_counters=packet_counters.get(name) if packet_counters else None, + ip_counters=ip_counters.get(name) if ip_counters else None, + out_bins_counters=out_bins.get(name) if out_bins else None, + in_bins_counters=in_bins.get(name) if in_bins else None, + rates_counters=rates_counters.get(name) if rates_counters else None, + pfc_counters=pfc_counters.get(name) if pfc_counters else None, + dropped_packet_counters=( + dropped_packet_counters.get(name) if dropped_packet_counters else None + ), + dropped_precedence_counters=( + drop_precedence_counters.get(name) if drop_precedence_counters else None + ), + per_queue_counters=per_queue_counters.get(name) if per_queue_counters else None, + pause_frame_counters=( + pause_frame_counters.get(name) if pause_frame_counters else None + ), + ecn_counters=ecn_counters.get(name) if ecn_counters else None, + ) + + try: + arista_data = ScaleOutAristaDataModel( + version=version, + lldp_neighbors=lldp_neighbors, + system_env=system_env, + 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 ScaleOutAristaDataModel", + data=get_exception_details(e), + priority=EventPriority.ERROR, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + return self.result, None + + self.result.message = "Arista switch data collected" + return self.result, arista_data diff --git a/nodescraper/plugins/inband/switch/scale_out_arista/scale_out_arista_plugin.py b/nodescraper/plugins/inband/switch/scale_out_arista/scale_out_arista_plugin.py new file mode 100644 index 00000000..c2055dc1 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_arista/scale_out_arista_plugin.py @@ -0,0 +1,50 @@ +############################################################################### +# +# 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 ScaleOutAristaAnalyzerArgs +from .collector_args import ScaleOutAristaCollectorArgs +from .scale_out_arista_analyzer import ScaleOutAristaAnalyzer +from .scale_out_arista_collector import ScaleOutAristaCollector +from .scaleoutaristadata import ScaleOutAristaDataModel + + +class ScaleOutAristaPlugin( + InBandDataPlugin[ + ScaleOutAristaDataModel, ScaleOutAristaCollectorArgs, ScaleOutAristaAnalyzerArgs + ] +): + """Plugin for collection and analysis of Arista switch data""" + + DATA_MODEL = ScaleOutAristaDataModel + + COLLECTOR = ScaleOutAristaCollector + + COLLECTOR_ARGS = ScaleOutAristaCollectorArgs + + ANALYZER = ScaleOutAristaAnalyzer + + ANALYZER_ARGS = ScaleOutAristaAnalyzerArgs diff --git a/nodescraper/plugins/inband/switch/scale_out_arista/scaleoutaristadata.py b/nodescraper/plugins/inband/switch/scale_out_arista/scaleoutaristadata.py new file mode 100644 index 00000000..1c48a8da --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_arista/scaleoutaristadata.py @@ -0,0 +1,373 @@ +############################################################################### +# +# 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, Union + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from nodescraper.models import DataModel + + +class AristaVersion(BaseModel): + """Contains the versioning info""" + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + protected_namespaces=(), + ) + + image_format_version: Optional[str] = None + uptime: Optional[float] = None + model_name: Optional[str] = None + internal_version: Optional[str] = None + mem_total: Optional[int] = None + mfg_name: Optional[str] = None + serial_number: Optional[str] = None + system_mac_address: Optional[str] = None + bootup_timestamp: Optional[float] = None + mem_free: Optional[int] = None + version: Optional[str] = None + config_mac_address: Optional[str] = None + is_intl_version: Optional[bool] = None + image_optimization: Optional[str] = None + internal_build_id: Optional[str] = None + hardware_revision: Optional[str] = None + hw_mac_address: Optional[str] = None + architecture: Optional[str] = None + + +class LldpNeighbor(BaseModel): + """Contains the LLDP neighbor info for an Arista switch.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + port: Optional[str] = None + neighbor_device: Optional[str] = None + neighbor_port: Optional[str] = None + ttl: Optional[int] = None + + +class AristaNeighbors(BaseModel): + """Contains the neighbor info for an Arista switch.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + tables_last_change_time: Optional[float] = None + tables_age_outs: Optional[int] = None + tables_inserts: Optional[int] = None + lldp_neighbors: Optional[List[LldpNeighbor]] = None + + +class FanConfiguration(BaseModel): + """Contains the fan configuration info for an Arista switch.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + label: Optional[str] = None + status: Optional[str] = None + uptime: Optional[float] = None + max_speed: Optional[int] = None + last_speed_stable_change_time: Optional[float] = None + configured_speed: Optional[int] = None + actual_speed: Optional[int] = None + speed_hw_override: Optional[bool] = None + speed_stable: Optional[bool] = None + + error_fields: ClassVar[dict[str, str]] = { + "status": "ok", + } + + +class AristaSystemEnv(BaseModel): + """Contains the system environment info for an Arista switch.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + system_status: Optional[str] = None + fans_status: Optional[str] = None + ambient_temperature: Optional[float] = None + airflow_direction: Optional[str] = None + current_zones: Optional[int] = None + configured_zones: Optional[int] = None + default_zones: Optional[bool] = None + num_cooling_zones: Optional[List[int]] = None + shutdown_on_insufficient_fans: Optional[bool] = None + override_fan_speed: Optional[int] = None + min_fan_speed: Optional[int] = None + cooling_mode: Optional[str] = None + + power_supply_slots: Optional[List[FanConfiguration]] = None + fan_tray_slots: Optional[List[FanConfiguration]] = None + + error_fields: ClassVar[dict[str, Union[str, bool]]] = { + "system_status": "coolingOk", + "fans_status": "fanAlarmOk", + } + + +class VlanInformation(BaseModel): + """Contains the VLAN info for an Arista switch port.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + vlan_id: Optional[int] = None + interface_mode: Optional[str] = None + interface_forwarding_model: Optional[str] = None + + error_fields: ClassVar[dict[str, str]] = { + "interface_mode": "routed", + "interface_forwarding_model": "routed", + } + + +class AristaPortStatus(BaseModel): + """Contains the port status info for an Arista switch.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + link_status: Optional[str] = None + description: Optional[str] = None + bandwidth: Optional[int] = None + duplex: Optional[str] = None + vlan_information: Optional[VlanInformation] = None + auto_negotiate_active: Optional[bool] = None + interface_type: Optional[str] = None + line_protocol_status: Optional[str] = None + interface_damped: Optional[bool] = None + + error_fields: ClassVar[dict[str, str]] = { + "link_status": "connected", + "duplex": "duplexFull", + "line_protocol_status": "up", + } + + +class AristaCountersErrors(BaseModel): + """Contains the error counters for an Arista switch port.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + in_errors: Optional[int] = None + frame_too_longs: Optional[int] = None + out_errors: Optional[int] = None + frame_too_shorts: Optional[int] = None + fcs_errors: Optional[int] = None + alignment_errors: Optional[int] = None + symbol_errors: Optional[int] = None + + error_fields: ClassVar[dict[str, str]] = { + "in_errors": "0", + "frame_too_longs": "0", + "out_errors": "0", + "frame_too_shorts": "0", + "fcs_errors": "0", + "alignment_errors": "0", + "symbol_errors": "0", + } + + +class AristaPacketCounters(BaseModel): + """Contains the packet counters for an Arista switch port.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + out_broadcast_pkts: Optional[int] = None + out_ucast_pkts: Optional[int] = None + in_multicast_pkts: Optional[int] = None + last_update_timestamp: Optional[float] = None + in_broadcast_pkts: Optional[int] = None + in_octets: Optional[int] = None + out_discards: Optional[int] = None + out_octets: Optional[int] = None + in_ucast_pkts: Optional[int] = None + out_multicast_pkts: Optional[int] = None + in_discards: Optional[int] = None + + +class AristaIpCounters(BaseModel): + """Contains the IP counters for an Arista switch port.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + ipv4_out_pkts: Optional[int] = None + ipv4_in_pkts: Optional[int] = None + ipv6_in_pkts: Optional[int] = None + ipv6_out_pkts: Optional[int] = None + + +class AristaBinsCounters(BaseModel): + """Contains the bins counters for an Arista switch port.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + frames_128_to_255_octet: Optional[int] = None + frames_64_octet: Optional[int] = None + frames_256_to_511_octet: Optional[int] = None + frames_1024_to_1522_octet: Optional[int] = None + frames_512_to_1023_octet: Optional[int] = None + frames_65_to_127_octet: Optional[int] = None + frames_1523_to_max_octet: Optional[int] = None + + +class AristaRatesCounters(BaseModel): + """Contains the rates counters for an Arista switch port.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + out_pps_rate: Optional[float] = None + in_pps_rate: Optional[float] = None + description: Optional[str] = None + last_update_timestamp: Optional[float] = None + in_pkts_rate: Optional[float] = None + in_bps_rate: Optional[float] = None + interval: Optional[int] = None + out_bps_rate: Optional[float] = None + out_pkts_rate: Optional[float] = None + + +class AristaDroppedPacketCounters(BaseModel): + """Contains the dropped packet counters for an Arista switch port.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + in_dropped_pkts: Optional[int] = None + out_uc_dropped_pkts: Optional[int] = None + out_mc_dropped_pkts: Optional[int] = None + + warning_fields: ClassVar[dict[str, str]] = { + "in_dropped_pkts": "0", + "out_uc_dropped_pkts": "0", + "out_mc_dropped_pkts": "0", + } + + +class AristaDropPrecedenceCounters(BaseModel): + """Contains the drop precedence counters for an Arista switch port.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + dp0_dropped_pkts: Optional[int] = None + dp1_dropped_pkts: Optional[int] = None + dp2_dropped_pkts: Optional[int] = None + + error_fields: ClassVar[dict[str, str]] = { + "dp0_dropped_pkts": "0", + "dp1_dropped_pkts": "0", + "dp2_dropped_pkts": "0", + } + + +class AristaPerQueueCounters(BaseModel): + """Contains the per-queue counters for an Arista switch port.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + txq: Optional[str] = None + pkts_counter: Optional[int] = None + bytes_counter: Optional[int] = None + pkts_drop: Optional[int] = None + bytes_drop: Optional[int] = None + + warning_fields: ClassVar[dict[str, str]] = { + "pkts_drop": "0", + "bytes_drop": "0", + } + + +class AristaPauseFrameCounters(BaseModel): + """Contains the pause frame counters for an Arista switch port.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + tx_admin_state: Optional[str] = None + tx_oper_state: Optional[str] = None + rx_admin_state: Optional[str] = None + rx_oper_state: Optional[str] = None + tx_pause: Optional[int] = None + rx_pause: Optional[int] = None + + error_fields: ClassVar[dict[str, str]] = { + "tx_pause": "0", + "rx_pause": "0", + } + + +class AristaEcnCounters(BaseModel): + """Contains the ECN counters for an Arista switch port.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + txq: Optional[str] = None + marked_packets: Optional[str] = None + + warning_fields: ClassVar[dict[str, str]] = { + "marked_packets": "0", + } + + +class AristaPfcCounters(BaseModel): + """Contains the PFC counters for an Arista switch port.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + rx_frames: Optional[int] = None + tx_frames: Optional[int] = None + + warning_fields: ClassVar[dict[str, str]] = { + "rx_frames": "0", + "tx_frames": "0", + } + + +class PortData(BaseModel): + """Contains all the data for a single port on an Arista switch.""" + + port_status: Optional[AristaPortStatus] = None + error_counters: Optional[AristaCountersErrors] = None + packet_counters: Optional[AristaPacketCounters] = None + ip_counters: Optional[AristaIpCounters] = None + out_bins_counters: Optional[AristaBinsCounters] = None + in_bins_counters: Optional[AristaBinsCounters] = None + rates_counters: Optional[AristaRatesCounters] = None + dropped_packet_counters: Optional[AristaDroppedPacketCounters] = None + dropped_precedence_counters: Optional[AristaDropPrecedenceCounters] = None + per_queue_counters: Optional[List[AristaPerQueueCounters]] = None + pause_frame_counters: Optional[AristaPauseFrameCounters] = None + pfc_counters: Optional[AristaPfcCounters] = None + ecn_counters: Optional[List[AristaEcnCounters]] = None + + +class ScaleOutAristaDataModel(DataModel): + """Collected output of Arista commands.""" + + version: Optional[AristaVersion] = None + lldp_neighbors: Optional[AristaNeighbors] = None + system_env: Optional[AristaSystemEnv] = None + port_list: Optional[List[str]] = None + + port: Optional[Dict[str, PortData]] = None diff --git a/nodescraper/plugins/inband/switch/scale_out_dell/__init__.py b/nodescraper/plugins/inband/switch/scale_out_dell/__init__.py new file mode 100644 index 00000000..41fd3b69 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_dell/__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_out_dell_plugin import ScaleOutDellPlugin + +__all__ = ["ScaleOutDellPlugin"] diff --git a/nodescraper/plugins/inband/switch/scale_out_dell/analyzer_args.py b/nodescraper/plugins/inband/switch/scale_out_dell/analyzer_args.py new file mode 100644 index 00000000..dd3e54ec --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_dell/analyzer_args.py @@ -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 ScaleOutDellAnalyzerArgs(AnalyzerArgs): + """Arguments for the Dell SONiC switch analyzer.""" + + analysis_ports: Optional[List[str]] = Field( + default=None, + description=( + "Restrict per-port analysis to the given ports. Accepts optional Eth " + "prefix (e.g. ['1/1', '1/31', '1/1/1'] or ['Eth1/1/1']). " + "When omitted, every port present in the data is analyzed. " + "Independent of any collection-time filter." + ), + ) + expected_port_speed: int = Field( + default=400000, + description=( + "Expected interface speed (Mbps) from show interface status " + "(DellInterfaceStatus.speed). Ports with a different speed are flagged." + ), + ) diff --git a/nodescraper/plugins/inband/switch/scale_out_dell/collector_args.py b/nodescraper/plugins/inband/switch/scale_out_dell/collector_args.py new file mode 100644 index 00000000..fb268eb8 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_dell/collector_args.py @@ -0,0 +1,43 @@ +############################################################################### +# +# 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 CollectorArgs + + +class ScaleOutDellCollectorArgs(CollectorArgs): + """Arguments for the Dell SONiC switch collector.""" + + collection_ports: Optional[List[str]] = Field( + default=None, + description=( + "Restrict detail counter collection to these ports. Accepts the same " + "tokens as analysis_ports (e.g. ['1/1', '1/1/2'] or ['Eth1/1/1']). " + "When omitted, every port from 'show interface status' is queried." + ), + ) diff --git a/nodescraper/plugins/inband/switch/scale_out_dell/port_names.py b/nodescraper/plugins/inband/switch/scale_out_dell/port_names.py new file mode 100644 index 00000000..51b72fc8 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_dell/port_names.py @@ -0,0 +1,86 @@ +############################################################################### +# +# 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 Mapping, Optional + +PORT_TOKEN_RE = re.compile(r"^(?:Eth)?(\d+(?:/\d+)*)$", re.IGNORECASE) +SAFE_ETH_PORT_RE = re.compile(r"^Eth\d+(?:/\d+)*$", re.IGNORECASE) + + +def normalize_port_token(port: str) -> Optional[str]: + """Return the canonical port key (slash-separated indices, no Eth prefix).""" + match = PORT_TOKEN_RE.match(port.strip()) + if not match: + return None + return match.group(1) + + +def to_eth_port_name(port: str) -> Optional[str]: + """Return a validated Eth… port name safe for CLI interpolation.""" + canonical = normalize_port_token(port) + if canonical is None: + return None + eth_name = f"Eth{canonical}" + if not SAFE_ETH_PORT_RE.match(eth_name): + return None + return eth_name + + +def resolve_detail_port_names( + ports_arg: list[str], + interface_status: Optional[Mapping[str, object]] = None, +) -> tuple[Optional[list[str]], Optional[str]]: + """Map collection/analysis port tokens to Eth… names from interface status when possible. + + Args: + ports_arg: Port identifiers such as ``1/1/1`` or ``Eth1/1/1``. + interface_status: Optional ``show interface status`` map keyed by Eth… names. + + Returns: + Tuple of (resolved Eth port names, invalid token). On success the invalid token is None. + """ + canonical_ports: list[str] = [] + for port in ports_arg: + canonical = normalize_port_token(port) + if canonical is None: + return None, port + canonical_ports.append(canonical) + + status_by_canonical: dict[str, str] = {} + if interface_status: + for name in interface_status: + canonical = normalize_port_token(name) + if canonical: + status_by_canonical[canonical] = name + + detail_names: list[str] = [] + for canonical in canonical_ports: + eth_name = status_by_canonical.get(canonical) or to_eth_port_name(canonical) + if eth_name is None: + return None, canonical + detail_names.append(eth_name) + return detail_names, None diff --git a/nodescraper/plugins/inband/switch/scale_out_dell/scale_out_dell_analyzer.py b/nodescraper/plugins/inband/switch/scale_out_dell/scale_out_dell_analyzer.py new file mode 100644 index 00000000..f061fd63 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_dell/scale_out_dell_analyzer.py @@ -0,0 +1,98 @@ +############################################################################### +# +# 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 ScaleOutDellAnalyzerArgs +from .port_names import PORT_TOKEN_RE +from .scaleoutdelldata import DellPortData, ScaleOutDellDataModel + + +class ScaleOutDellAnalyzer( + SwitchAnalyzerBase[ScaleOutDellDataModel], + DataAnalyzer[ScaleOutDellDataModel, ScaleOutDellAnalyzerArgs], +): + """Check Dell SONiC switch data for errors and warnings. + + Walks every model in the collected :class:`ScaleOutDellDataModel` and checks + each ``error_fields`` / ``warning_fields`` ClassVar against an optional + ``ports`` filter. + """ + + VENDOR_NAME: ClassVar[str] = "Dell" + DATA_MODEL = ScaleOutDellDataModel + + PORT_NAME_RE: ClassVar[re.Pattern] = PORT_TOKEN_RE + PORT_FORMAT_HINT: ClassVar[str] = "expected slash-separated decimals (e.g. 'M/S', 'A/B/C')" + + def _walk_system(self, switch_data: ScaleOutDellDataModel) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + + for idx, arp_entry in enumerate(switch_data.ip_arp or []): + findings.extend( + self._check_model( + arp_entry, + context={"section": "ip_arp", "index": idx}, + ) + ) + + for idx, route_entry in enumerate(switch_data.ip_route or []): + findings.extend( + self._check_model( + route_entry, + context={"section": "ip_route", "index": idx}, + ) + ) + + return findings + + def _extra_port_findings(self, port_name: str, port_data: BaseModel) -> list[dict[str, Any]]: + if not isinstance(port_data, DellPortData): + return [] + + args = self._analyzer_args + if not isinstance(args, ScaleOutDellAnalyzerArgs): + args = ScaleOutDellAnalyzerArgs() + + 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, + "DellInterfaceStatus", + ) + return [finding] if finding else [] diff --git a/nodescraper/plugins/inband/switch/scale_out_dell/scale_out_dell_collector.py b/nodescraper/plugins/inband/switch/scale_out_dell/scale_out_dell_collector.py new file mode 100644 index 00000000..44d76ca0 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_dell/scale_out_dell_collector.py @@ -0,0 +1,874 @@ +############################################################################### +# +# 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 ScaleOutDellCollectorArgs +from .port_names import resolve_detail_port_names, to_eth_port_name +from .scaleoutdelldata import ( + DellArpEntry, + DellFecStatus, + DellInterfaceCounters, + DellInterfaceDetailCounters, + DellInterfaceStatus, + DellPfcStatistics, + DellPfcWatchdogQueueStats, + DellPortData, + DellQueueCounter, + DellRouteEntry, + ScaleOutDellDataModel, +) + + +class _ParsedInterfaceStatusLine(TypedDict): + name: str + description: str + oper: str + reason: str + auto_neg: str + speed: int + mtu: int + alternate_name: str + + +class ScaleOutDellCollector(InBandDataCollector[ScaleOutDellDataModel, ScaleOutDellCollectorArgs]): + """Collect Dell SONiC switch data. + + Runs Dell SONiC CLI ``show`` commands over SSH and parses their text + output into a :class:`ScaleOutDellDataModel`. + """ + + SUPPORTED_OS_FAMILY: set[OSFamily] = {OSFamily.SONIC, OSFamily.LINUX, OSFamily.UNKNOWN} + + DATA_MODEL = ScaleOutDellDataModel + + # Each is wrapped in `` sonic-cli -c "" `` at run-time + CMD_VERSION = "show version | no-more" + CMD_INTERFACE_STATUS = "show interface status | no-more" + CMD_INTERFACE_COUNTERS = "show interface counters | no-more" + CMD_DETAIL_COUNTERS = "show interface counters {port} | no-more" + CMD_FEC_STATUS = "show interface fec status | no-more" + CMD_IP_ARP = "show ip arp | no-more" + CMD_IP_ROUTE = "show ip route | no-more" + CMD_PFC_STATISTICS = "show qos interface Ethall priority-flow-control statistics | no-more" + CMD_PFC_WATCHDOG_STATISTICS = ( + "show qos interface Ethall queue all priority-flow-control watchdog-statistics | no-more" + ) + CMD_QUEUE_COUNTERS = "show queue counters | no-more" + + # Commands run for diagnostics, not parsed into a data model. + CMD_CLOCK = "show clock | no-more" + CMD_PLATFORM_SYSEEPROM = "show platform syseeprom | no-more" + CMD_PLATFORM_FIRMWARE_DETAIL = "show platform firmware detail | no-more" + CMD_RUNNING_CONFIGURATION = "show running-configuration | no-more" + CMD_INTERFACE_TRANSCEIVER = "show interface transceiver | no-more" + CMD_INTERFACE_TRANSCEIVER_SUMMARY = "show interface transceiver summary | no-more" + CMD_IP_INTERFACES = "show ip interfaces | no-more" + CMD_QOS_MAP_DSCP_TC = "show qos map dscp-tc | no-more" + CMD_QOS_MAP_TC_QUEUE = "show qos map tc-queue | no-more" + CMD_QOS_MAP_TC_PG = "show qos map tc-pg | no-more" + CMD_QOS_MAP_TC_DSCP = "show qos map tc-dscp | no-more" + CMD_QOS_MAP_TC_DOT1P = "show qos map tc-dot1p | no-more" + CMD_QOS_MAP_PFC_PRIORITY_QUEUE = "show qos map pfc-priority-queue | no-more" + CMD_QOS_MAP_PFC_PRIORITY_PG = "show qos map pfc-priority-pg | no-more" + CMD_QOS_MAP_DOT1P_TC = "show qos map dot1p-tc | no-more" + CMD_QOS_SCHEDULER_POLICY = "show qos scheduler-policy | no-more" + CMD_QOS_WRED_POLICY = "show qos wred-policy | no-more" + CMD_QOS_INTERFACE_ETH_ALL = "show qos interface Eth all | no-more" + CMD_QOS_INTERFACE_ETH_ALL_QUEUE_ALL = "show qos interface Eth all queue all | no-more" + CMD_PFC_WATCHDOG = "show priority-flow-control watchdog | no-more" + CMD_BUFFER_PROFILE = "show buffer profile | no-more" + CMD_BUFFER_POOL = "show buffer pool | no-more" + CMD_INTERFACE_TRANSCEIVER_DOM = "show interface transceiver dom | no-more" + CMD_LLDP_TABLE = "show lldp table | no-more" + CMD_LLDP_NEIGHBOR = "show lldp neighbor | no-more" + CMD_INTERFACE_ETH = "show interface Eth | no-more" + CMD_INTERFACE_PHY_COUNTERS = "show interface phy counters | no-more" + CMD_INTERFACE_COUNTERS_RATE = "show interface counters rate | no-more" + CMD_QUEUE_WATERMARK_UNICAST = "show queue watermark unicast | no-more" + CMD_QUEUE_WATERMARK_MULTICAST = "show queue watermark multicast | no-more" + CMD_QUEUE_PERSISTENT_WATERMARK_UNICAST = "show queue persistent-watermark unicast | no-more" + CMD_QUEUE_PERSISTENT_WATERMARK_MULTICAST = "show queue persistent-watermark multicast | no-more" + CMD_PLATFORM_ENVIRONMENT = "show platform environment | no-more" + CMD_EVENT_DETAILS = "show event details | no-more" + CMD_ALARM = "show alarm | no-more" + + _INTERFACE_STATUS_LINE_RE = re.compile( + r"^" + r"(?PEth\S+)" + r"\s+" + r"(?P.+)" + r"\s+" + r"(?Pup|down)" + r"\s+" + r"(?P\S+)" + r"\s+" + r"(?P\S+)" + r"\s+" + r"(?P\d+)" + r"\s+" + r"(?P\d+)" + r"\s+" + r"(?P\S+)" + r"\s*$", + re.IGNORECASE, + ) + + # Aggregate of the diagnostic CMD_* commands above + ARTIFACT_COMMANDS: list[str] = [ + CMD_CLOCK, + CMD_PLATFORM_SYSEEPROM, + CMD_PLATFORM_FIRMWARE_DETAIL, + CMD_RUNNING_CONFIGURATION, + CMD_INTERFACE_TRANSCEIVER, + CMD_INTERFACE_TRANSCEIVER_SUMMARY, + CMD_IP_INTERFACES, + CMD_QOS_MAP_DSCP_TC, + CMD_QOS_MAP_TC_QUEUE, + CMD_QOS_MAP_TC_PG, + CMD_QOS_MAP_TC_DSCP, + CMD_QOS_MAP_TC_DOT1P, + CMD_QOS_MAP_PFC_PRIORITY_QUEUE, + CMD_QOS_MAP_PFC_PRIORITY_PG, + CMD_QOS_MAP_DOT1P_TC, + CMD_QOS_SCHEDULER_POLICY, + CMD_QOS_WRED_POLICY, + CMD_QOS_INTERFACE_ETH_ALL, + CMD_QOS_INTERFACE_ETH_ALL_QUEUE_ALL, + CMD_PFC_WATCHDOG, + CMD_BUFFER_PROFILE, + CMD_BUFFER_POOL, + CMD_INTERFACE_TRANSCEIVER_DOM, + CMD_LLDP_TABLE, + CMD_LLDP_NEIGHBOR, + CMD_INTERFACE_ETH, + CMD_INTERFACE_PHY_COUNTERS, + CMD_INTERFACE_COUNTERS_RATE, + CMD_QUEUE_WATERMARK_UNICAST, + CMD_QUEUE_WATERMARK_MULTICAST, + CMD_QUEUE_PERSISTENT_WATERMARK_UNICAST, + CMD_QUEUE_PERSISTENT_WATERMARK_MULTICAST, + CMD_PLATFORM_ENVIRONMENT, + CMD_EVENT_DETAILS, + CMD_ALARM, + ] + + # helpers + @staticmethod + def _is_dell_output(text: str) -> bool: + lowered = text.lower() + return all(marker in lowered for marker in ("dell", "sonic")) + + @staticmethod + def _wrap_sonic_cli(command: str) -> str: + """Wrap a command to run inside the Dell SONiC CLI shell. + + Args: + command: The CLI command to wrap. + + Returns: + The command as ``sonic-cli -c ""``. + """ + return f'sonic-cli -c "{command}"' + + @staticmethod + def _canonical_eth_port(port: str) -> Optional[str]: + """Return a validated ``Eth…`` port name safe for CLI interpolation.""" + return to_eth_port_name(port) + + def _run_dell_command(self, command: str) -> Optional[str]: + """Run a Dell SONiC CLI command via ``sonic-cli -c``. + + Args: + command: The full CLI command to run (already including + ``| no-more`` where paging applies). + + Returns: + The command stdout, or ``None`` on error. + """ + full_cmd = self._wrap_sonic_cli(command) + cmd_ret: CommandArtifact = self._run_sut_cmd(full_cmd) + if cmd_ret.exit_code != 0: + self._log_event( + category=EventCategory.SWITCH, + description=f"Error running Dell command: `{full_cmd}`", + data={ + "command": full_cmd, + "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 by anchoring fixed trailing columns.""" + stripped = line.strip() + if not stripped or not stripped.startswith("Eth"): + return None + match = cls._INTERFACE_STATUS_LINE_RE.match(stripped) + if not match: + return None + return { + "name": match.group("name"), + "description": match.group("description").strip(), + "oper": match.group("oper").lower(), + "reason": match.group("reason"), + "auto_neg": match.group("auto_neg"), + "speed": int(match.group("speed")), + "mtu": int(match.group("mtu")), + "alternate_name": match.group("alt"), + } + + def get_interface_status(self) -> Optional[Dict[str, DellInterfaceStatus]]: + """Parse ``show interface status`` into per-port status models. + + Returns: + Mapping of port name to :class:`DellInterfaceStatus`, or ``None``. + """ + text = self._run_dell_command(self.CMD_INTERFACE_STATUS) + if text is None: + return None + result: Dict[str, DellInterfaceStatus] = {} + for line in text.splitlines(): + parsed = self._parse_interface_status_line(line) + if parsed is None: + continue + name = str(parsed["name"]) + try: + result[name] = DellInterfaceStatus(**parsed) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build DellInterfaceStatus for {name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + def get_interface_counters(self) -> Optional[Dict[str, DellInterfaceCounters]]: + """Parse ``show interface counters`` into per-port counter models. + + Returns: + Mapping of port name to :class:`DellInterfaceCounters`, or ``None``. + """ + text = self._run_dell_command(self.CMD_INTERFACE_COUNTERS) + if text is None: + return None + line_pattern = re.compile( + r"^(?PEth\S+)" + r"\s+(?P\S+)" + r"\s+(?P\d+)" + r"\s+(?P\d+)" + r"\s+(?P\d+)" + r"\s+(?P\d+)" + r"\s+(?P\d+)" + r"\s+(?P\d+)" + r"\s+(?P\d+)" + r"\s+(?P\d+)" + ) + result: Dict[str, DellInterfaceCounters] = {} + for line in text.splitlines(): + match = line_pattern.match(line.strip()) + if not match: + continue + name = match.group("name") + try: + result[name] = DellInterfaceCounters( + state=match.group("state"), + rx_ok=int(match.group("rx_ok")), + rx_err=int(match.group("rx_err")), + rx_drp=int(match.group("rx_drp")), + rx_oversize=int(match.group("rx_oversize")), + tx_ok=int(match.group("tx_ok")), + tx_err=int(match.group("tx_err")), + tx_drp=int(match.group("tx_drp")), + tx_oversize=int(match.group("tx_oversize")), + ) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.SWITCH, + description=f"Failed to build DellInterfaceCounters for {name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + @staticmethod + def _label_to_field(label: str) -> str: + """Convert an ``Interface Detail Counters`` label to a snake-case field name.""" + return re.sub(r"[^a-z0-9]+", "_", label.lower()).strip("_") + + def get_detail_counters( + self, + port_names: List[str], + ) -> Optional[Dict[str, DellInterfaceDetailCounters]]: + """Parse ``show interface counters `` for each given port. + + Args: + port_names: Ports to query. + + Returns: + Mapping of port name to :class:`DellInterfaceDetailCounters`, or ``None``. + """ + if not port_names: + return None + result: Dict[str, DellInterfaceDetailCounters] = {} + for port_name in port_names: + safe_port = self._canonical_eth_port(port_name) + if safe_port is None: + self._log_event( + category=EventCategory.SWITCH, + description=f"Skipping detail counters for invalid port name: {port_name!r}", + data={"port": port_name}, + priority=EventPriority.WARNING, + ) + continue + text = self._run_dell_command(self.CMD_DETAIL_COUNTERS.format(port=safe_port)) + if text is None: + continue + parsed = self._parse_detail_counters_block(text) + if parsed is None: + continue + result[port_name] = parsed + return result or None + + def _parse_detail_counters_block(self, text: str) -> Optional[DellInterfaceDetailCounters]: + """Parse one port's ``