diff --git a/nodescraper/base/inbandcollectortask.py b/nodescraper/base/inbandcollectortask.py index d12b58dc..25a3c55b 100644 --- a/nodescraper/base/inbandcollectortask.py +++ b/nodescraper/base/inbandcollectortask.py @@ -80,6 +80,7 @@ def _run_sut_cmd( timeout: int = 300, strip: bool = True, log_artifact: bool = True, + html_view: Optional[bool] = None, ) -> CommandArtifact: """ Run a command on the SUT and return the result. @@ -90,6 +91,8 @@ def _run_sut_cmd( timeout (int, optional): command timeout in seconds. Defaults to 300. strip (bool, optional): whether output should be stripped. Defaults to True. log_artifact (bool, optional): whether we should log the command result. Defaults to True. + html_view (Optional[bool], optional): whether to include this command in HTML + artifacts. When omitted, uses collection_args.html_view. Returns: CommandArtifact: The result of the command execution, which includes stdout, stderr, and exit code. @@ -97,7 +100,9 @@ def _run_sut_cmd( command_res = self.connection.run_command( command=command, sudo=sudo, timeout=timeout, strip=strip ) - if log_artifact: + effective_html_view = self._effective_html_view(html_view) + if log_artifact or effective_html_view: + command_res.log_html = effective_html_view self.result.artifacts.append(command_res) return command_res diff --git a/nodescraper/base/redfishcollectortask.py b/nodescraper/base/redfishcollectortask.py index ed67c660..bdeb65cd 100644 --- a/nodescraper/base/redfishcollectortask.py +++ b/nodescraper/base/redfishcollectortask.py @@ -68,18 +68,23 @@ def _run_redfish_get( self, path: str, log_artifact: bool = True, + html_view: Optional[bool] = None, ) -> RedfishGetResult: """Run a Redfish GET request and return the result. Args: path: Redfish URI path log_artifact: If True, append the result to self.result.artifacts. + html_view: When set, controls HTML artifact output. When omitted, uses + collection_args.html_view. Returns: RedfishGetResult: path, success, data (or error), status_code. """ res = self.connection.run_get(path) - if log_artifact: + effective_html_view = self._effective_html_view(html_view) + if log_artifact or effective_html_view: + res.log_html = effective_html_view self.result.artifacts.append(res) return res @@ -88,6 +93,7 @@ def _run_redfish_get_paged( path: str, max_pages: int = 200, log_artifact: bool = True, + html_view: Optional[bool] = None, ) -> RedfishGetResult: """ Run a Redfish GET and follow Members@odata.nextLink pagination, merging all pages into a single response. @@ -96,11 +102,29 @@ def _run_redfish_get_paged( path (str): Redfish URI path. max_pages (int, optional): safety cap on the number of pages to follow. Defaults to 200. log_artifact (bool, optional): whether we should log the merged result. Defaults to True. + html_view (Optional[bool], optional): whether to include this request in HTML artifacts. + When omitted, uses collection_args.html_view. Returns: RedfishGetResult: path, success, merged data (or error), status_code. """ res = self.connection.run_get_paged(path, max_pages=max_pages) - if log_artifact: + effective_html_view = self._effective_html_view(html_view) + if log_artifact or effective_html_view: + res.log_html = effective_html_view + self.result.artifacts.append(res) + return res + + def _append_redfish_artifact( + self, + res: RedfishGetResult, + *, + log_artifact: bool = True, + html_view: Optional[bool] = None, + ) -> RedfishGetResult: + """Append a Redfish GET result to task artifacts with log flags applied.""" + effective_html_view = self._effective_html_view(html_view) + if log_artifact or effective_html_view: + res.log_html = effective_html_view self.result.artifacts.append(res) return res diff --git a/nodescraper/command_artifact_html.py b/nodescraper/command_artifact_html.py new file mode 100644 index 00000000..5bf3c5be --- /dev/null +++ b/nodescraper/command_artifact_html.py @@ -0,0 +1,231 @@ +############################################################################### +# +# 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 html + +COMMAND_ARTIFACTS_BASENAME = "command_artifacts" + +_HTML_HEAD = """ + + + + +Command Artifacts + + + +""" + +_HEADER_TEMPLATE = """
+

Command Artifacts

+
{count} commands · {title}
+
+ + + +
+
+
+""" + +_CARD_TEMPLATE = """
+ + + {command} + exit {exit_code} + +
+ {stdout_block} + {stderr_block} +
+
""" + +_HTML_TAIL = """ +
No commands match your filter.
+
+ + + +""" + + +def render_command_artifacts_html(entries: list[dict], title: str) -> str: + """Render command artifact entries into a self-contained HTML page. + + Args: + entries: Records with command, stdout, stderr, and exit_code keys. + title: Label shown in the page header. + + Returns: + str: Full HTML document. + """ + cards: list[str] = [] + for entry in entries: + command = html.escape(str(entry.get("command", "") or "")) + stdout = str(entry.get("stdout", "") or "") + stderr = str(entry.get("stderr", "") or "") + exit_code = entry.get("exit_code", "") + badge_cls = "ok" if exit_code == 0 else "fail" + + if stdout.strip(): + stdout_block = "
" + html.escape(stdout) + "
" + else: + stdout_block = '
(no stdout)
' + + if stderr.strip(): + stderr_block = ( + '
stderr
' + '
' + html.escape(stderr) + "
" + ) + else: + stderr_block = "" + + cards.append( + _CARD_TEMPLATE.format( + command=command, + badge_cls=badge_cls, + exit_code=html.escape(str(exit_code)), + stdout_block=stdout_block, + stderr_block=stderr_block, + ) + ) + + return ( + _HTML_HEAD + + _HEADER_TEMPLATE.format(count=len(entries), title=html.escape(title)) + + "\n".join(cards) + + _HTML_TAIL + ) diff --git a/nodescraper/connection/inband/inband.py b/nodescraper/connection/inband/inband.py index ef9cb9e2..9a52cb88 100644 --- a/nodescraper/connection/inband/inband.py +++ b/nodescraper/connection/inband/inband.py @@ -37,6 +37,16 @@ class CommandArtifact(BaseModel): stdout: str stderr: str exit_code: int + log_html: bool = False + + def to_html_entry(self) -> dict: + """Return a dict suitable for HTML command artifact rendering.""" + return { + "command": self.command, + "stdout": self.stdout, + "stderr": self.stderr, + "exit_code": self.exit_code, + } class BaseFileArtifact(BaseModel, abc.ABC): 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/connection/redfish/redfish_connection.py b/nodescraper/connection/redfish/redfish_connection.py index 4398b06f..882acbd5 100644 --- a/nodescraper/connection/redfish/redfish_connection.py +++ b/nodescraper/connection/redfish/redfish_connection.py @@ -25,6 +25,7 @@ ############################################################################### from __future__ import annotations +import json from typing import Any, ClassVar, Optional, Union from urllib.parse import urljoin @@ -52,6 +53,19 @@ class RedfishGetResult(BaseModel): data: Optional[dict[str, Any]] = None error: Optional[str] = None status_code: Optional[int] = None + log_html: bool = False + + def to_html_entry(self) -> dict: + """Return a dict suitable for HTML command artifact rendering.""" + stdout = json.dumps(self.data, indent=2, sort_keys=True) if self.data is not None else "" + stderr = self.error or "" + exit_code = 0 if self.success else (self.status_code if self.status_code is not None else 1) + return { + "command": f"GET {self.path}", + "stdout": stdout, + "stderr": stderr, + "exit_code": exit_code, + } class RedfishConnectionError(Exception): 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/datacollectortask.py b/nodescraper/interfaces/datacollectortask.py index 3c30a6ea..d8cec00f 100644 --- a/nodescraper/interfaces/datacollectortask.py +++ b/nodescraper/interfaces/datacollectortask.py @@ -87,6 +87,7 @@ def wrapper( if not collection_arg_model: raise ValueError("No model defined for analysis args") args = collection_arg_model(**args) # type: ignore + collector.apply_collection_html_view(args) result, data = func(collector, args) except Exception as exception: if isinstance(exception, ValidationError): @@ -181,6 +182,7 @@ def __init__( self.system_interaction_level = system_interaction_level self.connection = connection + self._html_view = False allowed_skus = _supported_sku_name_set(self.SUPPORTED_SKUS) if ( @@ -196,6 +198,19 @@ def __init__( f"{self.system_info.platform} platform is not supported for this collector" ) + def apply_collection_html_view(self, args: Optional[TCollectArg]) -> None: + """Apply collection_args.html_view for this collector run.""" + if args is not None and hasattr(args, "html_view"): + self._html_view = bool(args.html_view) + else: + self._html_view = False + + def _effective_html_view(self, html_view: Optional[bool]) -> bool: + """Resolve per-call html_view override against collection_args.html_view.""" + if html_view is not None: + return html_view + return self._html_view + def __init_subclass__(cls, **kwargs) -> None: super().__init_subclass__(**kwargs) if not inspect.isabstract(cls): 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/models/collectorargs.py b/nodescraper/models/collectorargs.py index ebc84952..b1751352 100644 --- a/nodescraper/models/collectorargs.py +++ b/nodescraper/models/collectorargs.py @@ -23,8 +23,18 @@ # SOFTWARE. # ############################################################################### -from pydantic import BaseModel +from pydantic import BaseModel, Field class CollectorArgs(BaseModel): + html_view: bool = Field( + default=False, + description=( + "When true, include logged command artifacts in command_artifacts.html " + "using human-readable output. Arista collectors re-run successful " + "'| json' commands without '| json' so HTML shows native EOS text " + "instead of raw JSON." + ), + ) + model_config = {"extra": "forbid", "exclude_none": True} diff --git a/nodescraper/models/taskresult.py b/nodescraper/models/taskresult.py index 5615e464..be406cfa 100644 --- a/nodescraper/models/taskresult.py +++ b/nodescraper/models/taskresult.py @@ -37,6 +37,7 @@ model_validator, ) +from nodescraper.command_artifact_html import render_command_artifacts_html from nodescraper.enums import EventPriority, ExecutionStatus from nodescraper.utils import get_unique_filename, pascal_to_snake @@ -171,6 +172,7 @@ def log_result(self, log_path: str) -> None: log_file.write(self.model_dump_json(exclude={"artifacts", "events"}, indent=2)) artifact_map: dict[str, list[dict[str, Any]]] = {} + html_entries_by_name: dict[str, list[dict[str, Any]]] = {} for artifact in self.artifacts: if isinstance(artifact, BaseFileArtifact): artifact.log_model(log_path) @@ -183,16 +185,28 @@ def log_result(self, log_path: str) -> None: ) or f"{pascal_to_snake(artifact.__class__.__name__)}s" ) + dumped = artifact.model_dump(mode="json") if name in artifact_map: - artifact_map[name].append(artifact.model_dump(mode="json")) + artifact_map[name].append(dumped) else: - artifact_map[name] = [artifact.model_dump(mode="json")] + artifact_map[name] = [dumped] + if getattr(artifact, "log_html", False) and hasattr(artifact, "to_html_entry"): + html_entries_by_name.setdefault(name, []).append(artifact.to_html_entry()) for name, artifacts in artifact_map.items(): log_name = get_unique_filename(log_path, f"{name}.json") - with open(os.path.join(log_path, log_name), "w", encoding="utf-8") as log_file: + json_path = os.path.join(log_path, log_name) + with open(json_path, "w", encoding="utf-8") as log_file: json.dump(artifacts, log_file, indent=2) + html_entries = html_entries_by_name.get(name, []) + if html_entries: + html_name = log_name[: -len(".json")] + ".html" + html_path = os.path.join(log_path, html_name) + title = self.task or self.parent or name + with open(html_path, "w", encoding="utf-8") as html_file: + html_file.write(render_command_artifacts_html(html_entries, title)) + if self.events: event_log = os.path.join(log_path, "events.json") new_events = [e.model_dump(mode="json", exclude_none=True) for e in self.events] 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..ec47912d --- /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=True, + description=( + "When true, include logged command artifacts in command_artifacts.html " + "using human-readable output. Re-runs successful '| json' commands without " + "'| json' so HTML shows native EOS text instead of raw JSON." + ), + ) 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..c5cf5c35 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_arista/scale_out_arista_collector.py @@ -0,0 +1,907 @@ +############################################################################### +# +# 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 + + 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, + html_view=False if self._html_view else None, + ) + 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._run_html_view_command(command) + return parsed + + def _run_html_view_command(self, json_command: str) -> None: + """Re-run a ``| json`` command without JSON for human-readable HTML output.""" + if not self._html_view or "| json" not in json_command: + return + text_command = json_command.replace(" | json", "") + cmd_ret = self._run_sut_cmd(text_command, html_view=True) + if cmd_ret.exit_code != 0: + self._log_event( + category=EventCategory.SWITCH, + description=f"Error running Arista html_view command: `{text_command}`", + data={ + "command": text_command, + "exit_code": cmd_ret.exit_code, + "stderr": cmd_ret.stderr, + }, + priority=EventPriority.WARNING, + console_log=True, + ) + + 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 _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)``. + """ + 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..ae2d1ef7 --- /dev/null +++ b/nodescraper/plugins/inband/switch/scale_out_dell/collector_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 CollectorArgs + + +class ScaleOutDellCollectorArgs(CollectorArgs): + """Arguments for the Dell SONiC switch collector.""" + + html_view: bool = Field( + default=True, + description=( + "When true, include logged command artifacts in command_artifacts.html " + "using human-readable output." + ), + ) + + 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 ``