diff --git a/nodescraper/interfaces/dataplugin.py b/nodescraper/interfaces/dataplugin.py index 19820b31..66dbac9e 100644 --- a/nodescraper/interfaces/dataplugin.py +++ b/nodescraper/interfaces/dataplugin.py @@ -339,7 +339,16 @@ def collect( ): self.connection_manager.connect() - if self.connection_manager.result.status != ExecutionStatus.OK: + # Proceed with collection as long as a usable connection was + # established. A non-fatal connection warning (e.g. the SUT OS + # could not be determined, as happens with switch CLIs that do not + # support ``uname``) must not block collection: each collector + # enforces its own ``SUPPORTED_OS_FAMILY`` and will be skipped via + # ``SystemCompatibilityError`` if the OS is unsupported. + 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..8e0fd76e --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_arista/analyzer_args.py @@ -0,0 +1,44 @@ +############################################################################### +# +# 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 specified in" + "the form 'S/P' (e.g. ['1/1', '2/1', '17/1']) " + "When omitted, every port present in the data is " + "analyzed, Independent of any collection-time port filter." + ), + ) 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..8f6e830b --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_arista/collector_args.py @@ -0,0 +1,45 @@ +############################################################################### +# +# 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 ScaleOutAristaCollectorArgs(CollectorArgs): + """Arguments for the Arista switch collector.""" + + collection_ports: Optional[List[str]] = Field( + default=None, + description=( + "Restrict collection to the given port(s). Each list element " + "triggers one command invocation" + "Accepted forms include '1/1', '1/1-8/1'" + "(e.g. ['1/1-3/1', '17/1-17/1']). " + "When omitted, commands run once against all ports." + ), + ) 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..8f68a363 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_arista/scale_out_arista_analyzer.py @@ -0,0 +1,94 @@ +############################################################################### +# +# 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 nodescraper.interfaces import DataAnalyzer + +from ..switch_analyzer_base import SwitchAnalyzerBase +from .analyzer_args import ScaleOutAristaAnalyzerArgs +from .scaleoutaristadata import 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 + + # ``M/S`` port identifier (e.g. ``1/1``), with optional ``Ethernet`` + # prefix so both filter tokens (``"1/1"``) and live port names + # (``"Ethernet1/1"``) normalize to the same canonical key. + PORT_NAME_RE: ClassVar[re.Pattern] = re.compile(r"^(?:Ethernet)?(\d+)/(\d+)$", re.IGNORECASE) + PORT_FORMAT_HINT: ClassVar[str] = "expected form 'M/S'" + + 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 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..7f83f497 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_arista/scale_out_arista_collector.py @@ -0,0 +1,1136 @@ +############################################################################### +# +# 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 Any, Dict, List, Optional, Union + +from pydantic import ValidationError + +from nodescraper.base import InBandDataCollector +from nodescraper.connection.inband import CommandArtifact, TextFileArtifact +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, + AristaPhyStatus, + AristaPortStatus, + AristaRatesCounters, + AristaSystemEnv, + AristaVersion, + PortData, + ScaleOutAristaDataModel, +) + +# Placeholder embedded in every Arista command that supports a per-port +# qualifier. At command-run time it is replaced with ``ethernet `` +# (when a port filter is active) or stripped entirely (when no filter is set). +ETHERNET_PLACEHOLDER = "ethernet_x" + + +def _normalize_port_spec(ports: Any) -> Optional[List[str]]: + """Convert a user-supplied port filter into a list of Arista spec strings. + + Each element of the returned list yields a single command invocation. + + Accepted input: + * None (no filter; commands run once with the placeholder + stripped). + * A list of strings -> one spec per element, e.g. + ["1/1-3/1", "17/1-17/1"] (two command calls). + + """ + if ports is None: + return None + + if not isinstance(ports, list): + raise TypeError(f"'ports' must be a list of strings, got {type(ports).__name__}") + + specs: list[str] = [] + for item in ports: + if not isinstance(item, str): + raise TypeError(f"Port filter tokens must be strings, got {item!r}") + cleaned: list[str] = [] + for tok in item.split(","): + tok = tok.strip() + if not tok: + continue + # Matches a single port-spec token like ``1/1``, ``1/1-8`` or + # ``1/1-8/1``. + match = re.match(r"^(\d+(?:/\d+)?)(?:-(\d+(?:/\d+)?))?$", tok) + if not match: + raise ValueError(f"Invalid port spec token: {tok!r}") + start, end = match.group(1), match.group(2) + cleaned.append(f"{start}-{end}" if end else start) + if cleaned: + specs.append(",".join(cleaned)) + + return specs or None + + +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.LINUX, OSFamily.UNKNOWN} + + DATA_MODEL = ScaleOutAristaDataModel + + _port_specs: Optional[List[str]] = None + + # Commands whose output is saved as file artifacts (not parsed into a data model). + ARTIFACT_COMMANDS: list[str] = [ + "show version", + "show running-config", + "show startup-config", + "show ip interface", + "show qos profile", + "show qos profile summary", + "show qos maps", + "show qos interfaces", + "show qos interfaces trust", + "show priority-flow-control status", + "show qos interfaces ecn", + "show lldp", + # "show priority-flow-control counters watchdog", + "show platform trident mmu queue status", + ] + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + + def _log_file_artifact(self, filename: str, contents: str) -> None: + """Append plain-text command output to the result as a file artifact.""" + self.result.artifacts.append(TextFileArtifact(filename=filename, contents=contents)) + + def _iter_port_specs(self) -> List[Optional[str]]: + """Return one entry per command invocation (None when no filter).""" + return list(self._port_specs) if self._port_specs else [None] + + def _substitute_port_placeholder(self, command: str, spec: Optional[str]) -> str: + """Replace the ``ethernet_x`` placeholder with ``ethernet ``. + + Args: + command: Command containing the placeholder. + spec: Port spec to insert, or ``None`` to strip the placeholder. + + Returns: + The rendered command string. + """ + if ETHERNET_PLACEHOLDER not in command: + return command + replacement = f"ethernet {spec}" if spec else "" + result = command.replace(ETHERNET_PLACEHOLDER, replacement) + return re.sub(r"\s{2,}", " ", result).strip() + + @staticmethod + def _merge_json( + accumulated: Optional[Union[dict, list]], new: Optional[Union[dict, list]] + ) -> Optional[Union[dict, list]]: + """Merge two JSON results (dicts recursively, lists concatenated). + + Args: + accumulated: Previously merged result. + new: New result to merge in. + + Returns: + The merged JSON value. + """ + if accumulated is None: + return new + if new is None: + return accumulated + if isinstance(accumulated, dict) and isinstance(new, dict): + merged = dict(accumulated) + for key, value in new.items(): + if key in merged and isinstance(merged[key], (dict, list)): + merged[key] = ScaleOutAristaCollector._merge_json(merged[key], value) + else: + merged[key] = value + return merged + if isinstance(accumulated, list) and isinstance(new, list): + return accumulated + new + return new + + def _run_arista_json(self, command: str) -> Optional[Union[dict, list]]: + """Run an Arista EOS command returning JSON, merging per-spec results. + + Args: + command: The EOS command (``| json`` is appended automatically). + + Returns: + Parsed JSON (dict or list), or ``None`` if every call failed. + """ + specs = self._iter_port_specs() if ETHERNET_PLACEHOLDER in command else [None] + accumulated: Optional[Union[dict, list]] = None + for spec in specs: + rendered = self._substitute_port_placeholder(command, spec) + cmd_ret: CommandArtifact = self._run_sut_cmd(f"{rendered} | json | no-more") + if cmd_ret.exit_code != 0: + self._log_event( + category=EventCategory.APPLICATION, + description=f"Error running Arista command: `{rendered}`", + data={ + "command": rendered, + "exit_code": cmd_ret.exit_code, + "stderr": cmd_ret.stderr, + }, + priority=EventPriority.ERROR, + console_log=True, + ) + continue + try: + parsed = json.loads(cmd_ret.stdout) + except json.JSONDecodeError as e: + self._log_event( + category=EventCategory.APPLICATION, + description=f"Error parsing JSON from Arista command: `{rendered}`", + data={ + "command": rendered, + "exception": get_exception_traceback(e), + }, + priority=EventPriority.ERROR, + console_log=True, + ) + continue + accumulated = self._merge_json(accumulated, parsed) + return accumulated + + def _run_arista_text(self, command: str) -> Optional[str]: + """Run an Arista EOS command returning text, concatenating per-spec output. + + Args: + command: The EOS command (``| no-more`` is appended automatically). + + Returns: + The combined stdout text, or ``None`` if every call failed. + """ + specs = self._iter_port_specs() if ETHERNET_PLACEHOLDER in command else [None] + chunks: list[str] = [] + for spec in specs: + rendered = self._substitute_port_placeholder(command, spec) + cmd_ret: CommandArtifact = self._run_sut_cmd(f"{rendered} | no-more") + if cmd_ret.exit_code != 0: + self._log_event( + category=EventCategory.APPLICATION, + description=f"Error running Arista command: `{rendered}`", + data={ + "command": rendered, + "exit_code": cmd_ret.exit_code, + "stderr": cmd_ret.stderr, + }, + priority=EventPriority.ERROR, + console_log=True, + ) + continue + if cmd_ret.stdout: + chunks.append(cmd_ret.stdout) + if not chunks: + return None + return "\n".join(chunks) + + # ------------------------------------------------------------------ + # sub-collectors + # ------------------------------------------------------------------ + + def get_version(self) -> Optional[AristaVersion]: + """Collect version information via ``show version | json``.""" + data = self._run_arista_json("show version") + if not isinstance(data, dict): + return None + try: + return AristaVersion(**data) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.APPLICATION, + 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 _port_id_from_name(port_name: str) -> Optional[str]: + """Extract the numeric identifier from an Ethernet port name. + + Args: + port_name: Full port name (e.g. ``"Ethernet1/1"``). + + Returns: + The portion after ``Ethernet`` (e.g. ``"1/1"``), or ``None``. + """ + match = re.match(r"Ethernet(\S+)", port_name) + return match.group(1) if match else None + + def get_port_status(self, port_names: list[str]) -> Optional[Dict[str, AristaPortStatus]]: + """Collect per-port status via ``show interfaces ethernet status``. + + Args: + port_names: Port names to query (e.g. ``["Ethernet1/1"]``). + + Returns: + Mapping of port name to :class:`AristaPortStatus`, or ``None``. + """ + result: Dict[str, AristaPortStatus] = {} + for port_name in port_names: + port_id = self._port_id_from_name(port_name) + if port_id is None: + self._log_event( + category=EventCategory.APPLICATION, + description=f"Could not extract port id from: {port_name}", + priority=EventPriority.WARNING, + ) + continue + data = self._run_arista_json(f"show interfaces Ethernet {port_id} status") + if not isinstance(data, dict): + continue + interfaces = data.get("interfaceStatuses", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.APPLICATION, + description=f"Unexpected format for port status of {port_name}", + priority=EventPriority.WARNING, + ) + continue + # The response is keyed by port name; grab the matching entry. + port_data = interfaces.get(port_name) + if port_data is None and interfaces: + # Fallback: take the first (and likely only) entry. + port_data = next(iter(interfaces.values())) + if port_data is None: + continue + try: + result[port_name] = AristaPortStatus(**port_data) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.APPLICATION, + description=f"Failed to build AristaPortStatus for {port_name}", + data=get_exception_details(e), + priority=EventPriority.WARNING, + ) + return result or None + + def get_phy_status(self) -> Optional[Dict[str, AristaPhyStatus]]: + """Collect PHY status via ``show interfaces phy | json``. + + Returns: + Mapping of port name to :class:`AristaPhyStatus`, or ``None``. + """ + data = self._run_arista_json("show interfaces ethernet_x phy") + if not isinstance(data, dict): + return None + interfaces = data.get("interfacePhyStatuses", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.APPLICATION, + description="Unexpected format for 'show interfaces phy' output", + priority=EventPriority.WARNING, + ) + return None + return self.parse_phy_status(interfaces) + + @staticmethod + def parse_phy_status( + interfaces: Dict[str, dict], + ) -> Optional[Dict[str, AristaPhyStatus]]: + """Parse the JSON output of ``show interfaces phy`` into models. + + Args: + interfaces: The ``interfacePhyStatuses`` dict from the output. + + Returns: + Mapping of port name to :class:`AristaPhyStatus`, or ``None``. + """ + # Pattern to match the fixed-width text row embedded in each entry. + # Port PHY state StateChanges ResetCount PMA/PMD PCS XAUI + line_pattern = re.compile( + r"(?PEthernet\S+)" # Port name (starts with Ethernet) + r"\s+" + r"(?P\S+)" # PHY state (e.g. linkUp, linkDown) + r"\s+" + r"(?P\d+)" # State Changes + r"\s+" + r"(?P\d+|-)" # Reset Count (integer or '-') + r"\s+" + r"(?P\S+)" # PMA/PMD flags + r"\s+" + r"(?P\S+)" # PCS flags + r"\s+" + r"(?P\S+)" # XAUI flags + ) + + result: Dict[str, AristaPhyStatus] = {} + for port_name, entry in interfaces.items(): + if not isinstance(entry, dict): + continue + text = entry.get("text", "") + match = line_pattern.search(text) + if not match: + continue + + pma_pmd = match.group("pma_pmd") + pcs = match.group("pcs") + xaui = match.group("xaui") + reset_count_raw = match.group("reset_count") + + # Decode PMA/PMD flags (3 chars: [U/D][R/.][T/.]) + link_up = pma_pmd[0] == "U" if len(pma_pmd) >= 1 else None + rx_fault = pma_pmd[1] == "R" if len(pma_pmd) >= 2 else None + tx_fault = pma_pmd[2] == "T" if len(pma_pmd) >= 3 else None + + # Decode PCS flags (up to 5 chars: [U/D][B/.][.][.][L/.]) + high_ber = pcs[1] == "B" if len(pcs) >= 2 else None + no_block_lock = pcs[4] == "L" if len(pcs) >= 5 else None + + # Decode XAUI flags + no_xaui_lane_alignment = "A" in xaui if xaui != "-" else None + no_xaui_lane_sync: Optional[List[int]] = None + if xaui != "-": + lanes = [int(ch) for ch in xaui if ch.isdigit()] + no_xaui_lane_sync = lanes if lanes else None + + result[port_name] = AristaPhyStatus( + phy_state=match.group("phy_state"), + state_changes=int(match.group("state_changes")), + reset_count=int(reset_count_raw) if reset_count_raw != "-" else None, + pma_pmd=pma_pmd, + pcs=pcs, + xaui=xaui, + link_up=link_up, + rx_fault=rx_fault, + tx_fault=tx_fault, + high_ber=high_ber, + no_block_lock=no_block_lock, + no_xaui_lane_alignment=no_xaui_lane_alignment, + no_xaui_lane_sync=no_xaui_lane_sync, + ) + + 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("show lldp neighbors") + if not isinstance(data, dict): + return None + try: + return AristaNeighbors(**data) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.APPLICATION, + 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("show system environment cooling") + 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.APPLICATION, + 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("show interfaces ethernet_x counters errors") + if not isinstance(data, dict): + return None + interfaces = data.get("interfaceErrorCounters", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.APPLICATION, + 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(): + try: + result[port_name] = AristaCountersErrors(**counters) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.APPLICATION, + 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("show interfaces ethernet_x counters") + if not isinstance(data, dict): + return None + interfaces = data.get("interfaces", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.APPLICATION, + description="Unexpected format for 'show interfaces counters' output", + priority=EventPriority.WARNING, + ) + return None + result: Dict[str, AristaPacketCounters] = {} + for port_name, counters in interfaces.items(): + try: + result[port_name] = AristaPacketCounters(**counters) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.APPLICATION, + 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("show interfaces ethernet_x counters bins") + if not isinstance(data, dict): + return None, None + interfaces = data.get("interfaces", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.APPLICATION, + 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 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.APPLICATION, + 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.APPLICATION, + 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("show interfaces ethernet_x counters ip") + if not isinstance(data, dict): + return None + interfaces = data.get("interfaces", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.APPLICATION, + 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(): + try: + result[port_name] = AristaIpCounters(**counters) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.APPLICATION, + 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("show interfaces ethernet_x counters rates") + if not isinstance(data, dict): + return None + interfaces = data.get("interfaces", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.APPLICATION, + 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(): + try: + result[port_name] = AristaRatesCounters(**counters) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.APPLICATION, + 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``. + """ + if self._port_specs: + command = "show priority-flow-control interfaces ethernet_x counters" + else: + command = "show priority-flow-control counters" + data = self._run_arista_json(command) + if not isinstance(data, dict): + return None + interfaces = data.get("interfaceCounters", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.APPLICATION, + 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(): + try: + result[port_name] = AristaPfcCounters(**counters) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.APPLICATION, + 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("show interfaces ethernet_x counters queue") + 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")) + 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.APPLICATION, + 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("show interfaces ethernet_x counters queue drop-precedence") + 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") + 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.APPLICATION, + 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("show interfaces ethernet_x counters queue detail") + 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")) + 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.APPLICATION, + 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("show interfaces ethernet_x flow-control") + if not isinstance(data, dict): + return None + interfaces = data.get("interfaceFlowControls", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.APPLICATION, + 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(): + try: + result[port_name] = AristaPauseFrameCounters(**counters) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.APPLICATION, + 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("show qos interfaces ethernet_x ecn counters queue") + if not isinstance(data, dict): + return None + interfaces = data.get("intfQueueCounters", data) + if not isinstance(interfaces, dict): + self._log_event( + category=EventCategory.APPLICATION, + 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 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.APPLICATION, + 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 + # ------------------------------------------------------------------ + + @staticmethod + def _command_to_filename(command: str) -> str: + """Convert a command string to a ``.log`` filename. + + Args: + command: The command string. + + Returns: + Filename with spaces/hyphens replaced by underscores. + """ + return command.replace(" ", "_").replace("-", "_") + ".log" + + def collect_artifact_commands(self) -> None: + """Run diagnostic commands and store their output as file artifacts. + + Failures are logged but do **not** cause the overall collection to fail. + """ + for command in self.ARTIFACT_COMMANDS: + specs = self._iter_port_specs() if ETHERNET_PLACEHOLDER in command else [None] + chunks: list[str] = [] + for spec in specs: + substituted = self._substitute_port_placeholder(command, spec) + full_cmd = f"{substituted} | no-more" + try: + cmd_ret = self._run_sut_cmd(full_cmd) + if cmd_ret.exit_code != 0: + self._log_event( + category=EventCategory.APPLICATION, + description=f"Error running artifact command: `{command}`", + data={ + "command": full_cmd, + "exit_code": cmd_ret.exit_code, + "stderr": cmd_ret.stderr, + }, + priority=EventPriority.ERROR, + console_log=True, + ) + continue + if cmd_ret.stdout: + chunks.append(cmd_ret.stdout) + except Exception as e: + self._log_event( + category=EventCategory.APPLICATION, + description=f"Error collecting artifact for command: `{command}`", + data={ + "command": command, + "exception": get_exception_traceback(e), + }, + priority=EventPriority.WARNING, + console_log=True, + ) + if chunks: + try: + self._log_file_artifact(self._command_to_filename(command), "\n".join(chunks)) + except Exception as e: + self._log_event( + category=EventCategory.APPLICATION, + description=f"Error saving artifact for command: `{command}`", + data={ + "command": command, + "exception": get_exception_traceback(e), + }, + priority=EventPriority.WARNING, + console_log=True, + ) + + 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, and that the reported + ``mfgName`` identifies the device as an Arista switch. If either + check fails, the device is unreachable, not an Arista EOS switch, or + otherwise incompatible -- treat like an unsupported OS and bail out + early. + + On failure this sets ``self.result.status`` to + :attr:`ExecutionStatus.EXECUTION_FAILURE` 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.APPLICATION, + description=("ScaleOutAristaCollector pre-flight check failed"), + priority=EventPriority.ERROR, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + return None + + mfg_name = version.mfg_name or "" + if "arista" not in mfg_name.lower(): + self._log_event( + category=EventCategory.APPLICATION, + description=("Not Arista switch"), + data={"mfg_name": mfg_name}, + priority=EventPriority.ERROR, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + 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`; its ``ports`` + attribute restricts collection, defaulting to all ports. + + Returns: + Tuple of ``(TaskResult, ScaleOutAristaDataModel | None)``. + """ + ports = args.collection_ports if args else None + try: + self._port_specs = _normalize_port_spec(ports) + except (TypeError, ValueError) as exc: + self._log_event( + category=EventCategory.APPLICATION, + description=f"Invalid 'ports' arg for ScaleOutAristaCollector: {exc}", + priority=EventPriority.ERROR, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + return self.result, None + + 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() + + phy_status = self.get_phy_status() + port_names = list(phy_status.keys()) if phy_status else [] + port_status = self.get_port_status(port_names) if port_names else None + 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.APPLICATION, + 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 + + # Build per-port PortData from all per-port collectors. + all_port_names: set[str] = set() + for d in ( + phy_status, + port_status, + 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 d: + all_port_names.update(d.keys()) + + 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, + phy_status=phy_status.get(name) if phy_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.APPLICATION, + 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..a2f9077d --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_arista/scaleoutaristadata.py @@ -0,0 +1,405 @@ +############################################################################### +# +# 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", + "bandwidth": "400000000000", + "duplex": "duplexFull", + "line_protocol_status": "up", + } + + +class AristaPhyStatus(BaseModel): + """Contains the PHY status info for an Arista switch port. + + Key for status flag fields (from 'show interfaces phy' output): + U = Link up, D = Link down, + R = RX Fault, T = TX Fault, + B = High BER, L = No Block Lock, + A = No XAUI Lane Alignment, + 0123 = No XAUI lane sync in lane N + """ + + phy_state: Optional[str] = None + state_changes: Optional[int] = None + reset_count: Optional[int] = None + pma_pmd: Optional[str] = None + pcs: Optional[str] = None + xaui: Optional[str] = None + link_up: Optional[bool] = None + rx_fault: Optional[bool] = None + tx_fault: Optional[bool] = None + high_ber: Optional[bool] = None + no_block_lock: Optional[bool] = None + no_xaui_lane_alignment: Optional[bool] = None + no_xaui_lane_sync: Optional[List[int]] = None + + error_fields: ClassVar[dict[str, Union[str, bool]]] = { + "tx_fault": False, + } + + +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 + phy_status: Optional[AristaPhyStatus] = 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..626e58b6 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_dell/analyzer_args.py @@ -0,0 +1,44 @@ +############################################################################### +# +# 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. 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." + ), + ) 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..eb8af591 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_dell/collector_args.py @@ -0,0 +1,44 @@ +############################################################################### +# +# 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 the detail port status collection to the " + "given port names (e.g. ['1/1', '1/1/2'] )" + "When omitted, every port discovered " + "via 'show interface status' is queried." + ), + ) 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..977d61b2 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_dell/scale_out_dell_analyzer.py @@ -0,0 +1,73 @@ +############################################################################### +# +# 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 nodescraper.interfaces import DataAnalyzer + +from ..switch_analyzer_base import SwitchAnalyzerBase +from .analyzer_args import ScaleOutDellAnalyzerArgs +from .scaleoutdelldata import 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] = re.compile(r"^(?:Eth)?(\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: 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 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..361851e5 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_dell/scale_out_dell_collector.py @@ -0,0 +1,779 @@ +############################################################################### +# +# 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 + +from pydantic import ValidationError + +from nodescraper.base import InBandDataCollector +from nodescraper.connection.inband import CommandArtifact, TextFileArtifact +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 .scaleoutdelldata import ( + DellArpEntry, + DellFecStatus, + DellInterfaceCounters, + DellInterfaceDetailCounters, + DellInterfaceStatus, + DellPfcStatistics, + DellPfcWatchdogQueueStats, + DellPortData, + DellQueueCounter, + DellRouteEntry, + ScaleOutDellDataModel, +) + + +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.LINUX, OSFamily.UNKNOWN} + + DATA_MODEL = ScaleOutDellDataModel + + # Commands whose output is saved as file artifacts (not parsed into the data model). + ARTIFACT_COMMANDS: list[str] = [ + "show clock", + "show version", + "show platform syseeprom", + "show platform firmware detail", + "show running-configuration", + "show interface transceiver", + "show interface transceiver summary", + "show ip interfaces", + "show qos map dscp-tc", + "show qos map tc-queue", + "show qos map tc-pg", + "show qos map tc-dscp", + "show qos map tc-dot1p", + "show qos map pfc-priority-queue", + "show qos map pfc-priority-pg", + "show qos map dot1p-tc", + "show qos scheduler-policy", + "show qos wred-policy", + "show qos interface Eth all", + "show qos interface Eth all queue all", + "show priority-flow-control watchdog", + "show buffer profile", + "show buffer pool", + "show interface transceiver dom", + "show lldp table", + "show lldp neighbor", + "show interface Eth", + "show interface phy counters", + "show interface counters rate", + "show queue watermark unicast", + "show queue watermark multicast", + "show queue persistent-watermark unicast", + "show queue persistent-watermark multicast", + "show platform environment", + "show event details", + "show alarm", + "show interface fec status", # temporarily added as artifact + ] + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + + def _log_file_artifact(self, filename: str, contents: str) -> None: + """Append plain-text command output to the result as a file artifact.""" + self.result.artifacts.append(TextFileArtifact(filename=filename, contents=contents)) + + @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}"' + + def _run_dell_command(self, command: str) -> Optional[str]: + """Run a Dell SONiC CLI command via ``sonic-cli -c`` with paging suppressed. + + Args: + command: The CLI command to run. + + Returns: + The command stdout, or ``None`` on error. + """ + inner = command if command.strip() == "show version" else f"{command} | no-more" + full_cmd = self._wrap_sonic_cli(inner) + cmd_ret: CommandArtifact = self._run_sut_cmd(full_cmd) + if cmd_ret.exit_code != 0: + self._log_event( + category=EventCategory.APPLICATION, + 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 + # ------------------------------------------------------------------ + + 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("show interface status") + if text is None: + return None + line_pattern = re.compile( + r"^(?PEth\S+)" + r"\s+(?P\S+)" + r"\s+(?P\S+)" + r"\s+(?P\S+)" + r"\s+(?P\S+)" + r"\s+(?P\d+)" + r"\s+(?P\d+)" + r"\s+(?P\S+)" + ) + result: Dict[str, DellInterfaceStatus] = {} + for line in text.splitlines(): + match = line_pattern.match(line.strip()) + if not match: + continue + name = match.group("name") + try: + result[name] = DellInterfaceStatus( + name=name, + description=match.group("description"), + oper=match.group("oper"), + 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"), + ) + except (ValidationError, TypeError) as e: + self._log_event( + category=EventCategory.APPLICATION, + 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("show 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.APPLICATION, + 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: + text = self._run_dell_command(f"show interface counters {port_name}") + 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 + + @classmethod + def _parse_detail_counters_block(cls, text: str) -> Optional[DellInterfaceDetailCounters]: + """Parse one port's ``