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 = """
+
+"""
+
+_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 `` `` detail-counter rows.
+
+ Args:
+ text: Raw command output for a single port.
+
+ Returns:
+ A :class:`DellInterfaceDetailCounters`, or ``None`` if empty.
+ """
+ kwargs: Dict[str, str] = {}
+ line_pattern = re.compile(r"^(?P.+?)\s{2,}(?P\S+)\s*$")
+ for line in text.splitlines():
+ stripped = line.rstrip()
+ if not stripped:
+ continue
+ match = line_pattern.match(stripped)
+ if not match:
+ continue
+ field = self._label_to_field(match.group("label").strip())
+ if field not in DellInterfaceDetailCounters.model_fields:
+ continue
+ kwargs[field] = match.group("value").strip()
+ if not kwargs:
+ return None
+ try:
+ return DellInterfaceDetailCounters.model_validate(kwargs)
+ except (ValidationError, TypeError) as e:
+ self._log_event(
+ category=EventCategory.SWITCH,
+ description="Failed to build DellInterfaceDetailCounters",
+ data={**get_exception_details(e), "parsed_fields": kwargs},
+ priority=EventPriority.WARNING,
+ )
+ return None
+
+ def get_fec_status(self) -> Optional[Dict[str, DellFecStatus]]:
+ """Parse ``show interface fec status`` into per-port FEC models.
+
+ Returns:
+ Mapping of port name to :class:`DellFecStatus`, or ``None``.
+ """
+ text = self._run_dell_command(self.CMD_FEC_STATUS)
+ if text is None:
+ return None
+ result: Dict[str, DellFecStatus] = {}
+ for line in text.splitlines():
+ stripped = line.strip()
+ if not stripped or not stripped.startswith("Eth"):
+ continue
+ tokens = stripped.split()
+ if len(tokens) < 4:
+ continue
+ name = tokens[0]
+ if_state = tokens[-1]
+ admin = tokens[-2]
+ oper = tokens[-3]
+ type_str = " ".join(tokens[1:-3]) if len(tokens) > 4 else None
+ try:
+ result[name] = DellFecStatus(
+ type=type_str,
+ oper=oper,
+ admin=admin,
+ if_state=if_state,
+ )
+ except (ValidationError, TypeError) as e:
+ self._log_event(
+ category=EventCategory.SWITCH,
+ description=f"Failed to build DellFecStatus for {name}",
+ data=get_exception_details(e),
+ priority=EventPriority.WARNING,
+ )
+ return result or None
+
+ def get_ip_arp(self) -> Optional[List[DellArpEntry]]:
+ """Parse ``show ip arp`` into ARP table entries.
+
+ Returns:
+ List of :class:`DellArpEntry`, or ``None``.
+ """
+ text = self._run_dell_command(self.CMD_IP_ARP)
+ if text is None:
+ return None
+ line_pattern = re.compile(
+ r"^(?P\d+\.\d+\.\d+\.\d+)"
+ r"\s+(?P[0-9a-fA-F:]{17})"
+ r"\s+(?P\S+)"
+ r"\s+(?P\S+)"
+ r"\s+(?P\S+)"
+ r"\s+(?P\S+)"
+ )
+ result: List[DellArpEntry] = []
+ for line in text.splitlines():
+ match = line_pattern.match(line.strip())
+ if not match:
+ continue
+ try:
+ result.append(
+ DellArpEntry(
+ address=match.group("address"),
+ hardware_address=match.group("hw"),
+ interface=match.group("iface"),
+ egress_interface=match.group("egress"),
+ type=match.group("type"),
+ action=match.group("action"),
+ )
+ )
+ except (ValidationError, TypeError) as e:
+ self._log_event(
+ category=EventCategory.SWITCH,
+ description="Failed to build DellArpEntry",
+ data=get_exception_details(e),
+ priority=EventPriority.WARNING,
+ )
+ return result or None
+
+ def get_ip_route(self) -> Optional[List[DellRouteEntry]]:
+ """Parse ``show ip route`` into route table entries.
+
+ Returns:
+ List of :class:`DellRouteEntry`, or ``None``.
+ """
+ text = self._run_dell_command(self.CMD_IP_ROUTE)
+ if text is None:
+ return None
+ line_pattern = re.compile(
+ r"^(?P\S+)"
+ r"\s+(?P\d[^\s]*)"
+ r"\s+(?Pvia\s+\S+|Direct)"
+ r"\s+(?P\S+)"
+ r"\s+(?P\d+/\d+)"
+ r"\s+(?P.+?\sago)\s*$"
+ )
+ result: List[DellRouteEntry] = []
+ for line in text.splitlines():
+ match = line_pattern.match(line.strip())
+ if not match:
+ continue
+ try:
+ result.append(
+ DellRouteEntry(
+ code=match.group("code"),
+ destination=match.group("dest"),
+ gateway=match.group("gateway"),
+ interface=match.group("iface"),
+ distance_metric=match.group("dm"),
+ last_update=match.group("last").strip(),
+ )
+ )
+ except (ValidationError, TypeError) as e:
+ self._log_event(
+ category=EventCategory.SWITCH,
+ description="Failed to build DellRouteEntry",
+ data=get_exception_details(e),
+ priority=EventPriority.WARNING,
+ )
+ return result or None
+
+ def get_pfc_statistics(
+ self,
+ ) -> tuple[Optional[Dict[str, DellPfcStatistics]], Optional[Dict[str, DellPfcStatistics]]]:
+ """Parse PFC RX/TX statistics from ``priority-flow-control statistics``.
+
+ Returns:
+ Tuple of ``(rx_by_port, tx_by_port)`` dicts, each value or ``None``.
+ """
+ cmd = self.CMD_PFC_STATISTICS
+ text = self._run_dell_command(cmd)
+ if text is None:
+ return None, None
+
+ rx: Dict[str, DellPfcStatistics] = {}
+ tx: Dict[str, DellPfcStatistics] = {}
+ current = rx # default to RX until we see the transmitted header
+ line_pattern = re.compile(
+ r"^(?PEth\S+)" + "".join(rf"\s+(?P\d+)" for i in range(8))
+ )
+ for line in text.splitlines():
+ stripped = line.strip()
+ low = stripped.lower()
+ if "flow control frames received" in low:
+ current = rx
+ continue
+ if "flow control frames transmitted" in low:
+ current = tx
+ continue
+ match = line_pattern.match(stripped)
+ if not match:
+ continue
+ name = match.group("name")
+ try:
+ current[name] = DellPfcStatistics(
+ **{f"pfc{i}": int(match.group(f"pfc{i}")) for i in range(8)}
+ )
+ except (ValidationError, TypeError) as e:
+ self._log_event(
+ category=EventCategory.SWITCH,
+ description=f"Failed to build DellPfcStatistics for {name}",
+ data=get_exception_details(e),
+ priority=EventPriority.WARNING,
+ )
+ return rx or None, tx or None
+
+ def get_pfc_watchdog_statistics(
+ self,
+ ) -> Optional[Dict[str, List[DellPfcWatchdogQueueStats]]]:
+ """Parse per-queue PFC watchdog statistics for each port.
+
+ Returns:
+ Mapping of port name to a list of
+ :class:`DellPfcWatchdogQueueStats`, or ``None``.
+ """
+ cmd = self.CMD_PFC_WATCHDOG_STATISTICS
+
+ text = self._run_dell_command(cmd)
+ if text is None:
+ return None
+ line_pattern = re.compile(
+ r"^(?PEth\S+)"
+ r"\s+(?P\d+)"
+ 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+)"
+ r"\s+(?P\d+)"
+ r"\s+(?P\d+)"
+ )
+ result: Dict[str, List[DellPfcWatchdogQueueStats]] = {}
+ for line in text.splitlines():
+ match = line_pattern.match(line.strip())
+ if not match:
+ continue
+ name = match.group("name")
+ try:
+ entry = DellPfcWatchdogQueueStats(
+ queue=int(match.group("queue")),
+ status=match.group("status"),
+ storms_detected=int(match.group("storms_det")),
+ storms_restored=int(match.group("storms_res")),
+ transmitted_ok=int(match.group("tx_ok")),
+ transmitted_drop=int(match.group("tx_drop")),
+ received_ok=int(match.group("rx_ok")),
+ received_drop=int(match.group("rx_drop")),
+ tx_last_ok=int(match.group("tx_last_ok")),
+ tx_last_drop=int(match.group("tx_last_drop")),
+ rx_last_ok=int(match.group("rx_last_ok")),
+ rx_last_drop=int(match.group("rx_last_drop")),
+ )
+ result.setdefault(name, []).append(entry)
+ except (ValidationError, TypeError) as e:
+ self._log_event(
+ category=EventCategory.SWITCH,
+ description=f"Failed to build DellPfcWatchdogQueueStats for {name}",
+ data=get_exception_details(e),
+ priority=EventPriority.WARNING,
+ )
+ return result or None
+
+ def get_queue_counters(self) -> Optional[Dict[str, List[DellQueueCounter]]]:
+ """Parse ``show queue counters`` (``Eth*`` rows only) per port.
+
+ Returns:
+ Mapping of port name to a list of :class:`DellQueueCounter`,
+ or ``None``.
+ """
+ text = self._run_dell_command(self.CMD_QUEUE_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\S+)"
+ r"\s+(?P\S+)"
+ r"\s+(?P\S+)"
+ r"\s+(?P\d+)"
+ r"\s+(?P\d+)"
+ )
+ result: Dict[str, List[DellQueueCounter]] = {}
+ for line in text.splitlines():
+ match = line_pattern.match(line.strip())
+ if not match:
+ continue
+ name = match.group("port")
+ try:
+ entry = DellQueueCounter(
+ txq=match.group("txq"),
+ counter_pkts=int(match.group("pkts")),
+ counter_bytes=int(match.group("bytes")),
+ rate_pps=match.group("pps"),
+ rate_bps=match.group("bps"),
+ rate_bits_ps=match.group("bits_ps"),
+ drop_pkts=int(match.group("drop_pkts")),
+ drop_bytes=int(match.group("drop_bytes")),
+ )
+ result.setdefault(name, []).append(entry)
+ except (ValidationError, TypeError) as e:
+ self._log_event(
+ category=EventCategory.SWITCH,
+ description=f"Failed to build DellQueueCounter for {name}",
+ data=get_exception_details(e),
+ priority=EventPriority.WARNING,
+ )
+ return result or None
+
+ # artifact-only collectors
+
+ def collect_artifact_commands(self) -> None:
+ """Run diagnostic commands so their output is captured in ``command_artifacts.json``.
+
+ Each command is executed via :meth:`_run_sut_cmd`, which records the
+ command, stdout, stderr and exit code as a ``CommandArtifact`` in
+ ``command_artifacts.json``. No separate per-command ``.log`` files are
+ written. Failures are logged but do **not** cause the overall
+ collection to fail.
+ """
+ for command in self.ARTIFACT_COMMANDS:
+ full_cmd = self._wrap_sonic_cli(command)
+ try:
+ cmd_ret = self._run_sut_cmd(full_cmd)
+ if cmd_ret.exit_code != 0:
+ self._log_event(
+ category=EventCategory.SWITCH,
+ description=f"Error running artifact command: `{full_cmd}`",
+ data={
+ "command": full_cmd,
+ "exit_code": cmd_ret.exit_code,
+ "stderr": cmd_ret.stderr,
+ },
+ priority=EventPriority.ERROR,
+ console_log=True,
+ )
+ continue
+ except Exception as e:
+ self._log_event(
+ category=EventCategory.SWITCH,
+ description=f"Error collecting artifact for command: `{command}`",
+ data={
+ "command": command,
+ "exception": get_exception_traceback(e),
+ },
+ priority=EventPriority.WARNING,
+ console_log=True,
+ )
+
+ def _preflight_check(self) -> bool:
+ """Verify the device is a reachable Dell SONiC switch.
+
+ Ensures the device responds and identifies as Dell SONiC.
+
+ On failure this sets ``self.result.status`` to
+ :attr:`ExecutionStatus.NOT_RAN` and returns ``False``.
+
+ Returns:
+ ``True`` if the pre-flight check passed, ``False`` otherwise.
+ """
+ version_text = self._run_dell_command(self.CMD_VERSION)
+ if version_text is None:
+ self._log_event(
+ category=EventCategory.SWITCH,
+ description="ScaleOutDellCollector pre-flight check failed",
+ priority=EventPriority.ERROR,
+ console_log=True,
+ )
+ self.result.status = ExecutionStatus.NOT_RAN
+ return False
+
+ if not self._is_dell_output(version_text):
+ self._log_event(
+ category=EventCategory.SWITCH,
+ description="Not a Dell SONiC switch",
+ data={"raw_output": version_text},
+ priority=EventPriority.ERROR,
+ console_log=True,
+ )
+ self.result.status = ExecutionStatus.NOT_RAN
+ return False
+
+ return True
+
+ # main entry point
+
+ def collect_data(
+ self, args: Optional[ScaleOutDellCollectorArgs] = None
+ ) -> tuple[TaskResult, Optional[ScaleOutDellDataModel]]:
+ """Run all Dell collectors and assemble the switch data model.
+
+ Args:
+ args: Optional :class:`ScaleOutDellCollectorArgs`; its ``ports``
+ attribute restricts per-port detail collection, defaulting to
+ every port from ``show interface status``.
+
+ Returns:
+ Tuple of ``(TaskResult, ScaleOutDellDataModel | None)``.
+ """
+
+ if not self._preflight_check():
+ return self.result, None
+
+ try:
+ interface_status = self.get_interface_status()
+ interface_counters = self.get_interface_counters()
+ fec_status = self.get_fec_status()
+ ip_arp = self.get_ip_arp()
+ ip_route = self.get_ip_route()
+ pfc_rx, pfc_tx = self.get_pfc_statistics()
+ pfc_watchdog = self.get_pfc_watchdog_statistics()
+ queue_counters = self.get_queue_counters()
+
+ # Determine the port list before issuing per-port commands.
+ ports_arg = args.collection_ports if args else None
+ detail_port_names: List[str]
+ if ports_arg is not None:
+ if not isinstance(ports_arg, list) or not all(
+ isinstance(p, str) for p in ports_arg
+ ):
+ self._log_event(
+ category=EventCategory.SWITCH,
+ description="Invalid 'ports' arg for ScaleOutDellCollector",
+ data={"ports": ports_arg},
+ priority=EventPriority.ERROR,
+ console_log=True,
+ )
+ self.result.status = ExecutionStatus.EXECUTION_FAILURE
+ return self.result, None
+ resolved_names, invalid_port = resolve_detail_port_names(
+ ports_arg, interface_status
+ )
+ if invalid_port is not None or resolved_names is None:
+ self._log_event(
+ category=EventCategory.SWITCH,
+ description="Invalid collection_ports entry for ScaleOutDellCollector",
+ data={"port": invalid_port, "ports": ports_arg},
+ priority=EventPriority.ERROR,
+ console_log=True,
+ )
+ self.result.status = ExecutionStatus.EXECUTION_FAILURE
+ return self.result, None
+ detail_port_names = resolved_names
+ elif interface_status:
+ detail_port_names = list(interface_status.keys())
+ else:
+ detail_port_names = []
+
+ detail_counters = self.get_detail_counters(detail_port_names)
+
+ self.collect_artifact_commands()
+ except Exception as e:
+ self._log_event(
+ category=EventCategory.SWITCH,
+ description="Error running Dell 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
+
+ # The canonical port list comes from ``show interface status`` so
+ # that names are in ``Eth1/x/y`` form
+ all_port_names: set[str] = set(interface_status.keys()) if interface_status else set()
+
+ port_data: Optional[Dict[str, DellPortData]] = None
+ if all_port_names:
+ port_data = {}
+ for name in sorted(all_port_names):
+ port_data[name] = DellPortData(
+ interface_status=interface_status.get(name) if interface_status else None,
+ interface_counters=(
+ interface_counters.get(name) if interface_counters else None
+ ),
+ detail_counters=detail_counters.get(name) if detail_counters else None,
+ fec_status=fec_status.get(name) if fec_status else None,
+ pfc_rx_statistics=pfc_rx.get(name) if pfc_rx else None,
+ pfc_tx_statistics=pfc_tx.get(name) if pfc_tx else None,
+ pfc_watchdog_statistics=(pfc_watchdog.get(name) if pfc_watchdog else None),
+ queue_counters=queue_counters.get(name) if queue_counters else None,
+ )
+
+ try:
+ dell_data = ScaleOutDellDataModel(
+ ip_arp=ip_arp,
+ ip_route=ip_route,
+ 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 ScaleOutDellDataModel",
+ data=get_exception_details(e),
+ priority=EventPriority.ERROR,
+ )
+ self.result.status = ExecutionStatus.EXECUTION_FAILURE
+ return self.result, None
+
+ self.result.message = "Dell switch data collected"
+ return self.result, dell_data
diff --git a/nodescraper/plugins/inband/switch/scale_out_dell/scale_out_dell_plugin.py b/nodescraper/plugins/inband/switch/scale_out_dell/scale_out_dell_plugin.py
new file mode 100644
index 00000000..60a40ade
--- /dev/null
+++ b/nodescraper/plugins/inband/switch/scale_out_dell/scale_out_dell_plugin.py
@@ -0,0 +1,48 @@
+###############################################################################
+#
+# MIT License
+#
+# Copyright (c) 2026 Advanced Micro Devices, Inc.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+#
+###############################################################################
+from nodescraper.base import InBandDataPlugin
+
+from .analyzer_args import ScaleOutDellAnalyzerArgs
+from .collector_args import ScaleOutDellCollectorArgs
+from .scale_out_dell_analyzer import ScaleOutDellAnalyzer
+from .scale_out_dell_collector import ScaleOutDellCollector
+from .scaleoutdelldata import ScaleOutDellDataModel
+
+
+class ScaleOutDellPlugin(
+ InBandDataPlugin[ScaleOutDellDataModel, ScaleOutDellCollectorArgs, ScaleOutDellAnalyzerArgs]
+):
+ """Plugin for collection and analysis of Dell SONiC switch data"""
+
+ DATA_MODEL = ScaleOutDellDataModel
+
+ COLLECTOR = ScaleOutDellCollector
+
+ COLLECTOR_ARGS = ScaleOutDellCollectorArgs
+
+ ANALYZER = ScaleOutDellAnalyzer
+
+ ANALYZER_ARGS = ScaleOutDellAnalyzerArgs
diff --git a/nodescraper/plugins/inband/switch/scale_out_dell/scaleoutdelldata.py b/nodescraper/plugins/inband/switch/scale_out_dell/scaleoutdelldata.py
new file mode 100644
index 00000000..5c6af271
--- /dev/null
+++ b/nodescraper/plugins/inband/switch/scale_out_dell/scaleoutdelldata.py
@@ -0,0 +1,286 @@
+###############################################################################
+#
+# MIT License
+#
+# Copyright (c) 2026 Advanced Micro Devices, Inc.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+#
+###############################################################################
+
+from typing import ClassVar, Dict, List, Optional
+
+from pydantic import BaseModel, ConfigDict
+from pydantic.alias_generators import to_camel
+
+from nodescraper.models import DataModel
+
+
+class DellArpEntry(BaseModel):
+ """Single entry from ``show ip arp``"""
+
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
+
+ address: Optional[str] = None
+ hardware_address: Optional[str] = None
+ interface: Optional[str] = None
+ egress_interface: Optional[str] = None
+ type: Optional[str] = None
+ action: Optional[str] = None
+
+ error_fields: ClassVar[dict[str, str]] = {
+ "address": "NOT_NULL",
+ "hardware_address": "NOT_NULL",
+ }
+
+
+class DellRouteEntry(BaseModel):
+ """Single entry from ``show ip route``"""
+
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
+
+ code: Optional[str] = None
+ destination: Optional[str] = None
+ gateway: Optional[str] = None
+ interface: Optional[str] = None
+ distance_metric: Optional[str] = None
+ last_update: Optional[str] = None
+
+ error_fields: ClassVar[dict[str, str]] = {
+ "destination": "NOT_NULL",
+ }
+
+
+class DellInterfaceStatus(BaseModel):
+ """Per-port info from ``show interface status``"""
+
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
+
+ name: Optional[str] = None
+ description: Optional[str] = None
+ oper: Optional[str] = None
+ reason: Optional[str] = None
+ auto_neg: Optional[str] = None
+ speed: Optional[int] = None
+ mtu: Optional[int] = None
+ alternate_name: Optional[str] = None
+
+ error_fields: ClassVar[dict[str, str]] = {
+ "oper": "up",
+ }
+
+
+class DellFecStatus(BaseModel):
+ """Per-port FEC status from ``show interface fec status``"""
+
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
+
+ type: Optional[str] = None
+ oper: Optional[str] = None
+ admin: Optional[str] = None
+ if_state: Optional[str] = None
+
+
+class DellInterfaceCounters(BaseModel):
+ """Per-port summary counters from ``show interface counters``"""
+
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
+
+ state: Optional[str] = None
+ rx_ok: Optional[int] = None
+ rx_err: Optional[int] = None
+ rx_drp: Optional[int] = None
+ rx_oversize: Optional[int] = None
+ tx_ok: Optional[int] = None
+ tx_err: Optional[int] = None
+ tx_drp: Optional[int] = None
+ tx_oversize: Optional[int] = None
+
+ error_fields: ClassVar[dict[str, str]] = {
+ "state": "U",
+ "rx_err": "0",
+ "rx_oversize": "0",
+ "tx_err": "0",
+ "tx_oversize": "0",
+ }
+
+ warning_fields: ClassVar[dict[str, str]] = {
+ "rx_drp": "0",
+ "tx_drp": "0",
+ }
+
+
+class DellInterfaceDetailCounters(BaseModel):
+ """Detailed per-port counters from ``show interface counters Eth``"""
+
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
+
+ packets_received_64_octets: Optional[int] = None
+ packets_received_65_127_octets: Optional[int] = None
+ packets_received_128_255_octets: Optional[int] = None
+ packets_received_256_511_octets: Optional[int] = None
+ packets_received_512_1023_octets: Optional[int] = None
+ packets_received_1024_1518_octets: Optional[int] = None
+ packets_received_1519_2047_octets: Optional[int] = None
+ packets_received_2048_4095_octets: Optional[int] = None
+ packets_received_4096_9216_octets: Optional[int] = None
+ packets_received_9217_16383_octets: Optional[int] = None
+ total_packets_received_without_errors: Optional[int] = None
+ unicast_packets_received: Optional[int] = None
+ multicast_packets_received: Optional[int] = None
+ broadcast_packets_received: Optional[int] = None
+ jabbers_received: Optional[int] = None
+ fragments_received: Optional[int] = None
+ undersize_received: Optional[int] = None
+ overruns_received: Optional[int] = None
+ crc_errors_received: Optional[int] = None
+
+ packets_transmitted_64_octets: Optional[int] = None
+ packets_transmitted_65_127_octets: Optional[int] = None
+ packets_transmitted_128_255_octets: Optional[int] = None
+ packets_transmitted_256_511_octets: Optional[int] = None
+ packets_transmitted_512_1023_octets: Optional[int] = None
+ packets_transmitted_1024_1518_octets: Optional[int] = None
+ packets_transmitted_1519_2047_octets: Optional[int] = None
+ packets_transmitted_2048_4095_octets: Optional[int] = None
+ packets_transmitted_4096_9216_octets: Optional[int] = None
+ packets_transmitted_9217_16383_octets: Optional[int] = None
+ total_packets_transmitted_successfully: Optional[int] = None
+ unicast_packets_transmitted: Optional[int] = None
+ multicast_packets_transmitted: Optional[int] = None
+ broadcast_packets_transmitted: Optional[int] = None
+
+ time_since_counters_last_cleared: Optional[str] = None
+
+ error_fields: ClassVar[dict[str, str]] = {
+ "packets_received_9217_16383_octets": "0",
+ "packets_transmitted_9217_16383_octets": "0",
+ "jabbers_received": "0",
+ "fragments_received": "0",
+ "undersize_received": "0",
+ "overruns_received": "0",
+ "crc_errors_received": "0",
+ }
+
+
+class DellQueueCounter(BaseModel):
+ """Single queue counter entry from ``show queue counters``"""
+
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
+
+ txq: Optional[str] = None
+ counter_pkts: Optional[int] = None
+ counter_bytes: Optional[int] = None
+ rate_pps: Optional[str] = None
+ rate_bps: Optional[str] = None
+ rate_bits_ps: Optional[str] = None
+ drop_pkts: Optional[int] = None
+ drop_bytes: Optional[int] = None
+
+ warning_fields: ClassVar[dict[str, str]] = {
+ "drop_pkts": "0",
+ "drop_bytes": "0",
+ }
+
+
+class DellPfcStatistics(BaseModel):
+ """PFC frames per-priority for a single port and direction.
+
+ Populated from
+ ``show qos interface Ethernet all priority-flow-control statistics``
+ """
+
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
+
+ pfc0: Optional[int] = None
+ pfc1: Optional[int] = None
+ pfc2: Optional[int] = None
+ pfc3: Optional[int] = None
+ pfc4: Optional[int] = None
+ pfc5: Optional[int] = None
+ pfc6: Optional[int] = None
+ pfc7: Optional[int] = None
+
+ warning_fields: ClassVar[dict[str, str]] = {
+ "pfc0": "0",
+ "pfc1": "0",
+ "pfc2": "0",
+ "pfc3": "0",
+ "pfc4": "0",
+ "pfc5": "0",
+ "pfc6": "0",
+ "pfc7": "0",
+ }
+
+
+class DellPfcWatchdogQueueStats(BaseModel):
+ """Per-queue PFC watchdog stats for a port"""
+
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
+
+ queue: Optional[int] = None
+ status: Optional[str] = None
+ storms_detected: Optional[int] = None
+ storms_restored: Optional[int] = None
+ transmitted_ok: Optional[int] = None
+ transmitted_drop: Optional[int] = None
+ received_ok: Optional[int] = None
+ received_drop: Optional[int] = None
+ tx_last_ok: Optional[int] = None
+ tx_last_drop: Optional[int] = None
+ rx_last_ok: Optional[int] = None
+ rx_last_drop: Optional[int] = None
+
+ warning_fields: ClassVar[dict[str, str]] = {
+ "storms_detected": "0",
+ "storms_restored": "0",
+ "transmitted_ok": "0",
+ "transmitted_drop": "0",
+ "received_ok": "0",
+ "received_drop": "0",
+ "tx_last_ok": "0",
+ "tx_last_drop": "0",
+ "rx_last_ok": "0",
+ "rx_last_drop": "0",
+ }
+
+
+class DellPortData(BaseModel):
+ """All collected per-port data for a Dell switch"""
+
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
+
+ interface_status: Optional[DellInterfaceStatus] = None
+ interface_counters: Optional[DellInterfaceCounters] = None
+ detail_counters: Optional[DellInterfaceDetailCounters] = None
+ fec_status: Optional[DellFecStatus] = None
+ pfc_rx_statistics: Optional[DellPfcStatistics] = None
+ pfc_tx_statistics: Optional[DellPfcStatistics] = None
+ pfc_watchdog_statistics: Optional[List[DellPfcWatchdogQueueStats]] = None
+ queue_counters: Optional[List[DellQueueCounter]] = None
+
+
+class ScaleOutDellDataModel(DataModel):
+ """Collected output of Dell SONiC switch commands"""
+
+ ip_arp: Optional[List[DellArpEntry]] = None
+ ip_route: Optional[List[DellRouteEntry]] = None
+ port_list: Optional[List[str]] = None
+
+ port: Optional[Dict[str, DellPortData]] = None
diff --git a/nodescraper/plugins/inband/switch/switch_analyzer_base.py b/nodescraper/plugins/inband/switch/switch_analyzer_base.py
new file mode 100644
index 00000000..bc0a5ba6
--- /dev/null
+++ b/nodescraper/plugins/inband/switch/switch_analyzer_base.py
@@ -0,0 +1,517 @@
+###############################################################################
+#
+# 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 datetime
+import logging
+import re
+from functools import lru_cache
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ ClassVar,
+ Generic,
+ Iterable,
+ Mapping,
+ Optional,
+ Type,
+ TypeVar,
+ Union,
+ get_args,
+ get_origin,
+)
+
+from pydantic import BaseModel
+
+from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus
+from nodescraper.models import DataModel, TaskResult
+from nodescraper.utils import get_exception_traceback
+
+TSwitchData = TypeVar("TSwitchData", bound=DataModel)
+
+
+def _unwrap_optional(annotation: Any) -> Any:
+ """Strip a single ``None`` arm from ``Optional[X]`` / ``X | None``"""
+
+ args = get_args(annotation)
+ if not args:
+ return annotation
+ non_none = [a for a in args if a is not type(None)]
+ if len(non_none) == 1 and len(non_none) < len(args):
+ return non_none[0]
+ return annotation
+
+
+@lru_cache(maxsize=None)
+def _classify_port_submodel_fields(
+ port_cls: Type[BaseModel],
+) -> tuple[tuple[tuple[str, Type[BaseModel]], ...], tuple[tuple[str, Type[BaseModel]], ...]]:
+ """Inspect a per-port pydantic model and split its sub-model fields"""
+
+ scalars: list[tuple[str, Type[BaseModel]]] = []
+ lists: list[tuple[str, Type[BaseModel]]] = []
+
+ for name, field in port_cls.model_fields.items():
+ inner = _unwrap_optional(field.annotation)
+
+ if isinstance(inner, type) and issubclass(inner, BaseModel):
+ scalars.append((name, inner))
+ continue
+
+ origin = get_origin(inner)
+ if origin in (list, tuple):
+ elem_args = get_args(inner)
+ if elem_args:
+ elem = _unwrap_optional(elem_args[0])
+ if isinstance(elem, type) and issubclass(elem, BaseModel):
+ lists.append((name, elem))
+
+ return tuple(scalars), tuple(lists)
+
+
+def _model_is_analyzed(model_cls: Type[BaseModel]) -> bool:
+ """Return True if ``model_cls`` declares any error/warning fields"""
+
+ return bool(
+ getattr(model_cls, "error_fields", None) or getattr(model_cls, "warning_fields", None)
+ )
+
+
+def _values_match(actual: Any, expected: Any) -> bool:
+ """Compare an actual model value to an expected value."""
+
+ if isinstance(expected, str) and expected == "NOT_NULL":
+ if actual is None:
+ return False
+ return str(actual) != ""
+ if isinstance(expected, bool) or isinstance(actual, bool):
+ return bool(actual) == bool(expected)
+ return str(actual) == str(expected)
+
+
+class SwitchAnalyzerBase(Generic[TSwitchData]):
+ """Shared scaffolding for vendor-specific switch analyzers.
+
+ A mixin that walks the vendor data model and flags sub-models whose
+ ``error_fields`` / ``warning_fields`` values mismatch.
+ Subclasses set :attr:`VENDOR_NAME`, :attr:`DATA_MODEL`,
+ :attr:`PORT_NAME_RE`, :attr:`PORT_FORMAT_HINT` and may override
+ :meth:`_walk_system` for non-port checks.
+ """
+
+ VENDOR_NAME: ClassVar[str]
+ DATA_MODEL: Type[DataModel]
+ PORT_NAME_RE: ClassVar[re.Pattern]
+ PORT_FORMAT_HINT: ClassVar[str] = "expected canonical port form"
+
+ if TYPE_CHECKING:
+ # These attributes/methods are provided by ``DataAnalyzer`` (via
+ # ``Task``) on the concrete vendor analyzer that mixes this class in.
+ result: TaskResult
+ logger: logging.Logger
+
+ def _log_event(
+ self,
+ category: Union[EventCategory, str],
+ description: str,
+ priority: EventPriority,
+ data: Optional[dict] = None,
+ timestamp: Optional[datetime.datetime] = None,
+ console_log: bool = False,
+ ) -> None: ...
+
+ def analyze_data(
+ self,
+ data: TSwitchData,
+ args: Optional[BaseModel] = None,
+ ) -> TaskResult:
+ """Analyze a single vendor's switch data model"""
+
+ ports = getattr(args, "analysis_ports", None) if args is not None else None
+ self._analyzer_args = args
+
+ try:
+ allowed_ports = self._parse_ports_kwarg(ports)
+ except (TypeError, ValueError) as exc:
+ self.result.message = f"Invalid 'ports' filter: {exc}"
+ self.result.status = ExecutionStatus.EXECUTION_FAILURE
+ return self.result
+
+ try:
+ has_errors, has_warnings = self._run_checks(data, allowed_ports)
+ except Exception as e:
+ self._log_event(
+ category=EventCategory.SWITCH,
+ description=f"Unhandled error while analyzing {self.VENDOR_NAME} data",
+ data={"exception": get_exception_traceback(e)},
+ priority=EventPriority.ERROR,
+ console_log=True,
+ )
+ self.result.message = f"{self.VENDOR_NAME} analysis failed with an unhandled error"
+ self.result.status = ExecutionStatus.EXECUTION_FAILURE
+ return self.result
+
+ if not has_errors and not has_warnings:
+ self.result.message = f"No {self.VENDOR_NAME} errors or warnings detected"
+ self.result.status = ExecutionStatus.OK
+ elif has_errors and has_warnings:
+ self.result.message = f"{self.VENDOR_NAME} errors and warnings detected"
+ self.result.status = ExecutionStatus.ERROR
+ elif has_errors:
+ self.result.message = f"{self.VENDOR_NAME} errors detected"
+ self.result.status = ExecutionStatus.ERROR
+ else:
+ self.result.message = f"{self.VENDOR_NAME} warnings detected"
+ self.result.status = ExecutionStatus.WARNING
+
+ return self.result
+
+ # Hooks for subclasses
+ def _walk_system(self, switch_data: TSwitchData) -> list[dict[str, Any]]:
+ """Return findings for vendor-specific top-level (non-port) sections"""
+
+ return []
+
+ # Common machinery
+ def _normalize_port(self, name: str) -> Optional[str]:
+ """Return ``name`` as a canonical port key, else ``None``"""
+
+ match = self.PORT_NAME_RE.match(name.strip())
+ if not match:
+ return None
+ # Vendor regexes either expose a single capture group with the
+ # whole canonical key (slash-joined) or two groups (P, S).
+ groups = match.groups()
+ if len(groups) == 1:
+ return groups[0]
+ if len(groups) == 2 and groups[1] is not None:
+ return f"{groups[0]}/{groups[1]}"
+ return groups[0]
+
+ def _parse_ports_kwarg(self, ports: Any) -> Optional[set[str]]:
+ """Parse the ``ports`` filter into a set of canonical port keys"""
+
+ if ports is None:
+ return None
+
+ if not isinstance(ports, list):
+ raise TypeError(f"Port filter must be a list of strings, got {ports!r}")
+
+ allowed: set[str] = set()
+ for token in ports:
+ if not isinstance(token, str):
+ raise TypeError(f"Port filter entries must be strings, got {token!r}")
+ normalized = self._normalize_port(token)
+ if normalized is None:
+ raise ValueError(f"Invalid port identifier {token!r}; {self.PORT_FORMAT_HINT}")
+ allowed.add(normalized)
+
+ return allowed or None
+
+ def _run_checks(
+ self,
+ switch_data: TSwitchData,
+ allowed_ports: Optional[set[str]],
+ ) -> tuple[bool, bool]:
+ """Execute system- and per-port-level checks.
+
+ Returns:
+ Tuple of (has_errors, has_warnings) derived from findings before events are emitted.
+ """
+
+ findings: list[dict[str, Any]] = list(self._walk_system(switch_data))
+
+ analyzed_ports: list[str] = []
+ skipped_ports: list[str] = []
+ ports_map = getattr(switch_data, "port", None) or {}
+ for port_name, port_data in ports_map.items():
+ if port_data is None:
+ continue
+ if allowed_ports is not None:
+ normalized = self._normalize_port(port_name)
+ if normalized is None or normalized not in allowed_ports:
+ skipped_ports.append(port_name)
+ continue
+ analyzed_ports.append(port_name)
+ findings.extend(self._check_port(port_name, port_data))
+
+ if allowed_ports is not None:
+ self.logger.info(
+ "%s port filter applied: analyzed=%s skipped=%d",
+ self.VENDOR_NAME,
+ analyzed_ports,
+ len(skipped_ports),
+ )
+ unmatched = allowed_ports - {self._normalize_port(p) or "" for p in analyzed_ports}
+ if unmatched:
+ self.logger.warning(
+ "%s port filter had no matching data for: %s",
+ self.VENDOR_NAME,
+ sorted(unmatched),
+ )
+
+ self._emit_grouped_findings(findings)
+ has_errors = any(f["priority"] >= EventPriority.ERROR for f in findings)
+ has_warnings = any(f["priority"] == EventPriority.WARNING for f in findings)
+ return has_errors, has_warnings
+
+ def _emit_grouped_findings(self, findings: list[dict[str, Any]]) -> None:
+ """Emit at most one event per (location, priority) group"""
+
+ # Preserve discovery order of locations and priorities.
+ grouped: dict[tuple[str, EventPriority], list[dict[str, Any]]] = {}
+ for finding in findings:
+ port = finding["context"].get("port")
+ location = port if port else "system"
+ key = (location, finding["priority"])
+ grouped.setdefault(key, []).append(finding)
+
+ for (location, priority), items in grouped.items():
+ kind = "warnings" if priority == EventPriority.WARNING else "errors"
+ mismatches = ", ".join(self._format_mismatch(item) for item in items)
+ self._log_event(
+ category=EventCategory.SWITCH,
+ description=(f"{self.VENDOR_NAME} {kind} detected on {location}: {mismatches}"),
+ data={
+ "location": location,
+ "mismatches": [
+ {
+ **({"missing": True} if item.get("missing") else {}),
+ **dict(item["context"]),
+ "field": item["field"],
+ "actual": item["actual"],
+ "expected": item.get("expected"),
+ }
+ for item in items
+ ],
+ },
+ priority=priority,
+ console_log=True,
+ )
+
+ @staticmethod
+ def _format_mismatch(item: dict[str, Any]) -> str:
+ """Render a single finding for the event description"""
+
+ section = item["context"].get("section")
+ prefix = f"{section}." if section else ""
+ if item.get("missing"):
+ return f"{item['field']} (not collected)"
+ return f"{prefix}{item['field']}={item['actual']!r}"
+
+ def _check_port(self, port_name: str, port_data: BaseModel) -> list[dict[str, Any]]:
+ """Check every sub-model of a single port for errors/warnings.
+
+ Analyzed sub-models that were not collected produce a missing-data
+ warning.
+ """
+
+ findings: list[dict[str, Any]] = []
+
+ scalar_attrs, list_attrs = _classify_port_submodel_fields(type(port_data))
+
+ for attr, model_cls in scalar_attrs:
+ model = getattr(port_data, attr, None)
+ if model is None:
+ if _model_is_analyzed(model_cls):
+ findings.append(
+ self._missing_submodel_finding(
+ attr,
+ model_cls,
+ {"port": port_name, "section": attr},
+ )
+ )
+ continue
+ findings.extend(
+ self._check_model(
+ model,
+ context={"port": port_name, "section": attr},
+ )
+ )
+
+ for attr, elem_cls in list_attrs:
+ items = getattr(port_data, attr, None)
+ if items is None:
+ if _model_is_analyzed(elem_cls):
+ findings.append(
+ self._missing_submodel_finding(
+ attr,
+ elem_cls,
+ {"port": port_name, "section": attr},
+ )
+ )
+ continue
+ for idx, item in enumerate(items):
+ if item is None:
+ continue
+ findings.extend(
+ self._check_model(
+ item,
+ context={"port": port_name, "section": attr, "index": idx},
+ )
+ )
+
+ findings.extend(self._extra_port_findings(port_name, port_data))
+
+ return findings
+
+ def _extra_port_findings(self, port_name: str, port_data: BaseModel) -> list[dict[str, Any]]:
+ """Return additional per-port findings from analyzer args (subclass hook)."""
+
+ return []
+
+ def _port_field_mismatch(
+ self,
+ port_name: str,
+ section: str,
+ field_name: str,
+ actual: Any,
+ expected: Any,
+ model_name: str,
+ priority: EventPriority = EventPriority.ERROR,
+ ) -> Optional[dict[str, Any]]:
+ """Build a finding when a port field does not match the expected value."""
+
+ if actual is None:
+ return None
+ if _values_match(actual, expected):
+ return None
+ return {
+ "priority": priority,
+ "field": field_name,
+ "actual": actual,
+ "expected": expected,
+ "model": model_name,
+ "context": {"port": port_name, "section": section},
+ }
+
+ def _missing_submodel_finding(
+ self,
+ attr: str,
+ model_cls: Type[BaseModel],
+ context: Mapping[str, Any],
+ ) -> dict[str, Any]:
+ """Build a warning finding for an analyzed sub-model that is absent"""
+
+ parent_section = context.get("section")
+ section = f"{parent_section}.{attr}" if parent_section else attr
+ return {
+ "priority": EventPriority.WARNING,
+ "field": attr,
+ "actual": None,
+ "model": model_cls.__name__,
+ "missing": True,
+ "context": {**dict(context), "section": section},
+ }
+
+ def _check_model(self, model: BaseModel, context: Mapping[str, Any]) -> list[dict[str, Any]]:
+ """Check a single pydantic model against its error/warning field dicts"""
+
+ findings: list[dict[str, Any]] = []
+
+ for fields, priority in (
+ (getattr(type(model), "error_fields", None), EventPriority.ERROR),
+ (getattr(type(model), "warning_fields", None), EventPriority.WARNING),
+ ):
+ if not fields:
+ continue
+ findings.extend(self._check_fields(model, fields, priority, context))
+
+ parent_section = context.get("section")
+ scalar_attrs, list_attrs = _classify_port_submodel_fields(type(model))
+
+ for attr, model_cls in scalar_attrs:
+ if not _model_is_analyzed(model_cls):
+ continue
+ nested = getattr(model, attr, None)
+ nested_context = {
+ **dict(context),
+ "section": f"{parent_section}.{attr}" if parent_section else attr,
+ }
+ if nested is None:
+ findings.append(self._missing_submodel_finding(attr, model_cls, context))
+ continue
+ findings.extend(self._check_model(nested, nested_context))
+
+ for attr, elem_cls in list_attrs:
+ if not _model_is_analyzed(elem_cls):
+ continue
+ items = getattr(model, attr, None)
+ nested_section = f"{parent_section}.{attr}" if parent_section else attr
+ if items is None:
+ findings.append(self._missing_submodel_finding(attr, elem_cls, context))
+ continue
+ for idx, item in enumerate(items):
+ if item is None:
+ continue
+ findings.extend(
+ self._check_model(
+ item,
+ {**dict(context), "section": nested_section, "index": idx},
+ )
+ )
+
+ return findings
+
+ def _check_fields(
+ self,
+ model: BaseModel,
+ fields: Union[Mapping[str, Any], Iterable[str]],
+ priority: EventPriority,
+ context: Mapping[str, Any],
+ ) -> list[dict[str, Any]]:
+ """Compare each named field on ``model`` to its expected value"""
+
+ findings: list[dict[str, Any]] = []
+
+ # Support either a dict[name -> expected] or a plain iterable of names
+ # (where the implicit expected value is 0 / "0").
+ iterator: Iterable[tuple[str, Any]]
+ if isinstance(fields, Mapping):
+ iterator = fields.items()
+ else:
+ iterator = ((name, "0") for name in fields)
+
+ for field_name, expected in iterator:
+ if not hasattr(model, field_name):
+ continue
+ actual = getattr(model, field_name)
+ if actual is None and not (isinstance(expected, str) and expected == "NOT_NULL"):
+ continue
+ if _values_match(actual, expected):
+ continue
+
+ findings.append(
+ {
+ "priority": priority,
+ "field": field_name,
+ "actual": actual,
+ "expected": expected,
+ "model": type(model).__name__,
+ "context": dict(context),
+ }
+ )
+
+ return findings
diff --git a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py
index 37bd839b..d76cb33d 100644
--- a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py
+++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py
@@ -178,7 +178,7 @@ def collect_data(
max_endpoints=max_endpoints,
)
for res in results:
- self.result.artifacts.append(res)
+ self._append_redfish_artifact(res)
if not res.success and res.error:
self._log_event(
category=EventCategory.RUNTIME,
@@ -269,7 +269,7 @@ def collect_data(
console_log=True,
)
for res in results:
- self.result.artifacts.append(res)
+ self._append_redfish_artifact(res)
if not responses:
self.result.message = "No Redfish endpoints could be read"
diff --git a/test/unit/connection/test_osdetection.py b/test/unit/connection/test_osdetection.py
new file mode 100644
index 00000000..d9092f04
--- /dev/null
+++ b/test/unit/connection/test_osdetection.py
@@ -0,0 +1,247 @@
+###############################################################################
+#
+# 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
+
+from nodescraper.connection.inband import CommandArtifact
+from nodescraper.connection.inband.inbandmanager import InBandConnectionManager
+from nodescraper.connection.inband.osdetection import (
+ ARISTA_VERSION_CMD,
+ DELL_VERSION_CMD,
+ detect_network_os,
+ parse_arista_version_output,
+ parse_dell_sonic_version_output,
+)
+from nodescraper.enums import OSFamily
+
+DUMMY_ARISTA_VERSION = {
+ "mfgName": "Arista Networks",
+ "version": "4.32.1F",
+ "modelName": "DCS-7280CR3-32P4",
+ "serialNumber": "JPE12345678",
+ "architecture": "x86_64",
+}
+DUMMY_ARISTA_VERSION_JSON = json.dumps(DUMMY_ARISTA_VERSION)
+
+DUMMY_ARISTA_VERSION_MINIMAL = {
+ "mfgName": "Arista Networks",
+ "version": "4.28.0F",
+}
+DUMMY_ARISTA_VERSION_MINIMAL_JSON = json.dumps(DUMMY_ARISTA_VERSION_MINIMAL)
+
+DUMMY_NON_ARISTA_VERSION_JSON = json.dumps({"mfgName": "Cisco Systems", "version": "1.0"})
+
+DUMMY_DELL_SONIC_VERSION_TEXT = """\
+Dell EMC Enterprise SONiC
+SONiC Software Version: 4.1.0-Enterprise
+HwSKU: DellEMC-S5248F-ON
+Serial Number: CN0123456789AB
+"""
+
+DUMMY_DELL_SONIC_VERSION_MINIMAL_TEXT = """\
+Dell SONiC
+SONiC Software Version: 4.0.0
+"""
+
+DUMMY_CISCO_NXOS_VERSION_TEXT = "Cisco NX-OS Software"
+
+DUMMY_UNAME_LINUX = CommandArtifact(
+ command="uname -s",
+ stdout="Linux",
+ stderr="",
+ exit_code=0,
+)
+
+DUMMY_UNAME_FAILED = CommandArtifact(
+ command="uname -s",
+ stdout="",
+ stderr="invalid command",
+ exit_code=1,
+)
+
+DUMMY_ARISTA_VERSION_CMD_OK = CommandArtifact(
+ command=ARISTA_VERSION_CMD,
+ stdout=DUMMY_ARISTA_VERSION_JSON,
+ stderr="",
+ exit_code=0,
+)
+
+DUMMY_ARISTA_VERSION_CMD_FAILED = CommandArtifact(
+ command=ARISTA_VERSION_CMD,
+ stdout="",
+ stderr="invalid",
+ exit_code=1,
+)
+
+DUMMY_DELL_VERSION_CMD_OK = CommandArtifact(
+ command=DELL_VERSION_CMD,
+ stdout=DUMMY_DELL_SONIC_VERSION_TEXT,
+ stderr="",
+ exit_code=0,
+)
+
+DUMMY_DELL_VERSION_CMD_MINIMAL_OK = CommandArtifact(
+ command=DELL_VERSION_CMD,
+ stdout=DUMMY_DELL_SONIC_VERSION_MINIMAL_TEXT,
+ stderr="",
+ exit_code=0,
+)
+
+DUMMY_DELL_VERSION_CMD_NON_DELL = CommandArtifact(
+ command=DELL_VERSION_CMD,
+ stdout=DUMMY_CISCO_NXOS_VERSION_TEXT,
+ stderr="",
+ exit_code=0,
+)
+
+
+def test_parse_arista_version_output_success():
+ detection = parse_arista_version_output(DUMMY_ARISTA_VERSION_JSON)
+
+ assert detection is not None
+ assert detection.os_family == OSFamily.EOS
+ assert detection.platform == "Arista EOS"
+ assert detection.metadata == {
+ "os_version": DUMMY_ARISTA_VERSION["version"],
+ "device_model": DUMMY_ARISTA_VERSION["modelName"],
+ }
+
+
+def test_parse_arista_version_output_rejects_non_arista():
+ assert parse_arista_version_output(DUMMY_NON_ARISTA_VERSION_JSON) is None
+
+
+def test_parse_dell_sonic_version_output_success():
+ detection = parse_dell_sonic_version_output(DUMMY_DELL_SONIC_VERSION_TEXT)
+
+ assert detection is not None
+ assert detection.os_family == OSFamily.SONIC
+ assert detection.platform == "Dell SONiC"
+ assert detection.metadata["os_version"] == "4.1.0-Enterprise"
+ assert detection.metadata["device_model"] == "DellEMC-S5248F-ON"
+
+
+def test_parse_dell_sonic_version_output_rejects_non_dell():
+ assert parse_dell_sonic_version_output(DUMMY_CISCO_NXOS_VERSION_TEXT) is None
+
+
+def test_detect_network_os_arista_first(conn_mock):
+ conn_mock.run_command.return_value = CommandArtifact(
+ command=ARISTA_VERSION_CMD,
+ stdout=DUMMY_ARISTA_VERSION_MINIMAL_JSON,
+ stderr="",
+ exit_code=0,
+ )
+
+ detection = detect_network_os(conn_mock)
+
+ assert detection is not None
+ assert detection.os_family == OSFamily.EOS
+ assert detection.metadata["os_version"] == DUMMY_ARISTA_VERSION_MINIMAL["version"]
+ conn_mock.run_command.assert_called_once_with(ARISTA_VERSION_CMD, timeout=30)
+
+
+def test_detect_network_os_falls_back_to_dell(conn_mock):
+ conn_mock.run_command.side_effect = [
+ DUMMY_ARISTA_VERSION_CMD_FAILED,
+ DUMMY_DELL_VERSION_CMD_MINIMAL_OK,
+ ]
+
+ detection = detect_network_os(conn_mock)
+
+ assert detection is not None
+ assert detection.os_family == OSFamily.SONIC
+ assert detection.metadata["os_version"] == "4.0.0"
+ assert conn_mock.run_command.call_count == 2
+
+
+def test_check_os_family_detects_arista_eos(system_info, conn_mock):
+ manager = InBandConnectionManager(system_info=system_info)
+ manager.connection = conn_mock
+ conn_mock.run_command.side_effect = [
+ DUMMY_UNAME_FAILED,
+ DUMMY_ARISTA_VERSION_CMD_OK,
+ ]
+
+ manager._check_os_family()
+
+ assert system_info.os_family == OSFamily.EOS
+ assert system_info.platform == "Arista EOS"
+ assert system_info.metadata["os_version"] == DUMMY_ARISTA_VERSION["version"]
+ assert system_info.metadata["device_model"] == DUMMY_ARISTA_VERSION["modelName"]
+ assert not any(
+ event.description == "Unable to determine SUT OS" for event in manager.result.events
+ )
+
+
+def test_check_os_family_detects_dell_sonic(system_info, conn_mock):
+ system_info.os_family = OSFamily.UNKNOWN
+ manager = InBandConnectionManager(system_info=system_info)
+ manager.connection = conn_mock
+ conn_mock.run_command.side_effect = [
+ DUMMY_UNAME_FAILED,
+ DUMMY_ARISTA_VERSION_CMD_FAILED,
+ DUMMY_DELL_VERSION_CMD_OK,
+ ]
+
+ manager._check_os_family()
+
+ assert system_info.os_family == OSFamily.SONIC
+ assert system_info.platform == "Dell SONiC"
+ assert system_info.metadata["os_version"] == "4.1.0-Enterprise"
+ assert system_info.metadata["device_model"] == "DellEMC-S5248F-ON"
+ assert not any(
+ event.description == "Unable to determine SUT OS" for event in manager.result.events
+ )
+
+
+def test_check_os_family_still_warns_when_unknown(system_info, conn_mock):
+ system_info.os_family = OSFamily.UNKNOWN
+ manager = InBandConnectionManager(system_info=system_info)
+ manager.connection = conn_mock
+ conn_mock.run_command.side_effect = [
+ DUMMY_UNAME_FAILED,
+ DUMMY_ARISTA_VERSION_CMD_FAILED,
+ DUMMY_DELL_VERSION_CMD_NON_DELL,
+ ]
+
+ manager._check_os_family()
+
+ assert system_info.os_family == OSFamily.UNKNOWN
+ assert any(
+ event.description == "Unable to determine SUT OS" and event.category == "UNKNOWN"
+ for event in manager.result.events
+ )
+
+
+def test_check_os_family_linux_skips_network_probes(system_info, conn_mock):
+ manager = InBandConnectionManager(system_info=system_info)
+ manager.connection = conn_mock
+ conn_mock.run_command.return_value = DUMMY_UNAME_LINUX
+
+ manager._check_os_family()
+
+ assert system_info.os_family == OSFamily.LINUX
+ conn_mock.run_command.assert_called_once_with("uname -s")
diff --git a/test/unit/framework/test_cli_helper.py b/test/unit/framework/test_cli_helper.py
index 7a29d888..b0f666d0 100644
--- a/test/unit/framework/test_cli_helper.py
+++ b/test/unit/framework/test_cli_helper.py
@@ -52,6 +52,7 @@
from nodescraper.models import PluginConfig, TaskResult
from nodescraper.models.datapluginresult import DataPluginResult
from nodescraper.models.pluginresult import PluginResult
+from nodescraper.pluginexecutor import PluginExecutor
from nodescraper.pluginregistry import PluginRegistry
@@ -124,6 +125,13 @@ def test_get_plugin_configs():
]
+def test_global_collection_args_html_view_merged():
+ merged = PluginExecutor.merge_configs(
+ [PluginConfig(global_args={"collection_args": {"html_view": True}})]
+ )
+ assert merged.global_args["collection_args"]["html_view"] is True
+
+
def test_config_builder(plugin_registry):
config = build_config(
diff --git a/test/unit/framework/test_command_artifact_html.py b/test/unit/framework/test_command_artifact_html.py
new file mode 100644
index 00000000..47b74aab
--- /dev/null
+++ b/test/unit/framework/test_command_artifact_html.py
@@ -0,0 +1,226 @@
+###############################################################################
+#
+# 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 pathlib import Path
+
+from nodescraper.command_artifact_html import render_command_artifacts_html
+from nodescraper.connection.inband.inband import CommandArtifact
+from nodescraper.connection.redfish.redfish_connection import RedfishGetResult
+from nodescraper.enums import SystemInteractionLevel
+from nodescraper.models import CollectorArgs, TaskResult
+from nodescraper.plugins.inband.switch.scale_out_arista.collector_args import (
+ ScaleOutAristaCollectorArgs,
+)
+from nodescraper.plugins.inband.switch.scale_out_dell.collector_args import (
+ ScaleOutDellCollectorArgs,
+)
+from nodescraper.plugins.inband.uptime.uptime_collector import UptimeCollector
+
+
+def _command_artifact(result: TaskResult, index: int = 0) -> CommandArtifact:
+ artifact = result.artifacts[index]
+ assert isinstance(artifact, CommandArtifact)
+ return artifact
+
+
+def test_switch_collector_args_default_html_view_true():
+ assert ScaleOutAristaCollectorArgs().html_view is True
+ assert ScaleOutDellCollectorArgs().html_view is True
+
+
+def test_switch_collector_applies_html_view_from_args(system_info, conn_mock):
+ from nodescraper.base import InBandDataCollector
+ from nodescraper.models.datamodel import DataModel
+
+ class _SwitchDataModel(DataModel):
+ value: str = ""
+
+ class _SwitchCollector(InBandDataCollector[_SwitchDataModel, ScaleOutAristaCollectorArgs]):
+ DATA_MODEL = _SwitchDataModel
+ SUPPORTED_OS_FAMILY = {system_info.os_family}
+
+ def collect_data(self, args=None):
+ return self.result, None
+
+ conn_mock.run_command.return_value = CommandArtifact(
+ command="show version",
+ stdout="EOS",
+ stderr="",
+ exit_code=0,
+ )
+ collector = _SwitchCollector(
+ system_info=system_info,
+ system_interaction_level=SystemInteractionLevel.PASSIVE,
+ connection=conn_mock,
+ )
+ collector.apply_collection_html_view(ScaleOutAristaCollectorArgs())
+ collector._run_sut_cmd("show version")
+ assert _command_artifact(collector.result).log_html is True
+
+
+def test_render_command_artifacts_html_includes_command_and_output():
+ html_doc = render_command_artifacts_html(
+ [
+ {
+ "command": "show version",
+ "stdout": "EOS 4.32",
+ "stderr": "",
+ "exit_code": 0,
+ }
+ ],
+ "test_collector",
+ )
+ assert "show version" in html_doc
+ assert "EOS 4.32" in html_doc
+ assert "test_collector" in html_doc
+
+
+def test_command_artifact_to_html_entry():
+ artifact = CommandArtifact(
+ command="uname -a",
+ stdout="linux",
+ stderr="",
+ exit_code=0,
+ log_html=True,
+ )
+ assert artifact.to_html_entry() == {
+ "command": "uname -a",
+ "stdout": "linux",
+ "stderr": "",
+ "exit_code": 0,
+ }
+
+
+def test_redfish_get_result_to_html_entry():
+ artifact = RedfishGetResult(
+ path="/redfish/v1/Systems",
+ success=True,
+ data={"Name": "System"},
+ status_code=200,
+ log_html=True,
+ )
+ entry = artifact.to_html_entry()
+ assert entry["command"] == "GET /redfish/v1/Systems"
+ assert '"Name"' in entry["stdout"]
+ assert entry["exit_code"] == 0
+
+
+def test_run_sut_cmd_defaults_html_view_false(system_info, conn_mock):
+ conn_mock.run_command.return_value = CommandArtifact(
+ command="echo ok",
+ stdout="ok",
+ stderr="",
+ exit_code=0,
+ )
+ collector = UptimeCollector(
+ system_info=system_info,
+ system_interaction_level=SystemInteractionLevel.PASSIVE,
+ connection=conn_mock,
+ )
+
+ collector._run_sut_cmd("echo ok", log_artifact=True)
+ assert len(collector.result.artifacts) == 1
+ assert _command_artifact(collector.result).log_html is False
+
+ collector.result = TaskResult()
+ collector._run_sut_cmd("echo ok", log_artifact=False)
+ assert collector.result.artifacts == []
+
+
+def test_run_sut_cmd_html_view_without_log_artifact(system_info, conn_mock, tmp_path: Path):
+ conn_mock.run_command.return_value = CommandArtifact(
+ command="dmesg",
+ stdout="log line",
+ stderr="",
+ exit_code=0,
+ )
+ collector = UptimeCollector(
+ system_info=system_info,
+ system_interaction_level=SystemInteractionLevel.PASSIVE,
+ connection=conn_mock,
+ )
+ collector.result = TaskResult(task="dmesg_collector")
+
+ collector._run_sut_cmd("dmesg", log_artifact=False, html_view=True)
+ assert len(collector.result.artifacts) == 1
+ assert _command_artifact(collector.result).log_html is True
+
+ collector.result.log_result(str(tmp_path))
+ assert (tmp_path / "command_artifacts.html").exists()
+ html = (tmp_path / "command_artifacts.html").read_text(encoding="utf-8")
+ assert "dmesg" in html
+ assert "log line" in html
+
+
+def test_collection_args_html_view(system_info, conn_mock, tmp_path: Path):
+ from nodescraper.base import InBandDataCollector
+ from nodescraper.models.datamodel import DataModel
+
+ class _DataModel(DataModel):
+ value: str = ""
+
+ class _Collector(InBandDataCollector[_DataModel, CollectorArgs]):
+ DATA_MODEL = _DataModel
+ SUPPORTED_OS_FAMILY = {system_info.os_family}
+
+ def collect_data(self, args=None):
+ return self.result, None
+
+ conn_mock.run_command.return_value = CommandArtifact(
+ command="uptime",
+ stdout="up 1 day",
+ stderr="",
+ exit_code=0,
+ )
+ collector = _Collector(
+ system_info=system_info,
+ system_interaction_level=SystemInteractionLevel.PASSIVE,
+ connection=conn_mock,
+ )
+ collector.apply_collection_html_view(CollectorArgs(html_view=True))
+ collector.result = TaskResult(task="uptime_collector")
+ collector._run_sut_cmd("uptime")
+ assert _command_artifact(collector.result).log_html is True
+
+ collector.result.log_result(str(tmp_path))
+ assert (tmp_path / "command_artifacts.html").exists()
+
+
+def test_log_result_skips_html_when_log_html_false(tmp_path: Path):
+ result = TaskResult(task="package_collector")
+ result.artifacts.append(
+ CommandArtifact(
+ command="rpm -qa",
+ stdout="pkg-1",
+ stderr="",
+ exit_code=0,
+ log_html=False,
+ )
+ )
+
+ result.log_result(str(tmp_path))
+
+ assert (tmp_path / "command_artifacts.json").exists()
+ assert not (tmp_path / "command_artifacts.html").exists()
diff --git a/test/unit/plugin/test_scale_out_arista_analyzer.py b/test/unit/plugin/test_scale_out_arista_analyzer.py
new file mode 100644
index 00000000..4c247f80
--- /dev/null
+++ b/test/unit/plugin/test_scale_out_arista_analyzer.py
@@ -0,0 +1,102 @@
+###############################################################################
+#
+# 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 pytest
+
+from nodescraper.enums.executionstatus import ExecutionStatus
+from nodescraper.plugins.inband.switch.scale_out_arista.analyzer_args import (
+ ScaleOutAristaAnalyzerArgs,
+)
+from nodescraper.plugins.inband.switch.scale_out_arista.scale_out_arista_analyzer import (
+ ScaleOutAristaAnalyzer,
+)
+from nodescraper.plugins.inband.switch.scale_out_arista.scaleoutaristadata import (
+ AristaPortStatus,
+ AristaSystemEnv,
+ FanConfiguration,
+ PortData,
+ ScaleOutAristaDataModel,
+ VlanInformation,
+)
+
+
+@pytest.fixture
+def analyzer(system_info):
+ return ScaleOutAristaAnalyzer(system_info=system_info)
+
+
+def _port(bandwidth=400000000000, link_status="connected"):
+ return PortData(
+ port_status=AristaPortStatus(
+ link_status=link_status,
+ duplex="duplexFull",
+ line_protocol_status="up",
+ bandwidth=bandwidth,
+ vlan_information=VlanInformation(
+ interface_mode="routed",
+ interface_forwarding_model="routed",
+ ),
+ )
+ )
+
+
+def test_expected_port_bandwidth_error(analyzer):
+ data = ScaleOutAristaDataModel(port={"Ethernet1/1": _port(bandwidth=100000000000)})
+ result = analyzer.analyze_data(
+ data, ScaleOutAristaAnalyzerArgs(expected_port_bandwidth=400000000000)
+ )
+ assert result.status == ExecutionStatus.ERROR
+ assert "errors" in result.message.lower()
+
+
+def test_analysis_ports_filter(analyzer):
+ data = ScaleOutAristaDataModel(
+ port={
+ "Ethernet1/1": _port(),
+ "Ethernet1/2": _port(link_status="notconnect"),
+ }
+ )
+ result = analyzer.analyze_data(data, ScaleOutAristaAnalyzerArgs(analysis_ports=["1/1"]))
+ assert all("Ethernet1/2" not in event.description for event in result.events)
+
+
+def test_system_env_fan_error(analyzer):
+ data = ScaleOutAristaDataModel(
+ system_env=AristaSystemEnv(
+ system_status="coolingOk",
+ fans_status="fanAlarmOk",
+ fan_tray_slots=[FanConfiguration(label="Fan1", status="failed")],
+ )
+ )
+ result = analyzer.analyze_data(data)
+ assert result.status == ExecutionStatus.ERROR
+ assert result.message.startswith("Arista errors and warnings detected")
+
+
+def test_healthy_port_has_no_errors(analyzer):
+ data = ScaleOutAristaDataModel(port={"Ethernet1/1": _port()})
+ result = analyzer.analyze_data(data)
+ assert result.status != ExecutionStatus.ERROR
+ assert "errors detected" not in result.message.lower()
diff --git a/test/unit/plugin/test_scale_out_arista_collector.py b/test/unit/plugin/test_scale_out_arista_collector.py
new file mode 100644
index 00000000..432c0a0d
--- /dev/null
+++ b/test/unit/plugin/test_scale_out_arista_collector.py
@@ -0,0 +1,141 @@
+###############################################################################
+#
+# 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 pytest
+
+from nodescraper.connection.inband.inband import CommandArtifact
+from nodescraper.enums.executionstatus import ExecutionStatus
+from nodescraper.enums.systeminteraction import SystemInteractionLevel
+from nodescraper.plugins.inband.switch.scale_out_arista.scale_out_arista_collector import (
+ ScaleOutAristaCollector,
+)
+
+
+@pytest.fixture
+def collector(system_info, conn_mock):
+ return ScaleOutAristaCollector(
+ system_info=system_info,
+ system_interaction_level=SystemInteractionLevel.PASSIVE,
+ connection=conn_mock,
+ )
+
+
+def test_expand_port_name():
+ assert ScaleOutAristaCollector._expand_port_name("Et1/1") == "Ethernet1/1"
+ assert ScaleOutAristaCollector._expand_port_name("Ethernet1/1") == "Ethernet1/1"
+
+
+def test_is_ethernet_port():
+ assert ScaleOutAristaCollector._is_ethernet_port("Ethernet1/1") is True
+ assert ScaleOutAristaCollector._is_ethernet_port("Port-Channel1") is False
+ assert ScaleOutAristaCollector._is_ethernet_port("Management1") is False
+
+
+def test_get_port_status_filters_non_ethernet(collector, conn_mock):
+ payload = {
+ "interfaceStatuses": {
+ "Ethernet1/1": {
+ "linkStatus": "connected",
+ "duplex": "duplexFull",
+ "lineProtocolStatus": "up",
+ },
+ "Port-Channel1": {"linkStatus": "connected"},
+ "Management1": {"linkStatus": "connected"},
+ }
+ }
+ conn_mock.run_command.return_value = CommandArtifact(
+ exit_code=0,
+ stdout=json.dumps(payload),
+ stderr="",
+ command="show interfaces status | json | no-more",
+ )
+
+ result = collector.get_port_status()
+
+ assert result is not None
+ assert list(result.keys()) == ["Ethernet1/1"]
+
+
+def test_html_view_reruns_command_without_json(collector, conn_mock):
+ from nodescraper.plugins.inband.switch.scale_out_arista.collector_args import (
+ ScaleOutAristaCollectorArgs,
+ )
+
+ collector.apply_collection_html_view(ScaleOutAristaCollectorArgs(html_view=True))
+ version_json = {"mfgName": "Arista Networks", "version": "4.28.0F"}
+ pretty_stdout = "Arista DCS-7050CX3-32S-C32\nHardware version: 11.00"
+ conn_mock.run_command.side_effect = [
+ CommandArtifact(
+ exit_code=0,
+ stdout=json.dumps(version_json),
+ stderr="",
+ command="show version | json | no-more",
+ ),
+ CommandArtifact(
+ exit_code=0,
+ stdout=pretty_stdout,
+ stderr="",
+ command="show version | no-more",
+ ),
+ ]
+
+ collector.get_version()
+
+ assert conn_mock.run_command.call_count == 2
+ second_call = conn_mock.run_command.call_args_list[1]
+ second_command = second_call.args[0] if second_call.args else second_call.kwargs["command"]
+ assert second_command == "show version | no-more"
+ commands = [artifact.command for artifact in collector.result.artifacts]
+ assert "show version | json | no-more" in commands
+ assert "show version | no-more" in commands
+ pretty_artifact = next(
+ artifact
+ for artifact in collector.result.artifacts
+ if artifact.command == "show version | no-more"
+ )
+ assert pretty_artifact.stdout == pretty_stdout
+ assert pretty_artifact.log_html is True
+ json_artifact = next(
+ artifact
+ for artifact in collector.result.artifacts
+ if artifact.command == "show version | json | no-more"
+ )
+ assert json_artifact.log_html is False
+
+
+def test_preflight_not_ran_when_not_arista(collector, conn_mock):
+ conn_mock.run_command.return_value = CommandArtifact(
+ exit_code=0,
+ stdout=json.dumps({"mfgName": "Cisco Systems", "version": "1.0"}),
+ stderr="",
+ command="show version | json | no-more",
+ )
+
+ version = collector._preflight_check()
+
+ assert version is None
+ assert collector.result.status == ExecutionStatus.NOT_RAN
diff --git a/test/unit/plugin/test_scale_out_dell_analyzer.py b/test/unit/plugin/test_scale_out_dell_analyzer.py
new file mode 100644
index 00000000..16a4e295
--- /dev/null
+++ b/test/unit/plugin/test_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 pytest
+
+from nodescraper.enums.executionstatus import ExecutionStatus
+from nodescraper.plugins.inband.switch.scale_out_dell.analyzer_args import (
+ ScaleOutDellAnalyzerArgs,
+)
+from nodescraper.plugins.inband.switch.scale_out_dell.scale_out_dell_analyzer import (
+ ScaleOutDellAnalyzer,
+)
+from nodescraper.plugins.inband.switch.scale_out_dell.scaleoutdelldata import (
+ DellInterfaceCounters,
+ DellInterfaceStatus,
+ DellPortData,
+ ScaleOutDellDataModel,
+)
+
+
+@pytest.fixture
+def analyzer(system_info):
+ return ScaleOutDellAnalyzer(system_info=system_info)
+
+
+def _port(oper="up", speed=400000, rx_drp=0):
+ return DellPortData(
+ interface_status=DellInterfaceStatus(oper=oper, speed=speed),
+ interface_counters=DellInterfaceCounters(
+ state="U",
+ rx_err=0,
+ rx_oversize=0,
+ tx_err=0,
+ tx_oversize=0,
+ rx_drp=rx_drp,
+ tx_drp=0,
+ ),
+ )
+
+
+def test_expected_port_speed_error(analyzer):
+ data = ScaleOutDellDataModel(port={"Eth1/1": _port(speed=100000)})
+ result = analyzer.analyze_data(data, ScaleOutDellAnalyzerArgs(expected_port_speed=400000))
+ assert result.status == ExecutionStatus.ERROR
+ assert "errors" in result.message.lower()
+
+
+def test_expected_port_speed_custom_passes(analyzer):
+ data = ScaleOutDellDataModel(port={"Eth1/1": _port(speed=100000)})
+ result = analyzer.analyze_data(data, ScaleOutDellAnalyzerArgs(expected_port_speed=100000))
+ assert result.status != ExecutionStatus.ERROR
+ assert "errors detected" not in result.message.lower()
+
+
+def test_analysis_ports_filter(analyzer):
+ data = ScaleOutDellDataModel(
+ port={
+ "Eth1/1": _port(),
+ "Eth1/2": _port(oper="down"),
+ }
+ )
+ result = analyzer.analyze_data(data, ScaleOutDellAnalyzerArgs(analysis_ports=["1/1"]))
+ assert "Eth1/2" not in " ".join(event.description for event in result.events)
+
+
+def test_warnings_only_message(analyzer):
+ data = ScaleOutDellDataModel(port={"Eth1/1": _port(rx_drp=5)})
+ result = analyzer.analyze_data(data)
+ assert result.status == ExecutionStatus.WARNING
+ assert result.message.startswith("Dell warnings detected")
+
+
+def test_invalid_analysis_ports(analyzer):
+ data = ScaleOutDellDataModel(port={"Eth1/1": _port()})
+ result = analyzer.analyze_data(data, ScaleOutDellAnalyzerArgs(analysis_ports=["bad port"]))
+ assert result.status == ExecutionStatus.EXECUTION_FAILURE
diff --git a/test/unit/plugin/test_scale_out_dell_collector.py b/test/unit/plugin/test_scale_out_dell_collector.py
new file mode 100644
index 00000000..95c35249
--- /dev/null
+++ b/test/unit/plugin/test_scale_out_dell_collector.py
@@ -0,0 +1,155 @@
+###############################################################################
+#
+# 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 pytest
+
+from nodescraper.connection.inband.inband import CommandArtifact
+from nodescraper.enums.executionstatus import ExecutionStatus
+from nodescraper.enums.systeminteraction import SystemInteractionLevel
+from nodescraper.plugins.inband.switch.scale_out_dell.scale_out_dell_collector import (
+ ScaleOutDellCollector,
+)
+
+
+@pytest.fixture
+def collector(system_info, conn_mock):
+ return ScaleOutDellCollector(
+ system_info=system_info,
+ system_interaction_level=SystemInteractionLevel.PASSIVE,
+ connection=conn_mock,
+ )
+
+
+SAMPLE_INTERFACE_STATUS = """\
+Interface Description Oper Reason AutoNeg Speed MTU Alternate Name
+Eth1/1 server-prod-a up none on 400000 9100 -
+Eth1/2 connection to leaf with spaces up none on 400000 9100 Eth1/2
+Eth1/3 backup uplink active down admin off 100000 1500 N/A
+Eth1/4 line marked up in description up none on 400000 9100 -
+"""
+
+
+def test_parse_interface_status_line_simple():
+ parsed = ScaleOutDellCollector._parse_interface_status_line(
+ "Eth1/1 server-prod-a up none on 400000 9100 -"
+ )
+ assert parsed == {
+ "name": "Eth1/1",
+ "description": "server-prod-a",
+ "oper": "up",
+ "reason": "none",
+ "auto_neg": "on",
+ "speed": 400000,
+ "mtu": 9100,
+ "alternate_name": "-",
+ }
+
+
+def test_parse_interface_status_line_description_with_extra_spaces():
+ parsed = ScaleOutDellCollector._parse_interface_status_line(
+ "Eth1/2 connection to leaf with spaces up none on 400000 9100 Eth1/2"
+ )
+ assert parsed is not None
+ assert parsed["description"] == "connection to leaf with spaces"
+ assert parsed["oper"] == "up"
+ assert parsed["speed"] == 400000
+ assert parsed["alternate_name"] == "Eth1/2"
+
+
+def test_parse_interface_status_line_description_contains_up_token():
+ parsed = ScaleOutDellCollector._parse_interface_status_line(
+ "Eth1/4 line marked up in description up none on 400000 9100 -"
+ )
+ assert parsed is not None
+ assert parsed["description"] == "line marked up in description"
+ assert parsed["oper"] == "up"
+ assert parsed["speed"] == 400000
+
+
+def test_parse_interface_status_line_down():
+ parsed = ScaleOutDellCollector._parse_interface_status_line(
+ "Eth1/3 backup uplink active down admin off 100000 1500 N/A"
+ )
+ assert parsed is not None
+ assert parsed["oper"] == "down"
+ assert parsed["reason"] == "admin"
+ assert parsed["speed"] == 100000
+ assert parsed["mtu"] == 1500
+
+
+def test_parse_interface_status_line_skips_header():
+ assert (
+ ScaleOutDellCollector._parse_interface_status_line(
+ "Interface Description Oper Reason AutoNeg Speed MTU Alternate Name"
+ )
+ is None
+ )
+
+
+def test_canonical_eth_port_rejects_invalid_name():
+ assert ScaleOutDellCollector._canonical_eth_port('Eth1"; evil') is None
+
+
+def test_get_detail_counters_skips_invalid_port(collector, conn_mock):
+ conn_mock.run_command.return_value = CommandArtifact(
+ exit_code=0,
+ stdout="",
+ stderr="",
+ command='sonic-cli -c "show interface counters Eth1/1 | no-more"',
+ )
+
+ result = collector.get_detail_counters(["Eth1/1", 'Eth1"; evil'])
+
+ assert conn_mock.run_command.call_count == 1
+ assert result is None or "Eth1/1" in result
+
+
+def test_preflight_not_ran_when_not_dell(collector, conn_mock):
+ conn_mock.run_command.return_value = CommandArtifact(
+ exit_code=0,
+ stdout="Cisco NX-OS Software",
+ stderr="",
+ command='sonic-cli -c "show version | no-more"',
+ )
+
+ assert collector._preflight_check() is False
+ assert collector.result.status == ExecutionStatus.NOT_RAN
+
+
+def test_get_interface_status_parses_sample_block(collector, conn_mock):
+ conn_mock.run_command.return_value = CommandArtifact(
+ exit_code=0,
+ stdout=SAMPLE_INTERFACE_STATUS,
+ stderr="",
+ command='sonic-cli -c "show interface status | no-more"',
+ )
+
+ result = collector.get_interface_status()
+
+ assert result is not None
+ assert set(result.keys()) == {"Eth1/1", "Eth1/2", "Eth1/3", "Eth1/4"}
+ assert result["Eth1/2"].description == "connection to leaf with spaces"
+ assert result["Eth1/4"].oper == "up"
+ assert result["Eth1/3"].speed == 100000
diff --git a/test/unit/plugin/test_scale_out_dell_port_names.py b/test/unit/plugin/test_scale_out_dell_port_names.py
new file mode 100644
index 00000000..17dad768
--- /dev/null
+++ b/test/unit/plugin/test_scale_out_dell_port_names.py
@@ -0,0 +1,58 @@
+###############################################################################
+#
+# 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.plugins.inband.switch.scale_out_dell.port_names import (
+ normalize_port_token,
+ resolve_detail_port_names,
+ to_eth_port_name,
+)
+
+
+def test_normalize_port_token_accepts_eth_prefix():
+ assert normalize_port_token("Eth1/1/1") == "1/1/1"
+ assert normalize_port_token("1/1") == "1/1"
+
+
+def test_to_eth_port_name_builds_valid_cli_name():
+ assert to_eth_port_name("1/1/1") == "Eth1/1/1"
+ assert to_eth_port_name("Eth1/1") == "Eth1/1"
+
+
+def test_to_eth_port_name_rejects_injection():
+ assert to_eth_port_name('Eth1"; rm -rf /') is None
+ assert to_eth_port_name("not-a-port") is None
+
+
+def test_resolve_detail_port_names_uses_interface_status_keys():
+ status = {"Eth1/1/1": object(), "Eth1/1/2": object()}
+ names, invalid = resolve_detail_port_names(["1/1/1", "Eth1/1/2"], status)
+ assert invalid is None
+ assert names == ["Eth1/1/1", "Eth1/1/2"]
+
+
+def test_resolve_detail_port_names_falls_back_without_status():
+ names, invalid = resolve_detail_port_names(["1/1/1"], None)
+ assert invalid is None
+ assert names == ["Eth1/1/1"]
diff --git a/test/unit/plugin/test_switch_analyzer_base.py b/test/unit/plugin/test_switch_analyzer_base.py
new file mode 100644
index 00000000..98a750ab
--- /dev/null
+++ b/test/unit/plugin/test_switch_analyzer_base.py
@@ -0,0 +1,171 @@
+###############################################################################
+#
+# 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 pytest
+
+from nodescraper.enums.executionstatus import ExecutionStatus
+from nodescraper.plugins.inband.switch.scale_out_arista.scale_out_arista_analyzer import (
+ ScaleOutAristaAnalyzer,
+)
+from nodescraper.plugins.inband.switch.scale_out_arista.scaleoutaristadata import (
+ AristaPortStatus,
+ PortData,
+ ScaleOutAristaDataModel,
+ VlanInformation,
+)
+from nodescraper.plugins.inband.switch.scale_out_dell.scale_out_dell_analyzer import (
+ ScaleOutDellAnalyzer,
+)
+from nodescraper.plugins.inband.switch.scale_out_dell.scaleoutdelldata import (
+ DellInterfaceCounters,
+ DellInterfaceStatus,
+ DellPortData,
+ ScaleOutDellDataModel,
+)
+
+
+@pytest.fixture
+def analyzer(system_info):
+ return ScaleOutAristaAnalyzer(system_info=system_info)
+
+
+def test_nested_vlan_information_error(analyzer):
+ data = ScaleOutAristaDataModel(
+ port={
+ "Ethernet1/1": PortData(
+ port_status=AristaPortStatus(
+ link_status="connected",
+ duplex="duplexFull",
+ line_protocol_status="up",
+ vlan_information=VlanInformation(
+ interface_mode="trunk",
+ interface_forwarding_model="routed",
+ ),
+ )
+ )
+ }
+ )
+
+ result = analyzer.analyze_data(data)
+
+ assert result.status == ExecutionStatus.ERROR
+ assert any("interface_mode" in event.description for event in result.events)
+
+
+def test_nested_vlan_information_ok(analyzer):
+ data = ScaleOutAristaDataModel(
+ port={
+ "Ethernet1/1": PortData(
+ port_status=AristaPortStatus(
+ link_status="connected",
+ duplex="duplexFull",
+ line_protocol_status="up",
+ vlan_information=VlanInformation(
+ interface_mode="routed",
+ interface_forwarding_model="routed",
+ ),
+ )
+ )
+ }
+ )
+
+ result = analyzer.analyze_data(data)
+
+ assert result.status != ExecutionStatus.ERROR
+ assert not any("interface_mode" in event.description for event in result.events)
+
+
+def test_missing_nested_vlan_information_warns(analyzer):
+ data = ScaleOutAristaDataModel(
+ port={
+ "Ethernet1/1": PortData(
+ port_status=AristaPortStatus(
+ link_status="connected",
+ duplex="duplexFull",
+ line_protocol_status="up",
+ vlan_information=None,
+ )
+ )
+ }
+ )
+
+ result = analyzer.analyze_data(data)
+
+ assert result.status == ExecutionStatus.WARNING
+ assert any("vlan_information" in event.description for event in result.events)
+
+
+@pytest.fixture
+def dell_analyzer(system_info):
+ return ScaleOutDellAnalyzer(system_info=system_info)
+
+
+def test_dell_analysis_ports_accepts_eth_prefix(dell_analyzer):
+ data = ScaleOutDellDataModel(
+ port={
+ "Eth1/1/1": DellPortData(
+ interface_status=DellInterfaceStatus(oper="up", speed=400000),
+ interface_counters=DellInterfaceCounters(
+ state="U",
+ rx_err=0,
+ rx_oversize=0,
+ tx_err=0,
+ tx_oversize=0,
+ rx_drp=0,
+ tx_drp=0,
+ ),
+ ),
+ "Eth1/1/2": DellPortData(
+ interface_status=DellInterfaceStatus(oper="down", speed=400000),
+ ),
+ }
+ )
+ from nodescraper.plugins.inband.switch.scale_out_dell.analyzer_args import (
+ ScaleOutDellAnalyzerArgs,
+ )
+
+ result = dell_analyzer.analyze_data(data, ScaleOutDellAnalyzerArgs(analysis_ports=["1/1/1"]))
+ assert all("Eth1/1/2" not in event.description for event in result.events)
+
+
+def test_analyzer_messages_distinguish_errors_and_warnings(analyzer):
+ data = ScaleOutAristaDataModel(
+ port={
+ "Ethernet1/1": PortData(
+ port_status=AristaPortStatus(
+ link_status="notconnect",
+ duplex="duplexFull",
+ line_protocol_status="up",
+ vlan_information=VlanInformation(
+ interface_mode="routed",
+ interface_forwarding_model="routed",
+ ),
+ )
+ )
+ }
+ )
+ result = analyzer.analyze_data(data)
+ assert result.status == ExecutionStatus.ERROR
+ assert result.message.startswith("Arista errors and warnings detected")