diff --git a/.github/workflows/code_quality_checks.yml b/.github/workflows/code_quality_checks.yml index 2408a8ce..7a51a639 100644 --- a/.github/workflows/code_quality_checks.yml +++ b/.github/workflows/code_quality_checks.yml @@ -14,7 +14,7 @@ jobs: container: python:3.9 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 # Fetch all history for pre-commit to work diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index 2c6618b5..a0c6fb59 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -15,7 +15,7 @@ jobs: container: python:3.9 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install xmllint run: | diff --git a/.github/workflows/release-trusted-publisher.yml b/.github/workflows/release-trusted-publisher.yml index 55267d48..e2a29680 100644 --- a/.github/workflows/release-trusted-publisher.yml +++ b/.github/workflows/release-trusted-publisher.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 # Fetch all history and tags token: ${{ secrets.GITHUB_TOKEN }} @@ -34,7 +34,7 @@ jobs: git fetch --tags --force - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.9' diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index fe6dc9c2..1ea0f6e7 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -15,7 +15,7 @@ jobs: container: python:3.9 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install xmllint run: | diff --git a/.github/workflows/update-plugin-docs.yml b/.github/workflows/update-plugin-docs.yml index 97c1e48a..a5b7040a 100644 --- a/.github/workflows/update-plugin-docs.yml +++ b/.github/workflows/update-plugin-docs.yml @@ -26,7 +26,7 @@ jobs: export HOME=/tmp/github-actions-home - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: token: ${{ secrets.GITHUB_TOKEN }} @@ -53,14 +53,14 @@ jobs: pre-commit run --files docs/PLUGIN_DOC.md README.md || true - name: Create Pull Request - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@v8 with: token: ${{ secrets.GITHUB_TOKEN }} commit-message: "docs: Update plugin documentation [automated]" committer: "github-actions[bot] " author: "github-actions[bot] " branch: automated-plugin-docs-update - delete-branch: true + delete-branch: false title: "docs: Update plugin documentation [automated]" body: | Automated plugin documentation update generated by workflow. diff --git a/docs/PLUGIN_DOC.md b/docs/PLUGIN_DOC.md index cf7cb371..deebb1a6 100644 --- a/docs/PLUGIN_DOC.md +++ b/docs/PLUGIN_DOC.md @@ -4,31 +4,33 @@ | Plugin | Collection | Analyzer Args | Collection Args | DataModel | Collector | Analyzer | | --- | --- | --- | --- | --- | --- | --- | -| GenericCollectionPlugin | Runs each command from collection_args.commands on the target (in-band host or BMC over OOB SSH).
Commands are user-configured; there are no fixed CMD_* class fields. | **Analyzer Args:**
- `checks`: list[nodescraper.plugins.generic_collection.analyzer_args.CommandCheck] — Per-command validation rules keyed by collected command name. | **Collection Args:**
- `commands`: list[nodescraper.plugins.generic_collection.collector_args.CommandSpec] — Named commands to run. Each entry must include 'name' and 'command'. Prefer small textual stdout; see class docstring...
- `sudo`: bool — Default sudo setting for commands that do not specify sudo.
- `timeout`: int — Default per-command timeout in seconds.
- `include_stdout`: bool — Default: include each command's stdout in collected results for analysis. When false, stdout is omitted from stored r... | [GenericCollectionDataModel](#GenericCollectionDataModel-Model) | [GenericCollectionCollector](#Collector-Class-GenericCollectionCollector) | [GenericAnalyzer](#Data-Analyzer-Class-GenericAnalyzer) | -| AmdSmiPlugin | bad-pages
firmware --json
list --json
metric -g all
partition --json
process --json
ras --cper --folder={folder}
ras --afid --cper-file {cper_file}
static -g all --json
static -g {gpu_id} --json
topology
version --json
xgmi -l
xgmi -m | **Analyzer Args:**
- `check_static_data`: bool — If True, run static data checks (e.g. driver version, partition mode).
- `expected_gpu_processes`: Optional[int] — Expected number of GPU processes.
- `expected_max_power`: Optional[int] — Expected maximum power value (e.g. watts).
- `expected_power_management`: Optional[str] — Expected amd-smi metric power_management value per GPU (e.g. DISABLED for active/full power, ENABLED for power-manage...
- `expected_driver_version`: Optional[str] — Expected AMD driver version string.
- `expected_memory_partition_mode`: Optional[str] — Expected memory partition mode (e.g. sp3, dp).
- `expected_compute_partition_mode`: Optional[str] — Expected compute partition mode.
- `expected_firmware_versions`: Optional[dict[str, str]] — Expected firmware versions keyed by amd-smi fw_id (e.g. PLDM_BUNDLE).
- `l0_to_recovery_count_error_threshold`: Optional[int] — L0-to-recovery count above which an error is raised.
- `l0_to_recovery_count_warning_threshold`: Optional[int] — L0-to-recovery count above which a warning is raised.
- `vendorid_ep`: Optional[str] — Expected endpoint vendor ID (e.g. for PCIe).
- `vendorid_ep_vf`: Optional[str] — Expected endpoint VF vendor ID.
- `devid_ep`: Optional[str] — Expected endpoint device ID.
- `devid_ep_vf`: Optional[str] — Expected endpoint VF device ID.
- `sku_name`: Optional[str] — Expected SKU name string for GPU.
- `expected_xgmi_speed`: Optional[list[float]] — Expected xGMI speed value(s) (e.g. link rate).
- `analysis_range_start`: Optional[datetime.datetime] — Start of time range for time-windowed analysis.
- `analysis_range_end`: Optional[datetime.datetime] — End of time range for time-windowed analysis. | **Collection Args:**
- `analysis_firmware_ids`: Optional[list[str]] — amd-smi fw_id values to record in analysis_ref.firmware_versions
- `cper_file_path`: Optional[str] — Path to CPER folder or file for RAS AFID collection (ras --afid --cper-file). | [AmdSmiDataModel](#AmdSmiDataModel-Model) | [AmdSmiCollector](#Collector-Class-AmdSmiCollector) | [AmdSmiAnalyzer](#Data-Analyzer-Class-AmdSmiAnalyzer) | +| GenericCollectionPlugin | Runs each command from collection_args.commands on the target (in-band host or BMC over OOB SSH).
Commands are user-configured; there are no fixed CMD_* class fields. | **Analyzer Args:**
- `checks`: list[nodescraper.plugins.generic_collection.analyzer_args.CommandCheck] — Per-command validation rules keyed by collected command name. | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors...
- `commands`: list[nodescraper.plugins.generic_collection.collector_args.CommandSpec] — Named commands to run. Each entry must include 'name' and 'command'. Prefer small textual stdout; see class docstring...
- `sudo`: bool — Default sudo setting for commands that do not specify sudo.
- `timeout`: int — Default per-command timeout in seconds.
- `include_stdout`: bool — Default: include each command's stdout in collected results for analysis. When false, stdout is omitted from stored r... | [GenericCollectionDataModel](#GenericCollectionDataModel-Model) | [GenericCollectionCollector](#Collector-Class-GenericCollectionCollector) | [GenericAnalyzer](#Data-Analyzer-Class-GenericAnalyzer) | +| AmdSmiPlugin | bad-pages
firmware --json
list --json
metric -g all
partition --json
process --json
ras --cper --folder={folder}
ras --afid --cper-file {cper_file}
static -g all --json
static -g {gpu_id} --json
topology
version --json
xgmi -l
xgmi -m | **Analyzer Args:**
- `check_static_data`: bool — If True, run static data checks (e.g. driver version, partition mode).
- `expected_gpu_processes`: Optional[int] — Expected number of GPU processes.
- `expected_max_power`: Optional[int] — Expected maximum power value (e.g. watts).
- `expected_power_management`: Optional[str] — Expected amd-smi metric power_management value per GPU (e.g. DISABLED for active/full power, ENABLED for power-manage...
- `expected_driver_version`: Optional[str] — Expected AMD driver version string.
- `expected_memory_partition_mode`: Optional[str] — Expected memory partition mode (e.g. sp3, dp).
- `expected_compute_partition_mode`: Optional[str] — Expected compute partition mode.
- `expected_firmware_versions`: Optional[dict[str, str]] — Expected firmware versions keyed by amd-smi fw_id (e.g. PLDM_BUNDLE).
- `l0_to_recovery_count_error_threshold`: Optional[int] — L0-to-recovery count above which an error is raised.
- `l0_to_recovery_count_warning_threshold`: Optional[int] — L0-to-recovery count above which a warning is raised.
- `vendorid_ep`: Optional[str] — Expected endpoint vendor ID (e.g. for PCIe).
- `vendorid_ep_vf`: Optional[str] — Expected endpoint VF vendor ID.
- `devid_ep`: Optional[str] — Expected endpoint device ID.
- `devid_ep_vf`: Optional[str] — Expected endpoint VF device ID.
- `sku_name`: Optional[str] — Expected SKU name string for GPU.
- `expected_xgmi_speed`: Optional[list[float]] — Expected xGMI speed value(s) (e.g. link rate).
- `analysis_range_start`: Optional[datetime.datetime] — Start of time range for time-windowed analysis.
- `analysis_range_end`: Optional[datetime.datetime] — End of time range for time-windowed analysis. | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors...
- `analysis_firmware_ids`: Optional[list[str]] — amd-smi fw_id values to record in analysis_ref.firmware_versions
- `cper_file_path`: Optional[str] — Path to CPER folder or file for RAS AFID collection (ras --afid --cper-file). | [AmdSmiDataModel](#AmdSmiDataModel-Model) | [AmdSmiCollector](#Collector-Class-AmdSmiCollector) | [AmdSmiAnalyzer](#Data-Analyzer-Class-AmdSmiAnalyzer) | | BiosPlugin | sh -c 'cat /sys/devices/virtual/dmi/id/bios_version'
wmic bios get SMBIOSBIOSVersion /Value | **Analyzer Args:**
- `exp_bios_version`: list[str] — Expected BIOS version(s) to match against collected value (str or list).
- `regex_match`: bool — If True, match exp_bios_version as regex; otherwise exact match. | - | [BiosDataModel](#BiosDataModel-Model) | [BiosCollector](#Collector-Class-BiosCollector) | [BiosAnalyzer](#Data-Analyzer-Class-BiosAnalyzer) | | CmdlinePlugin | cat /proc/cmdline | **Analyzer Args:**
- `required_cmdline`: Union[str, List] — Command-line parameters that must be present (e.g. 'pci=bfsort').
- `banned_cmdline`: Union[str, List] — Command-line parameters that must not be present.
- `os_overrides`: Dict[str, nodescraper.plugins.inband.cmdline.cmdlineconfig.OverrideConfig] — Per-OS overrides for required_cmdline and banned_cmdline (keyed by OS identifier).
- `platform_overrides`: Dict[str, nodescraper.plugins.inband.cmdline.cmdlineconfig.OverrideConfig] — Per-platform overrides for required_cmdline and banned_cmdline (keyed by platform). | - | [CmdlineDataModel](#CmdlineDataModel-Model) | [CmdlineCollector](#Collector-Class-CmdlineCollector) | [CmdlineAnalyzer](#Data-Analyzer-Class-CmdlineAnalyzer) | | DeviceEnumerationPlugin | powershell -Command "(Get-WmiObject -Class Win32_Processor | Measure-Object).Count"
lspci -d {vendorid_ep}: | grep -iE 'VGA|Display|3D|Processing accelerators|Co-processor|Accelerator' | grep -vi 'Virtual Function' | wc -l
powershell -Command "(wmic path win32_VideoController get name | findstr AMD | Measure-Object).Count"
lscpu
lshw
lspci -d {vendorid_ep}: | grep -i 'Virtual Function' | wc -l
powershell -Command "(Get-VMHostPartitionableGpu | Measure-Object).Count" | **Analyzer Args:**
- `cpu_count`: Optional[list[int]] — Expected CPU count(s); pass as int or list of ints. Analysis passes if actual is in list.
- `gpu_count`: Optional[list[int]] — Expected GPU count(s); pass as int or list of ints. Analysis passes if actual is in list.
- `vf_count`: Optional[list[int]] — Expected virtual function count(s); pass as int or list of ints. Analysis passes if actual is in list. | - | [DeviceEnumerationDataModel](#DeviceEnumerationDataModel-Model) | [DeviceEnumerationCollector](#Collector-Class-DeviceEnumerationCollector) | [DeviceEnumerationAnalyzer](#Data-Analyzer-Class-DeviceEnumerationAnalyzer) | -| DimmPlugin | sh -c 'dmidecode -t 17 | tr -s " " | grep -v "Volatile\|None\|Module" | grep Size' 2>/dev/null
dmidecode
wmic memorychip get Capacity | - | **Collection Args:**
- `skip_sudo`: bool — If True, do not use sudo when running dmidecode or wmic for memory info. | [DimmDataModel](#DimmDataModel-Model) | [DimmCollector](#Collector-Class-DimmCollector) | - | +| DimmPlugin | sh -c 'dmidecode -t 17 | tr -s " " | grep -v "Volatile\|None\|Module" | grep Size' 2>/dev/null
dmidecode
wmic memorychip get Capacity | - | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors...
- `skip_sudo`: bool — If True, do not use sudo when running dmidecode or wmic for memory info. | [DimmDataModel](#DimmDataModel-Model) | [DimmCollector](#Collector-Class-DimmCollector) | - | | DkmsPlugin | dkms status
dkms --version | **Analyzer Args:**
- `dkms_status`: Union[str, list] — Expected dkms status string(s) to match (e.g. 'amd/1.0.0'). At least one of dkms_status or dkms_version required.
- `dkms_version`: Union[str, list] — Expected dkms version string(s) to match. At least one of dkms_status or dkms_version required.
- `regex_match`: bool — If True, match dkms_status and dkms_version as regex; otherwise exact match. | - | [DkmsDataModel](#DkmsDataModel-Model) | [DkmsCollector](#Collector-Class-DkmsCollector) | [DkmsAnalyzer](#Data-Analyzer-Class-DkmsAnalyzer) | -| DmesgPlugin | dmesg --time-format iso -x
ls -1 /var/log/dmesg* 2>/dev/null | grep -E '^/var/log/dmesg(\.[0-9]+(\.gz)?)?$' || true | **Built-in Regexes:**
- Out of memory error: `(?:oom_kill_process.*)|(?:Out of memory.*)`
- I/O Page Fault: `IO_PAGE_FAULT`
- Kernel Panic: `\bkernel panic\b.*`
- SQ Interrupt: `sq_intr`
- SRAM ECC: `sram_ecc.*`
- Failed to load driver. IP hardware init error.: `\[amdgpu\]\] \*ERROR\* hw_init of IP block.*`
- Failed to load driver. IP software init error.: `\[amdgpu\]\] \*ERROR\* sw_init of IP block.*`
- Real Time throttling activated: `sched: RT throttling activated.*`
- RCU preempt detected stalls: `rcu_preempt detected stalls.*`
- RCU preempt self-detected stall: `rcu_preempt self-detected stall.*`
- QCM fence timeout: `qcm fence wait loop timeout.*`
- General protection fault: `(?:[\w-]+(?:\[[0-9.]+\])?\s+)?general protectio...`
- Segmentation fault: `(?:segfault.*in .*\[)|(?:[Ss]egmentation [Ff]au...`
- Failed to disallow cf state: `amdgpu: Failed to disallow cf state.*`
- Failed to terminate tmr: `\*ERROR\* Failed to terminate tmr.*`
- Suspend of IP block failed: `\*ERROR\* suspend of IP block <\w+> failed.*`
- amdgpu Page Fault: `(amdgpu \w{4}:\w{2}:\w{2}\.\w:\s+amdgpu:\s+\[\S...`
- Page Fault: `page fault for address.*`
- Fatal error during GPU init: `(?:amdgpu)(.*Fatal error during GPU init)|(Fata...`
- PCIe AER Error Status: `(pcieport [\w:.]+: AER: aer_status:[^\n]*(?:\n[...`
- PCIe AER Correctable Error Status: `(.*aer_cor_status: 0x[0-9a-fA-F]+, aer_cor_mask...`
- PCIe AER Uncorrectable Error Status: `(.*aer_uncor_status: 0x[0-9a-fA-F]+, aer_uncor_...`
- PCIe AER Uncorrectable Error Severity with TLP Header: `(.*aer_uncor_severity: 0x[0-9a-fA-F]+.*)(\n.*TL...`
- Failed to read journal file: `Failed to read journal file.*`
- Journal file corrupted or uncleanly shut down: `journal corrupted or uncleanly shut down.*`
- ACPI BIOS Error: `ACPI BIOS Error`
- ACPI Error: `ACPI Error`
- Filesystem corrupted!: `EXT4-fs error \(device .*\):`
- Error in buffered IO, check filesystem integrity: `(Buffer I\/O error on dev)(?:ice)? (\w+)`
- PCIe card no longer present: `pcieport (\w+:\w+:\w+\.\w+):\s+(\w+):\s+(Slot\(...`
- PCIe Link Down: `pcieport (\w+:\w+:\w+\.\w+):\s+(\w+):\s+(Slot\(...`
- Mismatched clock configuration between PCIe device and host: `pcieport (\w+:\w+:\w+\.\w+):\s+(\w+):\s+(curren...`
- RAS Correctable Error: `(?:\d{4}-\d+-\d+T\d+:\d+:\d+,\d+[+-]\d+:\d+)?(....`
- RAS Uncorrectable Error: `(?:\d{4}-\d+-\d+T\d+:\d+:\d+,\d+[+-]\d+:\d+)?(....`
- RAS Deferred Error: `(?:\d{4}-\d+-\d+T\d+:\d+:\d+,\d+[+-]\d+:\d+)?(....`
- RAS Corrected PCIe Error: `((?:\[Hardware Error\]:\s+)?event severity: cor...`
- GPU Reset: `(?:\d{4}-\d+-\d+T\d+:\d+:\d+,\d+[+-]\d+:\d+)?(....`
- GPU reset failed: `(?:\d{4}-\d+-\d+T\d+:\d+:\d+,\d+[+-]\d+:\d+)?(....`
- ACA Error: `(Accelerator Check Architecture[^\n]*)(?:\n[^\n...`
- ACA Error: `(Accelerator Check Architecture[^\n]*)(?:\n[^\n...`
- MCE Corrected Error: `\[Hardware Error\]:.+MC\d+_STATUS\[[^\]]*\|CE\|...`
- MCE Uncorrected Error: `\[Hardware Error\]:.+MC\d+_STATUS\[[^\]]*\|UC\|...`
- Mode 2 Reset Failed: `(?:\d{4}-\d+-\d+T\d+:\d+:\d+,\d+[+-]\d+:\d+)? (...`
- RAS Corrected Error: `(?:\d{4}-\d+-\d+T\d+:\d+:\d+,\d+[+-]\d+:\d+)?(....`
- SGX Error: `x86/cpu: SGX disabled by BIOS`
- MMP Error: `Failed to load MMP firmware qat_4xxx_mmp.bin`
- GPU Throttled: `amdgpu \w{4}:\w{2}:\w{2}.\w: amdgpu: WARN: GPU ...`
- RAS Poison Consumed: `amdgpu[ 0-9a-fA-F:.]+:(?:\s*amdgpu:)?\s+(?:{\d+...`
- RAS Poison created: `amdgpu[ 0-9a-fA-F:.]+:(?:\s*amdgpu:)?\s+(?:{\d+...`
- Bad page threshold exceeded: `(amdgpu: Saved bad pages (\d+) reaches threshol...`
- RAS Hardware Error: `Hardware error from APEI Generic Hardware Error...`
- Error Address: `Error Address.*(?:\s.*)`
- RAS EDR Event: `EDR: EDR event received`
- DPC Event: `DPC: .*`
- LNet: ko2iblnd has no matching interfaces: `(?:\[[^\]]+\]\s*)?LNetError:.*ko2iblnd:\s*No ma...`
- LNet: Error starting up LNI: `(?:\[[^\]]+\]\s*)?LNetError:\s*.*Error\s*-?\d+\...`
- Lustre: network initialisation failed: `LustreError:.*ptlrpc_init_portals\(\).*network ...` | **Collection Args:**
- `collect_rotated_logs`: bool — If True, also collect rotated dmesg log files from /var/log/dmesg*.
- `skip_sudo`: bool — If True, do not use sudo when running dmesg or listing log files.
- `log_dmesg_data`: bool — If True, log the collected dmesg output in artifacts. | [DmesgData](#DmesgData-Model) | [DmesgCollector](#Collector-Class-DmesgCollector) | [DmesgAnalyzer](#Data-Analyzer-Class-DmesgAnalyzer) | +| DmesgPlugin | dmesg --time-format iso -x
ls -1 /var/log/dmesg* 2>/dev/null | grep -E '^/var/log/dmesg(\.[0-9]+(\.gz)?)?$' || true | **Built-in Regexes:**
- Out of memory error: `(?:oom_kill_process.*)|(?:Out of memory.*)`
- I/O Page Fault: `IO_PAGE_FAULT`
- Kernel Panic: `\bkernel panic\b.*`
- SQ Interrupt: `sq_intr`
- SRAM ECC: `sram_ecc.*`
- Failed to load driver. IP hardware init error.: `\[amdgpu\]\] \*ERROR\* hw_init of IP block.*`
- Failed to load driver. IP software init error.: `\[amdgpu\]\] \*ERROR\* sw_init of IP block.*`
- Real Time throttling activated: `sched: RT throttling activated.*`
- RCU preempt detected stalls: `rcu_preempt detected stalls.*`
- RCU preempt self-detected stall: `rcu_preempt self-detected stall.*`
- QCM fence timeout: `qcm fence wait loop timeout.*`
- General protection fault: `(?:[\w-]+(?:\[[0-9.]+\])?\s+)?general protectio...`
- Segmentation fault: `(?:segfault.*in .*\[)|(?:[Ss]egmentation [Ff]au...`
- Failed to disallow cf state: `amdgpu: Failed to disallow cf state.*`
- Failed to terminate tmr: `\*ERROR\* Failed to terminate tmr.*`
- Suspend of IP block failed: `\*ERROR\* suspend of IP block <\w+> failed.*`
- amdgpu Page Fault: `(amdgpu \w{4}:\w{2}:\w{2}\.\w:\s+amdgpu:\s+\[\S...`
- Page Fault: `page fault for address.*`
- Fatal error during GPU init: `(?:amdgpu)(.*Fatal error during GPU init)|(Fata...`
- PCIe AER Error Status: `(pcieport [\w:.]+: AER: aer_status:[^\n]*(?:\n[...`
- PCIe AER Correctable Error Status: `(.*aer_cor_status: 0x[0-9a-fA-F]+, aer_cor_mask...`
- PCIe AER Uncorrectable Error Status: `(.*aer_uncor_status: 0x[0-9a-fA-F]+, aer_uncor_...`
- PCIe AER Uncorrectable Error Severity with TLP Header: `(.*aer_uncor_severity: 0x[0-9a-fA-F]+.*)(\n.*TL...`
- Failed to read journal file: `Failed to read journal file.*`
- Journal file corrupted or uncleanly shut down: `journal corrupted or uncleanly shut down.*`
- ACPI BIOS Error: `ACPI BIOS Error`
- ACPI Error: `ACPI Error`
- Filesystem corrupted!: `EXT4-fs error \(device .*\):`
- Error in buffered IO, check filesystem integrity: `(Buffer I\/O error on dev)(?:ice)? (\w+)`
- PCIe card no longer present: `pcieport (\w+:\w+:\w+\.\w+):\s+(\w+):\s+(Slot\(...`
- PCIe Link Down: `pcieport (\w+:\w+:\w+\.\w+):\s+(\w+):\s+(Slot\(...`
- Mismatched clock configuration between PCIe device and host: `pcieport (\w+:\w+:\w+\.\w+):\s+(\w+):\s+(curren...`
- RAS Correctable Error: `(?:\d{4}-\d+-\d+T\d+:\d+:\d+,\d+[+-]\d+:\d+)?(....`
- RAS Uncorrectable Error: `(?:\d{4}-\d+-\d+T\d+:\d+:\d+,\d+[+-]\d+:\d+)?(....`
- RAS Deferred Error: `(?:\d{4}-\d+-\d+T\d+:\d+:\d+,\d+[+-]\d+:\d+)?(....`
- RAS Corrected PCIe Error: `((?:\[Hardware Error\]:\s+)?event severity: cor...`
- GPU Reset: `(?:\d{4}-\d+-\d+T\d+:\d+:\d+,\d+[+-]\d+:\d+)?(....`
- GPU reset failed: `(?:\d{4}-\d+-\d+T\d+:\d+:\d+,\d+[+-]\d+:\d+)?(....`
- ACA Error: `(Accelerator Check Architecture[^\n]*)(?:\n[^\n...`
- ACA Error: `(Accelerator Check Architecture[^\n]*)(?:\n[^\n...`
- MCE Corrected Error: `\[Hardware Error\]:.+MC\d+_STATUS\[[^\]]*\|CE\|...`
- MCE Uncorrected Error: `\[Hardware Error\]:.+MC\d+_STATUS\[[^\]]*\|UC\|...`
- Mode 2 Reset Failed: `(?:\d{4}-\d+-\d+T\d+:\d+:\d+,\d+[+-]\d+:\d+)? (...`
- RAS Corrected Error: `(?:\d{4}-\d+-\d+T\d+:\d+:\d+,\d+[+-]\d+:\d+)?(....`
- SGX Error: `x86/cpu: SGX disabled by BIOS`
- MMP Error: `Failed to load MMP firmware qat_4xxx_mmp.bin`
- GPU Throttled: `amdgpu \w{4}:\w{2}:\w{2}.\w: amdgpu: WARN: GPU ...`
- RAS Poison Consumed: `amdgpu[ 0-9a-fA-F:.]+:(?:\s*amdgpu:)?\s+(?:{\d+...`
- RAS Poison created: `amdgpu[ 0-9a-fA-F:.]+:(?:\s*amdgpu:)?\s+(?:{\d+...`
- Bad page threshold exceeded: `(amdgpu: Saved bad pages (\d+) reaches threshol...`
- RAS Hardware Error: `Hardware error from APEI Generic Hardware Error...`
- Error Address: `Error Address.*(?:\s.*)`
- RAS EDR Event: `EDR: EDR event received`
- DPC Event: `DPC: .*`
- LNet: ko2iblnd has no matching interfaces: `(?:\[[^\]]+\]\s*)?LNetError:.*ko2iblnd:\s*No ma...`
- LNet: Error starting up LNI: `(?:\[[^\]]+\]\s*)?LNetError:\s*.*Error\s*-?\d+\...`
- Lustre: network initialisation failed: `LustreError:.*ptlrpc_init_portals\(\).*network ...` | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors...
- `collect_rotated_logs`: bool — If True, also collect rotated dmesg log files from /var/log/dmesg*.
- `skip_sudo`: bool — If True, do not use sudo when running dmesg or listing log files.
- `log_dmesg_data`: bool — If True, log the collected dmesg output in artifacts. | [DmesgData](#DmesgData-Model) | [DmesgCollector](#Collector-Class-DmesgCollector) | [DmesgAnalyzer](#Data-Analyzer-Class-DmesgAnalyzer) | | FabricsPlugin | lspci | grep -i cassini
lsmod | grep cxi
cxi_stat
ibstat
ibv_devinfo
ls -l /sys/class/infiniband/*/device/net
fi_info -p cxi
mst start
mst status -v
ip link show
ofed_info -s | - | - | [FabricsDataModel](#FabricsDataModel-Model) | [FabricsCollector](#Collector-Class-FabricsCollector) | - | -| JournalPlugin | journalctl --no-pager --system --output=short-iso
journalctl --no-pager --system --output=json | **Analyzer Args:**
- `analysis_range_start`: Optional[datetime.datetime] — Start of time range for analysis (ISO format). Only events on or after this time are analyzed.
- `analysis_range_end`: Optional[datetime.datetime] — End of time range for analysis (ISO format). Only events before this time are analyzed.
- `check_priority`: Optional[int] — Check against journal log priority (0=emergency..7=debug). If an entry has priority <= check_priority, an ERROR event...
- `group`: bool — If True, group entries that have the same priority and message. | **Collection Args:**
- `boot`: Optional[int] — Optional boot ID to limit journal collection to a specific boot. | [JournalData](#JournalData-Model) | [JournalCollector](#Collector-Class-JournalCollector) | [JournalAnalyzer](#Data-Analyzer-Class-JournalAnalyzer) | +| JournalPlugin | journalctl --no-pager --system --output=short-iso
journalctl --no-pager --system --output=json | **Analyzer Args:**
- `analysis_range_start`: Optional[datetime.datetime] — Start of time range for analysis (ISO format). Only events on or after this time are analyzed.
- `analysis_range_end`: Optional[datetime.datetime] — End of time range for analysis (ISO format). Only events before this time are analyzed.
- `check_priority`: Optional[int] — Check against journal log priority (0=emergency..7=debug). If an entry has priority <= check_priority, an ERROR event...
- `group`: bool — If True, group entries that have the same priority and message. | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors...
- `boot`: Optional[int] — Optional boot ID to limit journal collection to a specific boot. | [JournalData](#JournalData-Model) | [JournalCollector](#Collector-Class-JournalCollector) | [JournalAnalyzer](#Data-Analyzer-Class-JournalAnalyzer) | | KernelPlugin | sh -c 'uname -a'
sh -c 'cat /proc/sys/kernel/numa_balancing'
wmic os get Version /Value | **Analyzer Args:**
- `exp_kernel`: Union[str, list] — Expected kernel version string(s) to match (e.g. from uname -a).
- `exp_numa`: Optional[int] — Expected value for kernel.numa_balancing (e.g. 0 or 1).
- `regex_match`: bool — If True, match exp_kernel as regex; otherwise exact match. | - | [KernelDataModel](#KernelDataModel-Model) | [KernelCollector](#Collector-Class-KernelCollector) | [KernelAnalyzer](#Data-Analyzer-Class-KernelAnalyzer) | | KernelModulePlugin | cat /proc/modules
modinfo amdgpu
wmic os get Version /Value | **Analyzer Args:**
- `kernel_modules`: dict[str, dict] — Expected kernel module name -> {version, etc.}. Analyzer checks collected modules match.
- `regex_filter`: list[str] — List of regex patterns to filter which collected modules are checked (default: amd). | - | [KernelModuleDataModel](#KernelModuleDataModel-Model) | [KernelModuleCollector](#Collector-Class-KernelModuleCollector) | [KernelModuleAnalyzer](#Data-Analyzer-Class-KernelModuleAnalyzer) | | MemoryPlugin | free -b
lsmem
numactl -H
wmic OS get FreePhysicalMemory /Value; wmic ComputerSystem get TotalPhysicalMemory /Value | **Analyzer Args:**
- `ratio`: float — Required free-memory ratio (0-1). Analysis fails if free/total < ratio.
- `memory_threshold`: str — Minimum free memory required (e.g. '30Gi', '1T'). Used when ratio is not sufficient. | - | [MemoryDataModel](#MemoryDataModel-Model) | [MemoryCollector](#Collector-Class-MemoryCollector) | [MemoryAnalyzer](#Data-Analyzer-Class-MemoryAnalyzer) | -| NetworkPlugin | ip addr show
curl
ethtool -S {interface}
ethtool {interface}
lldpcli show neighbor
lldpctl
ip neighbor show
ping
rdma link -j
ip route show
ip rule show
wget | **Built-in Regexes:**
- tx_pfc_frames is non-zero: `^tx_pfc_frames$`
- tx_pfc_ena_frames_pri* is non-zero: `^tx_pfc_ena_frames_pri\d+$`
- pfc_pri*_tx_transitions is non-zero: `^pfc_pri\d+_tx_transitions$`
**Analyzer Args:**
- `error_regex`: Union[list[nodescraper.base.regexanalyzer.ErrorRegex], list[dict], NoneType] — Custom error regex patterns; each item can be ErrorRegex or dict with category/pattern. | **Collection Args:**
- `url`: Optional[str] — Optional URL to probe for network connectivity (used with netprobe).
- `netprobe`: Optional[Literal['ping', 'wget', 'curl']] — Tool to use for network connectivity probe: ping, wget, or curl. | [NetworkDataModel](#NetworkDataModel-Model) | [NetworkCollector](#Collector-Class-NetworkCollector) | [NetworkAnalyzer](#Data-Analyzer-Class-NetworkAnalyzer) | -| NicPlugin | niccli --listdev
niccli --list
niccli --list_devices
niccli -dev {device_num} nvm -getoption pcie_relaxed_ordering
niccli --dev {device_num} nvm --getoption pcie_relaxed_ordering
niccli -dev {device_num} nvm -getoption performance_profile
niccli --dev {device_num} nvm --getoption performance_profile
niccli -dev {device_num} nvm -getoption support_rdma -scope 0
niccli -dev {device_num} getqos
niccli --dev {device_num} nvm --getoption support_rdma --scope 0
niccli --dev {device_num} qos --ets --show
niccli --version
nicctl show card
nicctl --version
nicctl show card flash partition --json
nicctl show card interrupts --json
nicctl show card logs --non-persistent
nicctl show card logs --boot-fault
nicctl show card logs --persistent
nicctl show card profile --json
nicctl show card time --json
nicctl show card statistics packet-buffer summary --json
nicctl show lif statistics --json
nicctl show lif internal queue-to-ud-pinning
nicctl show pipeline internal anomalies
nicctl show pipeline internal rsq-ring
nicctl show pipeline internal statistics memory
nicctl show port fsm
nicctl show port transceiver --json
nicctl show port statistics --json
nicctl show port internal mac
nicctl show qos headroom --json
nicctl show rdma queue --json
nicctl show rdma queue-pair --detail --json
nicctl show version firmware
nicctl show dcqcn
nicctl show environment
nicctl show lif
nicctl show pcie ats
nicctl show port
nicctl show qos
nicctl show rdma statistics
nicctl show version host-software
nicctl show dcqcn --card {card_id} --json
nicctl show card hardware-config --card {card_id} | **Analyzer Args:**
- `expected_values`: Optional[Dict[str, Dict[str, Any]]] — Per-command expected checks keyed by canonical key (see command_to_canonical_key).
- `performance_profile_expected`: str — Expected Broadcom performance_profile value (case-insensitive). Default RoCE.
- `support_rdma_disabled_values`: List[str] — Values that indicate RDMA is not supported (case-insensitive).
- `pcie_relaxed_ordering_expected`: str — Expected Broadcom pcie_relaxed_ordering value (e.g. 'Relaxed ordering = enabled'); checked case-insensitively. Defaul...
- `expected_qos_prio_map`: Optional[Dict[Any, Any]] — Expected priority-to-TC map (e.g. {0: 0, 1: 1}; keys may be int or str in config). Checked per device when set.
- `expected_qos_pfc_enabled`: Optional[int] — Expected PFC enabled value (0/1 or bitmask). Checked per device when set.
- `expected_qos_tsa_map`: Optional[Dict[Any, Any]] — Expected TSA map for ETS (e.g. {0: 'ets', 1: 'strict'}; keys may be int or str in config). Checked per device when set.
- `expected_qos_tc_bandwidth`: Optional[List[int]] — Expected TC bandwidth percentages. Checked per device when set.
- `require_qos_consistent_across_adapters`: bool — When True and no expected_qos_* are set, require all adapters to have the same prio_map, pfc_enabled, and tsa_map.
- `nicctl_log_error_regex`: Optional[List[Dict[str, Any]]] — Optional list of error patterns for nicctl show card logs. | **Collection Args:**
- `commands`: Optional[List[str]] — Optional list of niccli/nicctl commands to run. When None, default command set is used.
- `use_sudo_niccli`: bool — If True, run niccli commands with sudo when required.
- `use_sudo_nicctl`: bool — If True, run nicctl commands with sudo when required. | [NicDataModel](#NicDataModel-Model) | [NicCollector](#Collector-Class-NicCollector) | [NicAnalyzer](#Data-Analyzer-Class-NicAnalyzer) | +| NetworkPlugin | ip addr show
curl
ethtool -S {interface}
ethtool {interface}
lldpcli show neighbor
lldpctl
ip neighbor show
ping
rdma link -j
ip route show
ip rule show
wget | **Built-in Regexes:**
- tx_pfc_frames is non-zero: `^tx_pfc_frames$`
- tx_pfc_ena_frames_pri* is non-zero: `^tx_pfc_ena_frames_pri\d+$`
- pfc_pri*_tx_transitions is non-zero: `^pfc_pri\d+_tx_transitions$`
**Analyzer Args:**
- `error_regex`: Union[list[nodescraper.base.regexanalyzer.ErrorRegex], list[dict], NoneType] — Custom error regex patterns; each item can be ErrorRegex or dict with category/pattern. | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors...
- `url`: Optional[str] — Optional URL to probe for network connectivity (used with netprobe).
- `netprobe`: Optional[Literal['ping', 'wget', 'curl']] — Tool to use for network connectivity probe: ping, wget, or curl. | [NetworkDataModel](#NetworkDataModel-Model) | [NetworkCollector](#Collector-Class-NetworkCollector) | [NetworkAnalyzer](#Data-Analyzer-Class-NetworkAnalyzer) | +| NicPlugin | niccli --listdev
niccli --list
niccli --list_devices
niccli -dev {device_num} nvm -getoption pcie_relaxed_ordering
niccli --dev {device_num} nvm --getoption pcie_relaxed_ordering
niccli -dev {device_num} nvm -getoption performance_profile
niccli --dev {device_num} nvm --getoption performance_profile
niccli -dev {device_num} nvm -getoption support_rdma -scope 0
niccli -dev {device_num} getqos
niccli --dev {device_num} nvm --getoption support_rdma --scope 0
niccli --dev {device_num} qos --ets --show
niccli --version
nicctl show card
nicctl --version
nicctl show card flash partition --json
nicctl show card interrupts --json
nicctl show card logs --non-persistent
nicctl show card logs --boot-fault
nicctl show card logs --persistent
nicctl show card profile --json
nicctl show card time --json
nicctl show card statistics packet-buffer summary --json
nicctl show lif statistics --json
nicctl show lif internal queue-to-ud-pinning
nicctl show pipeline internal anomalies
nicctl show pipeline internal rsq-ring
nicctl show pipeline internal statistics memory
nicctl show port fsm
nicctl show port transceiver --json
nicctl show port statistics --json
nicctl show port internal mac
nicctl show qos headroom --json
nicctl show rdma queue --json
nicctl show rdma queue-pair --detail --json
nicctl show version firmware
nicctl show dcqcn
nicctl show environment
nicctl show lif
nicctl show pcie ats
nicctl show port
nicctl show qos
nicctl show rdma statistics
nicctl show version host-software
nicctl show dcqcn --card {card_id} --json
nicctl show card hardware-config --card {card_id} | **Analyzer Args:**
- `expected_values`: Optional[Dict[str, Dict[str, Any]]] — Per-command expected checks keyed by canonical key (see command_to_canonical_key).
- `performance_profile_expected`: str — Expected Broadcom performance_profile value (case-insensitive). Default RoCE.
- `support_rdma_disabled_values`: List[str] — Values that indicate RDMA is not supported (case-insensitive).
- `pcie_relaxed_ordering_expected`: str — Expected Broadcom pcie_relaxed_ordering value (e.g. 'Relaxed ordering = enabled'); checked case-insensitively. Defaul...
- `expected_qos_prio_map`: Optional[Dict[Any, Any]] — Expected priority-to-TC map (e.g. {0: 0, 1: 1}; keys may be int or str in config). Checked per device when set.
- `expected_qos_pfc_enabled`: Optional[int] — Expected PFC enabled value (0/1 or bitmask). Checked per device when set.
- `expected_qos_tsa_map`: Optional[Dict[Any, Any]] — Expected TSA map for ETS (e.g. {0: 'ets', 1: 'strict'}; keys may be int or str in config). Checked per device when set.
- `expected_qos_tc_bandwidth`: Optional[List[int]] — Expected TC bandwidth percentages. Checked per device when set.
- `require_qos_consistent_across_adapters`: bool — When True and no expected_qos_* are set, require all adapters to have the same prio_map, pfc_enabled, and tsa_map.
- `nicctl_log_error_regex`: Optional[List[Dict[str, Any]]] — Optional list of error patterns for nicctl show card logs. | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors...
- `commands`: Optional[List[str]] — Optional list of niccli/nicctl commands to run. When None, default command set is used.
- `use_sudo_niccli`: bool — If True, run niccli commands with sudo when required.
- `use_sudo_nicctl`: bool — If True, run nicctl commands with sudo when required. | [NicDataModel](#NicDataModel-Model) | [NicCollector](#Collector-Class-NicCollector) | [NicAnalyzer](#Data-Analyzer-Class-NicAnalyzer) | | NvmePlugin | nvme smart-log {dev}
nvme error-log {dev} --log-entries=256
nvme id-ctrl {dev}
nvme id-ns {dev}{ns}
nvme fw-log {dev}
nvme self-test-log {dev}
nvme get-log {dev} --log-id=6 --log-len=512
nvme telemetry-log {dev} --output-file={dev}_{f_name}
nvme list -o json | - | - | [NvmeDataModel](#NvmeDataModel-Model) | [NvmeCollector](#Collector-Class-NvmeCollector) | - | | OsPlugin | sh -c '( lsb_release -ds || (cat /etc/*release | grep PRETTY_NAME) || uname -om ) 2>/dev/null | head -n1'
cat /etc/*release | grep VERSION_ID
wmic os get Version /value
wmic os get Caption /Value | **Analyzer Args:**
- `exp_os`: Union[str, list] — Expected OS name/version string(s) to match (e.g. from lsb_release or /etc/os-release).
- `exact_match`: bool — If True, require exact match for exp_os; otherwise substring match. | - | [OsDataModel](#OsDataModel-Model) | [OsCollector](#Collector-Class-OsCollector) | [OsAnalyzer](#Data-Analyzer-Class-OsAnalyzer) | | PackagePlugin | dnf list --installed
dpkg-query -W
pacman -Q
cat /etc/*release
wmic product get name,version | **Analyzer Args:**
- `exp_package_ver`: Dict[str, Optional[str]] — Map package name -> expected version (None = any version). Checked against installed packages.
- `regex_match`: bool — If True, match package versions with regex; otherwise exact or prefix match.
- `rocm_regex`: Optional[str] — Optional regex to identify ROCm package version (used when enable_rocm_regex is True).
- `enable_rocm_regex`: bool — If True, use rocm_regex (or default pattern) to extract ROCm version for checks. | - | [PackageDataModel](#PackageDataModel-Model) | [PackageCollector](#Collector-Class-PackageCollector) | [PackageAnalyzer](#Data-Analyzer-Class-PackageAnalyzer) | | PciePlugin | lspci -d {vendor_id}: -nn
lspci -x
lspci -xxxx
lspci -PP
lspci -PP -d {vendor_id}:{dev_id}
lspci -PP -D -d {vendor_id}:{dev_id}
lspci -PP -D
lspci -vvv
lspci -vvvt | **Analyzer Args:**
- `exp_speed`: int — Expected PCIe link speed (generation 1–5).
- `exp_width`: int — Expected PCIe link width in lanes (1–16).
- `exp_sriov_count`: int — Expected SR-IOV virtual function count.
- `exp_gpu_count_override`: Optional[int] — Override expected GPU count for validation.
- `exp_max_payload_size`: Union[Dict[int, int], int, NoneType] — Expected max payload size: int for all devices, or dict keyed by device ID.
- `exp_max_rd_req_size`: Union[Dict[int, int], int, NoneType] — Expected max read request size: int for all devices, or dict keyed by device ID.
- `exp_ten_bit_tag_req_en`: Union[Dict[int, int], int, NoneType] — Expected 10-bit tag request enable: int for all devices, or dict keyed by device ID. | - | [PcieDataModel](#PcieDataModel-Model) | [PcieCollector](#Collector-Class-PcieCollector) | [PcieAnalyzer](#Data-Analyzer-Class-PcieAnalyzer) | -| ProcessPlugin | top -b -n 1
rocm-smi --showpids
top -b -n 1 -o %CPU | **Analyzer Args:**
- `max_kfd_processes`: int — Maximum allowed number of KFD (Kernel Fusion Driver) processes; 0 disables the check.
- `max_cpu_usage`: float — Maximum allowed CPU usage (percent) for process checks. | **Collection Args:**
- `top_n_process`: int — Number of top processes by CPU usage to collect (e.g. for top -b -n 1 -o %%CPU). | [ProcessDataModel](#ProcessDataModel-Model) | [ProcessCollector](#Collector-Class-ProcessCollector) | [ProcessAnalyzer](#Data-Analyzer-Class-ProcessAnalyzer) | +| ProcessPlugin | top -b -n 1
rocm-smi --showpids
top -b -n 1 -o %CPU | **Analyzer Args:**
- `max_kfd_processes`: int — Maximum allowed number of KFD (Kernel Fusion Driver) processes; 0 disables the check.
- `max_cpu_usage`: float — Maximum allowed CPU usage (percent) for process checks. | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors...
- `top_n_process`: int — Number of top processes by CPU usage to collect (e.g. for top -b -n 1 -o %%CPU). | [ProcessDataModel](#ProcessDataModel-Model) | [ProcessCollector](#Collector-Class-ProcessCollector) | [ProcessAnalyzer](#Data-Analyzer-Class-ProcessAnalyzer) | | RdmaPlugin | rdma link -j
rdma dev
rdma link
rdma statistic -j | - | - | [RdmaDataModel](#RdmaDataModel-Model) | [RdmaCollector](#Collector-Class-RdmaCollector) | [RdmaAnalyzer](#Data-Analyzer-Class-RdmaAnalyzer) | | RegexSearchPlugin | - | Runs RegexSearchAnalyzer: user-defined patterns via analysis_args.error_regex (same shape as Dmesg).
Emits regex match events with optional per-file source in the description when scanning directories.
**Analyzer Args:**
- `error_regex`: Optional[list[dict[str, Any]]] — Regex patterns to search for; each dict may include regex (str), message, event_category, event_priority (same as Dme...
- `interval_to_collapse_event`: int — Seconds within which repeated events are collapsed into one.
- `num_timestamps`: int — Number of timestamps to include per event in output. | - | [RegexSearchData](#RegexSearchData-Model) | - | [RegexSearchAnalyzer](#Data-Analyzer-Class-RegexSearchAnalyzer) | -| RocmPlugin | {rocm_path}/opencl/bin/*/clinfo
env | grep -Ei 'rocm|hsa|hip|mpi|openmp|ucx|miopen'
ls /sys/class/kfd/kfd/proc/
grep -i -E 'rocm' /etc/ld.so.conf.d/*
{rocm_path}/bin/rocminfo
ls -v -d {rocm_path}*
ls -v -d {rocm_path}-[3-7]* | tail -1
ldconfig -p | grep -i -E 'rocm'
grep . -H -r -i {rocm_path}/.info/* | **Analyzer Args:**
- `exp_rocm`: Union[str, list] — Expected ROCm version string(s) to match (e.g. from rocminfo).
- `exp_rocm_latest`: str — Expected 'latest' ROCm path or version string for versioned installs.
- `exp_rocm_sub_versions`: dict[str, Union[str, list]] — Map sub-version name (e.g. version_rocm) to expected string or list of allowed strings. | **Collection Args:**
- `rocm_path`: str — Base path to ROCm installation (e.g. /opt/rocm). Used for rocminfo, clinfo, and version discovery. | [RocmDataModel](#RocmDataModel-Model) | [RocmCollector](#Collector-Class-RocmCollector) | [RocmAnalyzer](#Data-Analyzer-Class-RocmAnalyzer) | -| StoragePlugin | sh -c 'df -lH -B1 | grep -v 'boot''
wmic LogicalDisk Where DriveType="3" Get DeviceId,Size,FreeSpace | - | **Collection Args:**
- `skip_sudo`: bool — If True, do not use sudo when running df and related storage commands. | [StorageDataModel](#StorageDataModel-Model) | [StorageCollector](#Collector-Class-StorageCollector) | [StorageAnalyzer](#Data-Analyzer-Class-StorageAnalyzer) | -| SysSettingsPlugin | cat /sys/{}
ls -1 /sys/{}
ls -l /sys/{} | **Analyzer Args:**
- `checks`: Optional[list[nodescraper.plugins.inband.sys_settings.analyzer_args.SysfsCheck]] — List of sysfs checks (path, expected values or pattern, display name). | **Collection Args:**
- `paths`: list[str] — Sysfs paths to read (cat). Paths with '*' are collected with ls -l (e.g. class/net/*/device).
- `directory_paths`: list[str] — Sysfs paths to list (ls -1); used for checks that match entry names by regex. | [SysSettingsDataModel](#SysSettingsDataModel-Model) | [SysSettingsCollector](#Collector-Class-SysSettingsCollector) | [SysSettingsAnalyzer](#Data-Analyzer-Class-SysSettingsAnalyzer) | +| RocmPlugin | {rocm_path}/opencl/bin/*/clinfo
env | grep -Ei 'rocm|hsa|hip|mpi|openmp|ucx|miopen'
ls /sys/class/kfd/kfd/proc/
grep -i -E 'rocm' /etc/ld.so.conf.d/*
{rocm_path}/bin/rocminfo
ls -v -d {rocm_path}*
ls -v -d {rocm_path}-[3-7]* | tail -1
ldconfig -p | grep -i -E 'rocm'
grep . -H -r -i {rocm_path}/.info/* | **Analyzer Args:**
- `exp_rocm`: Union[str, list] — Expected ROCm version string(s) to match (e.g. from rocminfo).
- `exp_rocm_latest`: str — Expected 'latest' ROCm path or version string for versioned installs.
- `exp_rocm_sub_versions`: dict[str, Union[str, list]] — Map sub-version name (e.g. version_rocm) to expected string or list of allowed strings. | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors...
- `rocm_path`: str — Base path to ROCm installation (e.g. /opt/rocm). Used for rocminfo, clinfo, and version discovery. | [RocmDataModel](#RocmDataModel-Model) | [RocmCollector](#Collector-Class-RocmCollector) | [RocmAnalyzer](#Data-Analyzer-Class-RocmAnalyzer) | +| StoragePlugin | sh -c 'df -lH -B1 | grep -v 'boot''
wmic LogicalDisk Where DriveType="3" Get DeviceId,Size,FreeSpace | - | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors...
- `skip_sudo`: bool — If True, do not use sudo when running df and related storage commands. | [StorageDataModel](#StorageDataModel-Model) | [StorageCollector](#Collector-Class-StorageCollector) | [StorageAnalyzer](#Data-Analyzer-Class-StorageAnalyzer) | +| ScaleOutAristaPlugin | show interfaces counters bins | json | no-more
show interfaces counters queue | no-more
show interfaces counters queue drop-precedence | no-more
show qos interfaces ecn counters queue | json | no-more
show interfaces counters errors | json | no-more
show interfaces phy | no-more
show interfaces phy detail | no-more
show interfaces counters ip | json | no-more
show ip interface | no-more
show lldp | no-more
show lldp neighbors | json | no-more
show interfaces counters | json | no-more
show interfaces flow-control | json | no-more
show interfaces counters queue detail | no-more
show priority-flow-control counters | json | no-more
show priority-flow-control status | no-more
show interfaces status | json | no-more
show qos interfaces | no-more
show qos interfaces ecn | no-more
show qos interfaces trust | no-more
show qos maps | no-more
show qos profile | no-more
show qos profile summary | no-more
show interfaces counters rates | json | no-more
show running-config | no-more
show startup-config | no-more
show system environment cooling | json | no-more
show platform trident mmu queue status | no-more
show version | json | no-more | **Analyzer Args:**
- `analysis_ports`: Optional[List[str]] — Restrict per-port analysis to the given ports. Ports are S/P/[SP] where subport is optional (e.g. ['1/1', '1/31', '1/...
- `expected_port_bandwidth`: int — Expected interface bandwidth (bps) from show interfaces status (AristaPortStatus.bandwidth). Ports with a different b... | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Re-runs successful... | [ScaleOutAristaDataModel](#ScaleOutAristaDataModel-Model) | [ScaleOutAristaCollector](#Collector-Class-ScaleOutAristaCollector) | [ScaleOutAristaAnalyzer](#Data-Analyzer-Class-ScaleOutAristaAnalyzer) | +| ScaleOutDellPlugin | show alarm | no-more
show buffer pool | no-more
show buffer profile | no-more
show clock | no-more
show interface counters {port} | no-more
show event details | no-more
show interface fec status | no-more
show interface counters | no-more
show interface counters rate | no-more
show interface Eth | no-more
show interface phy counters | no-more
show interface status | no-more
show interface transceiver | no-more
show interface transceiver dom | no-more
show interface transceiver summary | no-more
show ip arp | no-more
show ip interfaces | no-more
show ip route | no-more
show lldp neighbor | no-more
show lldp table | no-more
show qos interface Ethall priority-flow-control statistics | no-more
show priority-flow-control watchdog | no-more
show qos interface Ethall queue all priority-flow-control watchdog-statistics | no-more
show platform environment | no-more
show platform firmware detail | no-more
show platform syseeprom | no-more
show qos interface Eth all | no-more
show qos interface Eth all queue all | no-more
show qos map dot1p-tc | no-more
show qos map dscp-tc | no-more
show qos map pfc-priority-pg | no-more
show qos map pfc-priority-queue | no-more
show qos map tc-dot1p | no-more
show qos map tc-dscp | no-more
show qos map tc-pg | no-more
show qos map tc-queue | no-more
show qos scheduler-policy | no-more
show qos wred-policy | no-more
show queue counters | no-more
show queue persistent-watermark multicast | no-more
show queue persistent-watermark unicast | no-more
show queue watermark multicast | no-more
show queue watermark unicast | no-more
show running-configuration | no-more
show version | no-more | **Analyzer Args:**
- `analysis_ports`: Optional[List[str]] — Restrict per-port analysis to the given ports. Accepts optional Eth prefix (e.g. ['1/1', '1/31', '1/1/1'] or ['Eth1/1...
- `expected_port_speed`: int — Expected interface speed (Mbps) from show interface status (DellInterfaceStatus.speed). Ports with a different speed... | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output.
- `collection_ports`: Optional[List[str]] — Restrict detail counter collection to these ports. Accepts the same tokens as analysis_ports (e.g. ['1/1', '1/1/2'] o... | [ScaleOutDellDataModel](#ScaleOutDellDataModel-Model) | [ScaleOutDellCollector](#Collector-Class-ScaleOutDellCollector) | [ScaleOutDellAnalyzer](#Data-Analyzer-Class-ScaleOutDellAnalyzer) | +| SysSettingsPlugin | cat /sys/{}
ls -1 /sys/{}
ls -l /sys/{} | **Analyzer Args:**
- `checks`: Optional[list[nodescraper.plugins.inband.sys_settings.analyzer_args.SysfsCheck]] — List of sysfs checks (path, expected values or pattern, display name). | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors...
- `paths`: list[str] — Sysfs paths to read (cat). Paths with '*' are collected with ls -l (e.g. class/net/*/device).
- `directory_paths`: list[str] — Sysfs paths to list (ls -1); used for checks that match entry names by regex. | [SysSettingsDataModel](#SysSettingsDataModel-Model) | [SysSettingsCollector](#Collector-Class-SysSettingsCollector) | [SysSettingsAnalyzer](#Data-Analyzer-Class-SysSettingsAnalyzer) | | SysctlPlugin | sysctl -n | **Analyzer Args:**
- `exp_vm_swappiness`: Optional[int] — Expected vm.swappiness value.
- `exp_vm_numa_balancing`: Optional[int] — Expected vm.numa_balancing value.
- `exp_vm_oom_kill_allocating_task`: Optional[int] — Expected vm.oom_kill_allocating_task value.
- `exp_vm_compaction_proactiveness`: Optional[int] — Expected vm.compaction_proactiveness value.
- `exp_vm_compact_unevictable_allowed`: Optional[int] — Expected vm.compact_unevictable_allowed value.
- `exp_vm_extfrag_threshold`: Optional[int] — Expected vm.extfrag_threshold value.
- `exp_vm_zone_reclaim_mode`: Optional[int] — Expected vm.zone_reclaim_mode value.
- `exp_vm_dirty_background_ratio`: Optional[int] — Expected vm.dirty_background_ratio value.
- `exp_vm_dirty_ratio`: Optional[int] — Expected vm.dirty_ratio value.
- `exp_vm_dirty_writeback_centisecs`: Optional[int] — Expected vm.dirty_writeback_centisecs value.
- `exp_kernel_numa_balancing`: Optional[int] — Expected kernel.numa_balancing value. | - | [SysctlDataModel](#SysctlDataModel-Model) | [SysctlCollector](#Collector-Class-SysctlCollector) | [SysctlAnalyzer](#Data-Analyzer-Class-SysctlAnalyzer) | | SyslogPlugin | ls -1 /var/log/syslog* 2>/dev/null | grep -E '^/var/log/syslog(\.[0-9]+(\.gz)?)?$' || true
ls -1 /var/log/messages* 2>/dev/null | grep -E '^/var/log/messages(\.[0-9]+(\.gz)?)?$' || true | - | - | [SyslogData](#SyslogData-Model) | [SyslogCollector](#Collector-Class-SyslogCollector) | - | | UptimePlugin | uptime | - | - | [UptimeDataModel](#UptimeDataModel-Model) | [UptimeCollector](#Collector-Class-UptimeCollector) | - | @@ -37,12 +39,12 @@ | Plugin | Collection | Analyzer Args | Collection Args | DataModel | Collector | Analyzer | | --- | --- | --- | --- | --- | --- | --- | -| OobGenericCollectionPlugin | Runs each command from collection_args.commands on the target (in-band host or BMC over OOB SSH).
Commands are user-configured; there are no fixed CMD_* class fields. | **Analyzer Args:**
- `checks`: list[nodescraper.plugins.generic_collection.analyzer_args.CommandCheck] — Per-command validation rules keyed by collected command name. | **Collection Args:**
- `commands`: list[nodescraper.plugins.generic_collection.collector_args.CommandSpec] — Named commands to run. Each entry must include 'name' and 'command'. Prefer small textual stdout; see class docstring...
- `sudo`: bool — Default sudo setting for commands that do not specify sudo.
- `timeout`: int — Default per-command timeout in seconds.
- `include_stdout`: bool — Default: include each command's stdout in collected results for analysis. When false, stdout is omitted from stored r... | [GenericCollectionDataModel](#GenericCollectionDataModel-Model) | [GenericCollectionCollector](#Collector-Class-GenericCollectionCollector) | [GenericAnalyzer](#Data-Analyzer-Class-GenericAnalyzer) | -| OobBmcArchivePlugin | SSH (BMC) shell: tar+gzip archives for each path in collection_args (see PathSpec entries).
Uses sudo on the BMC when collection_args paths require elevated access. | - | **Collection Args:**
- `paths`: list[nodescraper.plugins.ooband.bmc_archive.collector_args.PathSpec] — Named BMC paths to archive with tar czf -. Configure in plugin config under plugins.OobBmcArchivePlugin.collection_ar...
- `sudo`: bool — Default sudo setting for paths that do not specify sudo.
- `timeout`: int — Default per-path tar timeout in seconds.
- `skip_if_missing`: bool — Skip paths that do not exist on the BMC instead of failing collection.
- `ignore_failed_read`: bool — When true, pass GNU tar's --ignore-failed-read when the remote tar supports it. | [BmcArchiveDataModel](#BmcArchiveDataModel-Model) | [BmcArchiveCollector](#Collector-Class-BmcArchiveCollector) | - | -| RedfishEndpointPlugin | Redfish GET: explicit paths from collection_args.uris (parallel when max_workers>1).
Optional paged GET following the Members collection OData nextLink field when follow_next_link is true.
Redfish GET tree: when discover_tree is true, walks from api_root using OData resource id links and Members navigation (depth and endpoint caps from collection_args). | For each entry in analysis_args.checks, reads JSON paths in collected responses and compares values to constraints (eq, min/max, anyOf, regex, etc.).
URI key "*" runs checks against every collected response body.
**Analyzer Args:**
- `checks`: dict[str, dict[str, Union[int, float, str, bool, dict[str, Any]]]] — Map: URI or '*' -> { property_path: constraint }. URI keys must match a key in the collected responses (exact match).... | **Collection Args:**
- `uris`: list[str] — Redfish URIs to GET. Ignored when discover_tree is True.
- `discover_tree`: bool — If True, discover endpoints from the BMC Redfish tree (service root and links) instead of using uris.
- `tree_max_depth`: int — When discover_tree is True: max traversal depth (1=service root only, 2=root + collections, 3=+ members).
- `tree_max_endpoints`: int — When discover_tree is True: max endpoints to discover (0=no limit).
- `max_workers`: int — Max concurrent GETs (1=sequential). Use >1 for async endpoint fetches.
- `follow_next_link`: bool — If True, follow Redfish Members collection OData nextLink pagination for each URI and merge all pages into a single r...
- `max_pages`: int — When follow_next_link is True: safety cap on the number of pages to follow per URI (default 200). | [RedfishEndpointDataModel](#RedfishEndpointDataModel-Model) | [RedfishEndpointCollector](#Collector-Class-RedfishEndpointCollector) | [RedfishEndpointAnalyzer](#Data-Analyzer-Class-RedfishEndpointAnalyzer) | -| RedfishOemDiagPlugin | Redfish LogService.CollectDiagnosticData for each entry in collection_args.oem_diagnostic_types (collection_args.log_service_path selects the LogService).
Optional binary archives under the plugin log path when log_path is set. | Summarizes success/failure per OEM diagnostic type from collected results.
When analysis_args.require_all_success is true, fails the run if any type failed collection.
**Analyzer Args:**
- `require_all_success`: bool — If True, analysis fails when any OEM type collection failed. | **Collection Args:**
- `log_service_path`: str — Redfish path to the LogService (e.g. DiagLogs).
- `oem_diagnostic_types_allowable`: Optional[list[str]] — Allowable OEM diagnostic types for this architecture/BMC. When set, used for validation and as default for oem_diagno...
- `oem_diagnostic_types`: list[str] — OEM diagnostic types to collect. When empty and oem_diagnostic_types_allowable is set, defaults to that list.
- `task_timeout_s`: int — Max seconds to wait for each BMC task. | [RedfishOemDiagDataModel](#RedfishOemDiagDataModel-Model) | [RedfishOemDiagCollector](#Collector-Class-RedfishOemDiagCollector) | [RedfishOemDiagAnalyzer](#Data-Analyzer-Class-RedfishOemDiagAnalyzer) | -| ServiceabilityPluginMI3XX | - | **Analyzer Args:**
- `hub_python_module`: Optional[str] — Import path for the hub module (class implements hub_analyze_method); hub_options forwards kwargs.
- `hub_display_name`: Optional[str] — Optional label for analyzer status messages.
- `afid_sag_path`: Optional[str] — Path to hub config (e.g. AFID_SAG.json); passed as hub_init_path_kwarg.
- `hub_init_path_kwarg`: str — Hub __init__ keyword that receives afid_sag_path.
- `hub_analyze_method`: str — Hub method called with rf_events first (default get_service_info).
- `skip_hub`: bool — If True, only build afid_events without running the service hub.
- `cper_decode_module`: Optional[str] — Module import path for CPER decoding when events include CPER attachments.
- `cper_decode_method`: str — Callable on cper_decode_module: file-like CPER in, (return_code, decode_dict) out.
- `hub_options`: Optional[dict[str, Any]] — Extra kwargs for hub __init__ and analyze; collected cper_data overrides cper_data key.
- `from_ac_cycle`: int — from_ac_cycle kwarg for the hub analyze call (merged after hub_options).
- `from_date`: Optional[str] — Optional from_date for the hub analyze call (merged after hub_options).
- `designation_serials`: Optional[dict[str, str]] — Optional designation_serials for the hub analyze call (merged after hub_options).
- `suppress_service_actions`: Optional[list[str]] — Optional suppress_service_actions for the hub analyze call (merged after hub_options). | **Collection Args:**
- `uri`: Optional[str] — Optional alias for ``rf_event_log_uri``. When both ``uri`` and ``rf_event_log_uri`` are explicitly set to non-empty v...
- `rf_event_log_uri`: str — Redfish URI for the event log ``Entries`` collection.
- `rf_chassis_devices`: Optional[List[str]] — Chassis designations for Assembly GETs; required with ``rf_assembly_uri_template``.
- `rf_assembly_uri_template`: Optional[str] — Redfish URI template containing ``{device}`` for each chassis Assembly resource.
- `rf_firmware_bundle_uri`: Optional[str] — Redfish URI for firmware bundle inventory when subclasses extract component details.
- `follow_next_link`: bool — If True, follow Members@odata.nextLink up to max_pages; else single GET.
- `max_pages`: int — Safety cap on the number of pages when following event log pagination.
- `top`: Optional[int] — Most recent N entries via $skip after count probe; None collects full window.
- `reference_time`: Optional[str] — Optional ISO-8601 date or date-time used with time_operator (e.g. 2026-05-17 or 2026-05-17T13:01:00).
- `time_operator`: Optional[Literal['>', '>=', '<', '<=', '==']] — Comparison operator applied when reference_time is set. | [ServiceabilityDataModel](#ServiceabilityDataModel-Model) | [MI3XXCollector](#Collector-Class-MI3XXCollector) | [MI3XXAnalyzer](#Data-Analyzer-Class-MI3XXAnalyzer) | -| ServiceabilityPluginBase | - | - | - | [ServiceabilityDataModel](#ServiceabilityDataModel-Model) | [ServiceabilityCollectorBase](#Collector-Class-ServiceabilityCollectorBase) | - | +| OobGenericCollectionPlugin | Runs each command from collection_args.commands on the target (in-band host or BMC over OOB SSH).
Commands are user-configured; there are no fixed CMD_* class fields. | **Analyzer Args:**
- `checks`: list[nodescraper.plugins.generic_collection.analyzer_args.CommandCheck] — Per-command validation rules keyed by collected command name. | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors...
- `commands`: list[nodescraper.plugins.generic_collection.collector_args.CommandSpec] — Named commands to run. Each entry must include 'name' and 'command'. Prefer small textual stdout; see class docstring...
- `sudo`: bool — Default sudo setting for commands that do not specify sudo.
- `timeout`: int — Default per-command timeout in seconds.
- `include_stdout`: bool — Default: include each command's stdout in collected results for analysis. When false, stdout is omitted from stored r... | [GenericCollectionDataModel](#GenericCollectionDataModel-Model) | [GenericCollectionCollector](#Collector-Class-GenericCollectionCollector) | [GenericAnalyzer](#Data-Analyzer-Class-GenericAnalyzer) | +| OobBmcArchivePlugin | SSH (BMC) shell: tar+gzip archives for each path in collection_args (see PathSpec entries).
Uses sudo on the BMC when collection_args paths require elevated access. | - | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors...
- `paths`: list[nodescraper.plugins.ooband.bmc_archive.collector_args.PathSpec] — Named BMC paths to archive with tar czf -. Configure in plugin config under plugins.OobBmcArchivePlugin.collection_ar...
- `sudo`: bool — Default sudo setting for paths that do not specify sudo.
- `timeout`: int — Default per-path tar timeout in seconds.
- `skip_if_missing`: bool — Skip paths that do not exist on the BMC instead of failing collection.
- `ignore_failed_read`: bool — When true, pass GNU tar's --ignore-failed-read when the remote tar supports it. | [BmcArchiveDataModel](#BmcArchiveDataModel-Model) | [BmcArchiveCollector](#Collector-Class-BmcArchiveCollector) | - | +| RedfishEndpointPlugin | Redfish GET: explicit paths from collection_args.uris (parallel when max_workers>1).
Optional paged GET following the Members collection OData nextLink field when follow_next_link is true.
Redfish GET tree: when discover_tree is true, walks from api_root using OData resource id links and Members navigation (depth and endpoint caps from collection_args). | For each entry in analysis_args.checks, reads JSON paths in collected responses and compares values to constraints (eq, min/max, anyOf, regex, etc.).
URI key "*" runs checks against every collected response body.
**Analyzer Args:**
- `checks`: dict[str, dict[str, Union[int, float, str, bool, dict[str, Any]]]] — Map: URI or '*' -> { property_path: constraint }. URI keys must match a key in the collected responses (exact match).... | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors...
- `uris`: list[str] — Redfish URIs to GET. Ignored when discover_tree is True.
- `discover_tree`: bool — If True, discover endpoints from the BMC Redfish tree (service root and links) instead of using uris.
- `tree_max_depth`: int — When discover_tree is True: max traversal depth (1=service root only, 2=root + collections, 3=+ members).
- `tree_max_endpoints`: int — When discover_tree is True: max endpoints to discover (0=no limit).
- `max_workers`: int — Max concurrent GETs (1=sequential). Use >1 for async endpoint fetches.
- `follow_next_link`: bool — If True, follow Redfish Members collection OData nextLink pagination for each URI and merge all pages into a single r...
- `max_pages`: int — When follow_next_link is True: safety cap on the number of pages to follow per URI (default 200). | [RedfishEndpointDataModel](#RedfishEndpointDataModel-Model) | [RedfishEndpointCollector](#Collector-Class-RedfishEndpointCollector) | [RedfishEndpointAnalyzer](#Data-Analyzer-Class-RedfishEndpointAnalyzer) | +| RedfishOemDiagPlugin | Redfish LogService.CollectDiagnosticData for each entry in collection_args.oem_diagnostic_types (collection_args.log_service_path selects the LogService).
Optional binary archives under the plugin log path when log_path is set. | Summarizes success/failure per OEM diagnostic type from collected results.
When analysis_args.require_all_success is true, fails the run if any type failed collection.
**Analyzer Args:**
- `require_all_success`: bool — If True, analysis fails when any OEM type collection failed. | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors...
- `log_service_path`: str — Redfish path to the LogService (e.g. DiagLogs).
- `oem_diagnostic_types_allowable`: Optional[list[str]] — Allowable OEM diagnostic types for this architecture/BMC. When set, used for validation and as default for oem_diagno...
- `oem_diagnostic_types`: list[str] — OEM diagnostic types to collect. When empty and oem_diagnostic_types_allowable is set, defaults to that list.
- `task_timeout_s`: int — Max seconds to wait for each BMC task. | [RedfishOemDiagDataModel](#RedfishOemDiagDataModel-Model) | [RedfishOemDiagCollector](#Collector-Class-RedfishOemDiagCollector) | [RedfishOemDiagAnalyzer](#Data-Analyzer-Class-RedfishOemDiagAnalyzer) | +| ServiceabilityPluginMI3XX | - | **Analyzer Args:**
- `hub_python_module`: Optional[str] — Import path for the hub module (class implements hub_analyze_method); hub_options forwards kwargs.
- `hub_display_name`: Optional[str] — Optional label for analyzer status messages.
- `afid_sag_path`: Optional[str] — Path to hub config (e.g. AFID_SAG.json); passed as hub_init_path_kwarg.
- `hub_init_path_kwarg`: str — Hub __init__ keyword that receives afid_sag_path.
- `hub_analyze_method`: str — Hub method called with rf_events first (default get_service_info).
- `skip_hub`: bool — If True, only build afid_events without running the service hub.
- `cper_decode_module`: Optional[str] — Module import path for CPER decoding when events include CPER attachments.
- `cper_decode_method`: str — Callable on cper_decode_module: file-like CPER in, (return_code, decode_dict) out.
- `hub_options`: Optional[dict[str, Any]] — Extra kwargs for hub __init__ and analyze; collected cper_data overrides cper_data key.
- `from_ac_cycle`: int — from_ac_cycle kwarg for the hub analyze call (merged after hub_options).
- `from_date`: Optional[str] — Optional from_date for the hub analyze call (merged after hub_options).
- `designation_serials`: Optional[dict[str, str]] — Optional designation_serials for the hub analyze call (merged after hub_options).
- `suppress_service_actions`: Optional[list[str]] — Optional suppress_service_actions for the hub analyze call (merged after hub_options). | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors...
- `uri`: Optional[str] — Optional alias for ``rf_event_log_uri``. When both ``uri`` and ``rf_event_log_uri`` are explicitly set to non-empty v...
- `rf_event_log_uri`: str — Redfish URI for the event log ``Entries`` collection.
- `rf_chassis_devices`: Optional[List[str]] — Chassis designations for Assembly GETs; required with ``rf_assembly_uri_template``.
- `rf_assembly_uri_template`: Optional[str] — Redfish URI template containing ``{device}`` for each chassis Assembly resource.
- `rf_firmware_bundle_uri`: Optional[str] — Redfish URI for firmware bundle inventory when subclasses extract component details.
- `follow_next_link`: bool — If True, follow Members@odata.nextLink up to max_pages; else single GET.
- `max_pages`: int — Safety cap on the number of pages when following event log pagination.
- `top`: Optional[int] — Most recent N entries via $skip after count probe; None collects full window.
- `reference_time`: Optional[str] — Optional ISO-8601 date or date-time used with time_operator (e.g. 2026-05-17 or 2026-05-17T13:01:00).
- `time_operator`: Optional[Literal['>', '>=', '<', '<=', '==']] — Comparison operator applied when reference_time is set. | [ServiceabilityDataModel](#ServiceabilityDataModel-Model) | [MI3XXCollector](#Collector-Class-MI3XXCollector) | [MI3XXAnalyzer](#Data-Analyzer-Class-MI3XXAnalyzer) | +| ServiceabilityPluginBase | - | - | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Arista collectors... | [ServiceabilityDataModel](#ServiceabilityDataModel-Model) | [ServiceabilityCollectorBase](#Collector-Class-ServiceabilityCollectorBase) | - | # Collectors @@ -886,6 +888,256 @@ StorageDataModel - sh -c 'df -lH -B1 | grep -v 'boot'' - wmic LogicalDisk Where DriveType="3" Get DeviceId,Size,FreeSpace +## Collector Class ScaleOutAristaCollector + +### Description + +Collect Arista switch data. + + Runs Arista EOS ``show`` commands (JSON and text) and parses their + output into a :class:`ScaleOutAristaDataModel`. + +**Bases**: ['InBandDataCollector'] + +**Link to code**: [scale_out_arista_collector.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/inband/switch/scale_out_arista/scale_out_arista_collector.py) + +### Class Variables + +- **SUPPORTED_OS_FAMILY**: `{, , }` +- **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` +- **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` +- **ARTIFACT_COMMANDS**: `[ + show running-config | no-more, + show startup-config | no-more, + show ip interface | no-more, + show interfaces phy | no-more, + show interfaces phy detail | no-more, + show qos profile | no-more, + show qos profile summary | no-more, + show qos maps | no-more, + show qos interfaces | no-more, + show qos interfaces trust | no-more, + show priority-flow-control status | no-more, + show qos interfaces ecn | no-more, + show lldp | no-more, + show platform trident mmu queue status | no-more +]` + +### Provides Data + +ScaleOutAristaDataModel + +### Commands + +- show interfaces counters bins | json | no-more +- show interfaces counters queue | no-more +- show interfaces counters queue drop-precedence | no-more +- show qos interfaces ecn counters queue | json | no-more +- show interfaces counters errors | json | no-more +- show interfaces phy | no-more +- show interfaces phy detail | no-more +- show interfaces counters ip | json | no-more +- show ip interface | no-more +- show lldp | no-more +- show lldp neighbors | json | no-more +- show interfaces counters | json | no-more +- show interfaces flow-control | json | no-more +- show interfaces counters queue detail | no-more +- show priority-flow-control counters | json | no-more +- show priority-flow-control status | no-more +- show interfaces status | json | no-more +- show qos interfaces | no-more +- show qos interfaces ecn | no-more +- show qos interfaces trust | no-more +- show qos maps | no-more +- show qos profile | no-more +- show qos profile summary | no-more +- show interfaces counters rates | json | no-more +- show running-config | no-more +- show startup-config | no-more +- show system environment cooling | json | no-more +- show platform trident mmu queue status | no-more +- show version | json | no-more + +## Collector Class ScaleOutDellCollector + +### Description + +Collect Dell SONiC switch data. + + Runs Dell SONiC CLI ``show`` commands over SSH and parses their text + output into a :class:`ScaleOutDellDataModel`. + +**Bases**: ['InBandDataCollector'] + +**Link to code**: [scale_out_dell_collector.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/inband/switch/scale_out_dell/scale_out_dell_collector.py) + +### Class Variables + +- **SUPPORTED_OS_FAMILY**: `{, , }` +- **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` +- **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('^(?PEth\\S+)\\s+(?P.+)\\s+(?Pup|down)\\s+(?P\\S+)\\s+(?P\\S+)\\s+(?P\\d+)\\s+(?P\\d+)\\s+(?P\\S+)\\s*$', re.IGNORECASE)` +- **ARTIFACT_COMMANDS**: `[ + show clock | no-more, + show platform syseeprom | no-more, + show platform firmware detail | no-more, + show running-configuration | no-more, + show interface transceiver | no-more, + show interface transceiver summary | no-more, + show ip interfaces | no-more, + show qos map dscp-tc | no-more, + show qos map tc-queue | no-more, + show qos map tc-pg | no-more, + show qos map tc-dscp | no-more, + show qos map tc-dot1p | no-more, + show qos map pfc-priority-queue | no-more, + show qos map pfc-priority-pg | no-more, + show qos map dot1p-tc | no-more, + show qos scheduler-policy | no-more, + show qos wred-policy | no-more, + show qos interface Eth all | no-more, + show qos interface Eth all queue all | no-more, + show priority-flow-control watchdog | no-more, + show buffer profile | no-more, + show buffer pool | no-more, + show interface transceiver dom | no-more, + show lldp table | no-more, + show lldp neighbor | no-more, + show interface Eth | no-more, + show interface phy counters | no-more, + show interface counters rate | no-more, + show queue watermark unicast | no-more, + show queue watermark multicast | no-more, + show queue persistent-watermark unicast | no-more, + show queue persistent-watermark multicast | no-more, + show platform environment | no-more, + show event details | no-more, + show alarm | no-more +]` + +### Provides Data + +ScaleOutDellDataModel + +### Commands + +- show alarm | no-more +- show buffer pool | no-more +- show buffer profile | no-more +- show clock | no-more +- show interface counters {port} | no-more +- show event details | no-more +- show interface fec status | no-more +- show interface counters | no-more +- show interface counters rate | no-more +- show interface Eth | no-more +- show interface phy counters | no-more +- show interface status | no-more +- show interface transceiver | no-more +- show interface transceiver dom | no-more +- show interface transceiver summary | no-more +- show ip arp | no-more +- show ip interfaces | no-more +- show ip route | no-more +- show lldp neighbor | no-more +- show lldp table | no-more +- show qos interface Ethall priority-flow-control statistics | no-more +- show priority-flow-control watchdog | no-more +- show qos interface Ethall queue all priority-flow-control watchdog-statistics | no-more +- show platform environment | no-more +- show platform firmware detail | no-more +- show platform syseeprom | no-more +- show qos interface Eth all | no-more +- show qos interface Eth all queue all | no-more +- show qos map dot1p-tc | no-more +- show qos map dscp-tc | no-more +- show qos map pfc-priority-pg | no-more +- show qos map pfc-priority-queue | no-more +- show qos map tc-dot1p | no-more +- show qos map tc-dscp | no-more +- show qos map tc-pg | no-more +- show qos map tc-queue | no-more +- show qos scheduler-policy | no-more +- show qos wred-policy | no-more +- show queue counters | no-more +- show queue persistent-watermark multicast | no-more +- show queue persistent-watermark unicast | no-more +- show queue watermark multicast | no-more +- show queue watermark unicast | no-more +- show running-configuration | no-more +- show version | no-more + ## Collector Class SysSettingsCollector ### Description @@ -1267,6 +1519,7 @@ Memory data model - **mem_free**: `str` - **mem_total**: `str` +- **mem_available**: `Optional[str]` - **lsmem_data**: `Optional[nodescraper.plugins.inband.memory.memorydata.LsmemData]` - **numa_topology**: `Optional[nodescraper.plugins.inband.memory.memorydata.NumaTopology]` @@ -1475,6 +1728,41 @@ Loaded file or directory contents passed to the analyzer (via --data). - **storage_data**: `dict[str, nodescraper.plugins.inband.storage.storagedata.DeviceStorageData]` +## ScaleOutAristaDataModel Model + +### Description + +Collected output of Arista commands. + +**Link to code**: [scaleoutaristadata.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/inband/switch/scale_out_arista/scaleoutaristadata.py) + +**Bases**: ['DataModel'] + +### Model annotations and fields + +- **version**: `Optional[nodescraper.plugins.inband.switch.scale_out_arista.scaleoutaristadata.AristaVersion]` +- **lldp_neighbors**: `Optional[nodescraper.plugins.inband.switch.scale_out_arista.scaleoutaristadata.AristaNeighbors]` +- **system_env**: `Optional[nodescraper.plugins.inband.switch.scale_out_arista.scaleoutaristadata.AristaSystemEnv]` +- **port_list**: `Optional[List[str]]` +- **port**: `Optional[Dict[str, nodescraper.plugins.inband.switch.scale_out_arista.scaleoutaristadata.PortData]]` + +## ScaleOutDellDataModel Model + +### Description + +Collected output of Dell SONiC switch commands + +**Link to code**: [scaleoutdelldata.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/inband/switch/scale_out_dell/scaleoutdelldata.py) + +**Bases**: ['DataModel'] + +### Model annotations and fields + +- **ip_arp**: `Optional[List[nodescraper.plugins.inband.switch.scale_out_dell.scaleoutdelldata.DellArpEntry]]` +- **ip_route**: `Optional[List[nodescraper.plugins.inband.switch.scale_out_dell.scaleoutdelldata.DellRouteEntry]]` +- **port_list**: `Optional[List[str]]` +- **port**: `Optional[Dict[str, nodescraper.plugins.inband.switch.scale_out_dell.scaleoutdelldata.DellPortData]]` + ## SysSettingsDataModel Model ### Description @@ -1983,6 +2271,46 @@ Check storage usage **Link to code**: [storage_analyzer.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/inband/storage/storage_analyzer.py) +## Data Analyzer Class ScaleOutAristaAnalyzer + +### Description + +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. + +**Bases**: ['SwitchAnalyzerBase', 'DataAnalyzer'] + +**Link to code**: [scale_out_arista_analyzer.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/inband/switch/scale_out_arista/scale_out_arista_analyzer.py) + +### Class Variables + +- **VENDOR_NAME**: `Arista` +- **PORT_NAME_RE**: `re.compile('^(?:Ethernet)?(\\d+(?:/\\d+)*)$', re.IGNORECASE)` +- **PORT_FORMAT_HINT**: `expected slash-separated decimals (e.g. 'M/S', 'A/B/C')` + +## Data Analyzer Class ScaleOutDellAnalyzer + +### Description + +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. + +**Bases**: ['SwitchAnalyzerBase', 'DataAnalyzer'] + +**Link to code**: [scale_out_dell_analyzer.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/inband/switch/scale_out_dell/scale_out_dell_analyzer.py) + +### Class Variables + +- **VENDOR_NAME**: `Dell` +- **PORT_NAME_RE**: `re.compile('^(?:Eth)?(\\d+(?:/\\d+)*)$', re.IGNORECASE)` +- **PORT_FORMAT_HINT**: `expected slash-separated decimals (e.g. 'M/S', 'A/B/C')` + ## Data Analyzer Class SysSettingsAnalyzer ### Description @@ -2301,6 +2629,36 @@ Arguments for RegexSearchAnalyzer (dict items match Dmesg-style error_regex). - **exp_rocm_latest**: `str` — Expected 'latest' ROCm path or version string for versioned installs. - **exp_rocm_sub_versions**: `dict[str, Union[str, list]]` — Map sub-version name (e.g. version_rocm) to expected string or list of allowed strings. +## Analyzer Args Class ScaleOutAristaAnalyzerArgs + +### Description + +Arguments for the Arista switch analyzer. + +**Bases**: ['AnalyzerArgs'] + +**Link to code**: [analyzer_args.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/inband/switch/scale_out_arista/analyzer_args.py) + +### Annotations / fields + +- **analysis_ports**: `Optional[List[str]]` — 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` — Expected interface bandwidth (bps) from show interfaces status (AristaPortStatus.bandwidth). Ports with a different bandwidth are flagged. + +## Analyzer Args Class ScaleOutDellAnalyzerArgs + +### Description + +Arguments for the Dell SONiC switch analyzer. + +**Bases**: ['AnalyzerArgs'] + +**Link to code**: [analyzer_args.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/inband/switch/scale_out_dell/analyzer_args.py) + +### Annotations / fields + +- **analysis_ports**: `Optional[List[str]]` — 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` — Expected interface speed (Mbps) from show interface status (DellInterfaceStatus.speed). Ports with a different speed are flagged. + ## Analyzer Args Class SysSettingsAnalyzerArgs ### Description 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/match_ignore.py b/nodescraper/base/match_ignore.py new file mode 100644 index 00000000..f1521a58 --- /dev/null +++ b/nodescraper/base/match_ignore.py @@ -0,0 +1,209 @@ +############################################################################### +# +# 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 dataclasses import dataclass +from typing import Optional, Sequence, Union + +_MCE_BANK_RE = re.compile(r"\bMC(?P\d+)_STATUS\b", re.IGNORECASE) + +MceBankSpec = Union[int, str] +IgnoreMatchRuleSpec = dict[str, object] + + +@dataclass(frozen=True) +class ParsedIgnoreMatchRule: + line_regex: Optional[re.Pattern[str]] = None + match_regex: Optional[re.Pattern[str]] = None + messages: Optional[frozenset[str]] = None + mce_banks: Optional[frozenset[int]] = None + + +def parse_mce_bank_spec(spec: Sequence[MceBankSpec]) -> frozenset[int]: + """Expand MCA bank ids and inclusive ranges into a set of bank numbers. + + Args: + spec: Bank ids, bank ranges like "60-63", or an empty sequence. + + Returns: + frozenset[int]: MCA bank numbers. + """ + banks: set[int] = set() + for entry in spec: + if isinstance(entry, int): + if entry < 0: + raise ValueError(f"Invalid MCE bank number: {entry}") + banks.add(entry) + continue + + token = str(entry).strip() + if not token: + raise ValueError("Empty MCE bank entry") + + if "-" in token: + start_text, end_text = token.split("-", 1) + start = int(start_text.strip()) + end = int(end_text.strip()) + if start < 0 or end < 0 or start > end: + raise ValueError(f"Invalid MCE bank range: {entry}") + banks.update(range(start, end + 1)) + continue + + bank = int(token) + if bank < 0: + raise ValueError(f"Invalid MCE bank number: {entry}") + banks.add(bank) + + return frozenset(banks) + + +def extract_mce_bank_from_line(line: str) -> Optional[int]: + """Return the MCA bank number from a dmesg line, if present. + + Args: + line: Single dmesg log line. + + Returns: + Optional[int]: MCA bank number, or None when the line has no MCn_STATUS token. + """ + match = _MCE_BANK_RE.search(line) + if match is None: + return None + return int(match.group("bank")) + + +def extract_mce_banks_from_text(text: str) -> frozenset[int]: + """Return all MCA bank numbers referenced in text. + + Args: + text: Log line or regex match text. + + Returns: + frozenset[int]: MCA bank numbers found in text. + """ + return frozenset(int(match.group("bank")) for match in _MCE_BANK_RE.finditer(text)) + + +def parse_ignore_match_rules( + spec: Optional[Sequence[IgnoreMatchRuleSpec]], +) -> tuple[list[ParsedIgnoreMatchRule], frozenset[int]]: + """Parse ignore_match_rules config into compiled skip rules and ignored MCA banks. + + Args: + spec: Rule dicts using line_regex, match_regex, message, and/or mce_banks. + + Returns: + tuple[list[ParsedIgnoreMatchRule], frozenset[int]]: Parsed rules and all ignored MCA banks. + """ + if not spec: + return [], frozenset() + + parsed_rules: list[ParsedIgnoreMatchRule] = [] + ignored_mce_banks: set[int] = set() + + for index, raw_rule in enumerate(spec): + if not isinstance(raw_rule, dict): + raise ValueError(f"ignore_match_rules[{index}] must be a dict") + + line_regex = raw_rule.get("line_regex") + match_regex = raw_rule.get("match_regex") + message = raw_rule.get("message") + mce_banks = raw_rule.get("mce_banks") + + if line_regex is None and match_regex is None and mce_banks is None: + raise ValueError( + f"ignore_match_rules[{index}] must specify at least one of " + "line_regex, match_regex, or mce_banks" + ) + + messages: Optional[frozenset[str]] = None + if message is not None: + if isinstance(message, str): + messages = frozenset({message}) + elif isinstance(message, list) and all(isinstance(item, str) for item in message): + messages = frozenset(message) + else: + raise ValueError( + f"ignore_match_rules[{index}].message must be a string or list of strings" + ) + + parsed_mce_banks: Optional[frozenset[int]] = None + if mce_banks is not None: + if not isinstance(mce_banks, list): + raise ValueError(f"ignore_match_rules[{index}].mce_banks must be a list") + parsed_mce_banks = parse_mce_bank_spec(mce_banks) + ignored_mce_banks.update(parsed_mce_banks) + + parsed_rules.append( + ParsedIgnoreMatchRule( + line_regex=re.compile(str(line_regex)) if line_regex is not None else None, + match_regex=re.compile(str(match_regex)) if match_regex is not None else None, + messages=messages, + mce_banks=parsed_mce_banks, + ) + ) + + return parsed_rules, frozenset(ignored_mce_banks) + + +def should_ignore_match( + *, + line: str, + match_text: str, + error_regex_message: str, + rules: Sequence[ParsedIgnoreMatchRule], +) -> bool: + """Return True when any ignore rule matches the current regex hit. + + Args: + line: Full log line containing the match. + match_text: Regex match text. + error_regex_message: ErrorRegex.message for the pattern that matched. + rules: Parsed ignore rules; first matching rule wins. + + Returns: + bool: True when the match should be skipped. + """ + for rule in rules: + if rule.messages is not None and error_regex_message not in rule.messages: + continue + + if rule.mce_banks is not None: + banks = extract_mce_banks_from_text(match_text) + if not banks: + line_bank = extract_mce_bank_from_line(line) + banks = frozenset({line_bank}) if line_bank is not None else frozenset() + if not banks or not banks.issubset(rule.mce_banks): + continue + + if rule.line_regex is not None and rule.line_regex.search(line) is None: + continue + + if rule.match_regex is not None and rule.match_regex.search(match_text) is None: + continue + + return True + + return False 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/base/regexanalyzer.py b/nodescraper/base/regexanalyzer.py index a53267fa..1fab952d 100644 --- a/nodescraper/base/regexanalyzer.py +++ b/nodescraper/base/regexanalyzer.py @@ -25,10 +25,11 @@ ############################################################################### import datetime import re -from typing import Optional, Union +from typing import Optional, Sequence, Union from pydantic import BaseModel +from nodescraper.base.match_ignore import ParsedIgnoreMatchRule, should_ignore_match from nodescraper.enums import EventCategory, EventPriority from nodescraper.generictypes import TAnalyzeArg, TDataModel from nodescraper.interfaces.dataanalyzertask import DataAnalyzer @@ -121,6 +122,51 @@ def _extract_timestamp_from_match_position( timestamp_match = self.TIMESTAMP_PATTERN.search(first_line) return timestamp_match.group(1) if timestamp_match else None + def _line_at_match_position(self, content: str, match_start: int) -> str: + """Return the full line containing a regex match start position. + + Args: + content: Full content being analyzed. + match_start: Start position of the regex match. + + Returns: + str: Line text containing the match. + """ + line_start = content.rfind("\n", 0, match_start) + 1 + line_end = content.find("\n", match_start) + if line_end == -1: + line_end = len(content) + return content[line_start:line_end] + + def _should_ignore_regex_match( + self, + content: str, + match_start: int, + match_text: str, + error_regex_message: str, + ignore_match_rules: Sequence[ParsedIgnoreMatchRule], + ) -> bool: + """Return True when ignore_match_rules say to skip this regex hit. + + Args: + content: Full content being analyzed. + match_start: Start position of the regex match. + match_text: Regex match text. + error_regex_message: ErrorRegex.message for the pattern that matched. + ignore_match_rules: Parsed ignore rules. + + Returns: + bool: True when the match should be skipped. + """ + if not ignore_match_rules: + return False + return should_ignore_match( + line=self._line_at_match_position(content, match_start), + match_text=match_text, + error_regex_message=error_regex_message, + rules=ignore_match_rules, + ) + def _convert_and_extend_error_regex( self, custom_regex: Optional[Union[list[ErrorRegex], list[dict]]], @@ -198,6 +244,7 @@ def check_all_regexes( group: bool = True, num_timestamps: int = 3, interval_to_collapse_event: int = 60, + ignore_match_rules: Optional[Sequence[ParsedIgnoreMatchRule]] = None, ) -> list[RegexEvent]: """Iterate over all ERROR_REGEX and check content for any matches @@ -205,6 +252,7 @@ def check_all_regexes( - Extracts timestamps from matched lines - Collapses events within interval_to_collapse_event seconds - Prunes timestamp lists to keep first N and last N timestamps + - Skips matches that satisfy ignore_match_rules Args: content (str): content to match regex on @@ -213,6 +261,7 @@ def check_all_regexes( group (bool, optional): flag to control whether matches should be grouped together. Defaults to True. num_timestamps (int, optional): maximum number of timestamps to keep for each event. Defaults to 3. interval_to_collapse_event (int, optional): time interval in seconds to collapse events. Defaults to 60. + ignore_match_rules (Optional[Sequence[ParsedIgnoreMatchRule]], optional): Parsed skip rules. Defaults to None. Returns: list[RegexEvent]: list of regex event objects @@ -246,8 +295,20 @@ def _is_within_interval(new_timestamp_str: str, existing_timestamps: list[str]) continue return False + skip_rules = list(ignore_match_rules) if ignore_match_rules else [] + for error_regex_obj in error_regex: for match_obj in error_regex_obj.regex.finditer(content): + raw_match = match_obj.group(0) + if self._should_ignore_regex_match( + content, + match_obj.start(), + raw_match, + error_regex_obj.message, + skip_rules, + ): + continue + # Extract timestamp from the line where match occurs timestamp = self._extract_timestamp_from_match_position(content, match_obj.start()) diff --git a/nodescraper/command_artifact_html.py b/nodescraper/command_artifact_html.py new file mode 100644 index 00000000..5bf3c5be --- /dev/null +++ b/nodescraper/command_artifact_html.py @@ -0,0 +1,231 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +import html + +COMMAND_ARTIFACTS_BASENAME = "command_artifacts" + +_HTML_HEAD = """ + + + + +Command Artifacts + + + +""" + +_HEADER_TEMPLATE = """
+

Command Artifacts

+
{count} commands · {title}
+
+ + + +
+
+
+""" + +_CARD_TEMPLATE = """
+ + + {command} + exit {exit_code} + +
+ {stdout_block} + {stderr_block} +
+
""" + +_HTML_TAIL = """ +
No commands match your filter.
+
+ + + +""" + + +def render_command_artifacts_html(entries: list[dict], title: str) -> str: + """Render command artifact entries into a self-contained HTML page. + + Args: + entries: Records with command, stdout, stderr, and exit_code keys. + title: Label shown in the page header. + + Returns: + str: Full HTML document. + """ + cards: list[str] = [] + for entry in entries: + command = html.escape(str(entry.get("command", "") or "")) + stdout = str(entry.get("stdout", "") or "") + stderr = str(entry.get("stderr", "") or "") + exit_code = entry.get("exit_code", "") + badge_cls = "ok" if exit_code == 0 else "fail" + + if stdout.strip(): + stdout_block = "
" + html.escape(stdout) + "
" + else: + stdout_block = '
(no stdout)
' + + if stderr.strip(): + stderr_block = ( + '
stderr
' + '
' + html.escape(stderr) + "
" + ) + else: + stderr_block = "" + + cards.append( + _CARD_TEMPLATE.format( + command=command, + badge_cls=badge_cls, + exit_code=html.escape(str(exit_code)), + stdout_block=stdout_block, + stderr_block=stderr_block, + ) + ) + + return ( + _HTML_HEAD + + _HEADER_TEMPLATE.format(count=len(entries), title=html.escape(title)) + + "\n".join(cards) + + _HTML_TAIL + ) diff --git a/nodescraper/connection/inband/inband.py b/nodescraper/connection/inband/inband.py index ef9cb9e2..9a52cb88 100644 --- a/nodescraper/connection/inband/inband.py +++ b/nodescraper/connection/inband/inband.py @@ -37,6 +37,16 @@ class CommandArtifact(BaseModel): stdout: str stderr: str exit_code: int + log_html: bool = False + + def to_html_entry(self) -> dict: + """Return a dict suitable for HTML command artifact rendering.""" + return { + "command": self.command, + "stdout": self.stdout, + "stderr": self.stderr, + "exit_code": self.exit_code, + } class BaseFileArtifact(BaseModel, abc.ABC): diff --git a/nodescraper/connection/inband/inbandmanager.py b/nodescraper/connection/inband/inbandmanager.py index f9220ea9..c1c6bea5 100644 --- a/nodescraper/connection/inband/inbandmanager.py +++ b/nodescraper/connection/inband/inbandmanager.py @@ -43,6 +43,7 @@ from .inband import InBandConnection from .inbandlocal import LocalShell from .inbandremote import RemoteShell, SSHConnectionError +from .osdetection import NetworkOsDetection, detect_network_os from .sshparams import SSHConnectionParams @@ -68,6 +69,18 @@ def __init__( **kwargs, ) + @staticmethod + def _apply_network_os_detection( + system_info: SystemInfo, + detection: NetworkOsDetection, + ) -> None: + """Apply network OS probe results to system info.""" + system_info.os_family = detection.os_family + system_info.platform = detection.platform + if system_info.metadata is None: + system_info.metadata = {} + system_info.metadata.update(detection.metadata) + def _check_os_family(self): """Check the OS family of the system under test (SUT) @@ -84,12 +97,23 @@ def _check_os_family(self): elif res.exit_code == 0: self.system_info.os_family = OSFamily.LINUX else: - self._log_event( - category=EventCategory.UNKNOWN, - description="Unable to determine SUT OS", - priority=EventPriority.WARNING, + detection = detect_network_os(self.connection) + if detection is not None: + self._apply_network_os_detection(self.system_info, detection) + else: + self._log_event( + category=EventCategory.UNKNOWN, + description="Unable to determine SUT OS", + priority=EventPriority.WARNING, + ) + if self.system_info.platform: + self.logger.info( + "OS Family: %s (%s)", + self.system_info.os_family.name, + self.system_info.platform, ) - self.logger.info("OS Family: %s", self.system_info.os_family.name) + else: + self.logger.info("OS Family: %s", self.system_info.os_family.name) def connect( self, diff --git a/nodescraper/connection/inband/osdetection.py b/nodescraper/connection/inband/osdetection.py new file mode 100644 index 00000000..9353439d --- /dev/null +++ b/nodescraper/connection/inband/osdetection.py @@ -0,0 +1,152 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +import json +import re +from dataclasses import dataclass +from typing import Optional + +from nodescraper.enums import OSFamily + +from .inband import InBandConnection + +ARISTA_VERSION_CMD = "show version | json | no-more" +DELL_VERSION_CMD = 'sonic-cli -c "show version | no-more"' + +_DELL_VERSION_PATTERNS = ( + re.compile(r"SONiC Software Version:\s*(.+)", re.IGNORECASE), + re.compile(r"SONiC OS Version:\s*(.+)", re.IGNORECASE), +) +_DELL_MODEL_PATTERNS = ( + re.compile(r"HwSKU:\s*(.+)", re.IGNORECASE), + re.compile(r"Model Number:\s*(.+)", re.IGNORECASE), + re.compile(r"Platform:\s*(.+)", re.IGNORECASE), +) + + +@dataclass(frozen=True) +class NetworkOsDetection: + """Detected network operating system details.""" + + os_family: OSFamily + platform: str + metadata: dict[str, str] + + +def _first_regex_match(patterns: tuple[re.Pattern[str], ...], text: str) -> Optional[str]: + """Return the first captured group from the first matching regex pattern.""" + for pattern in patterns: + match = pattern.search(text) + if match: + return match.group(1).strip() + return None + + +def parse_arista_version_output(stdout: str) -> Optional[NetworkOsDetection]: + """Parse Arista EOS ``show version | json`` output into detection details. + + Args: + stdout: Command stdout containing JSON version data. + + Returns: + NetworkOsDetection when the output identifies an Arista device, else None. + """ + try: + data = json.loads(stdout) + except json.JSONDecodeError: + return None + + if not isinstance(data, dict): + return None + + mfg_name = str(data.get("mfgName") or data.get("mfg_name") or "") + if "arista" not in mfg_name.lower(): + return None + + metadata: dict[str, str] = {} + version = data.get("version") + if version: + metadata["os_version"] = str(version) + model_name = data.get("modelName") or data.get("model_name") + if model_name: + metadata["device_model"] = str(model_name) + + return NetworkOsDetection( + os_family=OSFamily.EOS, + platform="Arista EOS", + metadata=metadata, + ) + + +def parse_dell_sonic_version_output(stdout: str) -> Optional[NetworkOsDetection]: + """Parse Dell SONiC ``show version`` text output into detection details. + + Args: + stdout: Command stdout containing version text. + + Returns: + NetworkOsDetection when the output identifies a Dell SONiC device, else None. + """ + lowered = stdout.lower() + if not all(marker in lowered for marker in ("dell", "sonic")): + return None + + metadata: dict[str, str] = {} + version = _first_regex_match(_DELL_VERSION_PATTERNS, stdout) + if version: + metadata["os_version"] = version + model = _first_regex_match(_DELL_MODEL_PATTERNS, stdout) + if model: + metadata["device_model"] = model + + return NetworkOsDetection( + os_family=OSFamily.SONIC, + platform="Dell SONiC", + metadata=metadata, + ) + + +def detect_network_os(connection: InBandConnection) -> Optional[NetworkOsDetection]: + """Probe a network device for Arista EOS or Dell SONiC after uname fails. + + Args: + connection: Active in-band connection to the target device. + + Returns: + NetworkOsDetection when a supported network OS is identified, else None. + """ + arista_res = connection.run_command(ARISTA_VERSION_CMD, timeout=30) + if arista_res.exit_code == 0: + detection = parse_arista_version_output(arista_res.stdout) + if detection is not None: + return detection + + dell_res = connection.run_command(DELL_VERSION_CMD, timeout=30) + if dell_res.exit_code == 0: + detection = parse_dell_sonic_version_output(dell_res.stdout) + if detection is not None: + return detection + + return None diff --git a/nodescraper/connection/redfish/redfish_connection.py b/nodescraper/connection/redfish/redfish_connection.py index 4398b06f..882acbd5 100644 --- a/nodescraper/connection/redfish/redfish_connection.py +++ b/nodescraper/connection/redfish/redfish_connection.py @@ -25,6 +25,7 @@ ############################################################################### from __future__ import annotations +import json from typing import Any, ClassVar, Optional, Union from urllib.parse import urljoin @@ -52,6 +53,19 @@ class RedfishGetResult(BaseModel): data: Optional[dict[str, Any]] = None error: Optional[str] = None status_code: Optional[int] = None + log_html: bool = False + + def to_html_entry(self) -> dict: + """Return a dict suitable for HTML command artifact rendering.""" + stdout = json.dumps(self.data, indent=2, sort_keys=True) if self.data is not None else "" + stderr = self.error or "" + exit_code = 0 if self.success else (self.status_code if self.status_code is not None else 1) + return { + "command": f"GET {self.path}", + "stdout": stdout, + "stderr": stderr, + "exit_code": exit_code, + } class RedfishConnectionError(Exception): diff --git a/nodescraper/enums/eventcategory.py b/nodescraper/enums/eventcategory.py index 42aa6c98..2c5d5514 100644 --- a/nodescraper/enums/eventcategory.py +++ b/nodescraper/enums/eventcategory.py @@ -65,6 +65,8 @@ class EventCategory(AutoNameStrEnum): Network, IT issues, Downtime - NETWORK Network configuration, interfaces, routing, neighbors, ethtool data + - SWITCH + Switch configuration, switch OS, command issues - TELEMETRY Telemetry / monitored data checks (e.g. Redfish endpoint constraint violations) - RUNTIME @@ -87,6 +89,7 @@ class EventCategory(AutoNameStrEnum): BIOS = auto() INFRASTRUCTURE = auto() NETWORK = auto() + SWITCH = auto() TELEMETRY = auto() RUNTIME = auto() UNKNOWN = auto() diff --git a/nodescraper/enums/osfamily.py b/nodescraper/enums/osfamily.py index bf9c1cd1..bce34550 100644 --- a/nodescraper/enums/osfamily.py +++ b/nodescraper/enums/osfamily.py @@ -32,3 +32,5 @@ class OSFamily(enum.Enum): WINDOWS = enum.auto() UNKNOWN = enum.auto() LINUX = enum.auto() + EOS = enum.auto() + SONIC = enum.auto() diff --git a/nodescraper/interfaces/datacollectortask.py b/nodescraper/interfaces/datacollectortask.py index 60826b16..034303bd 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/analyzerargs.py b/nodescraper/models/analyzerargs.py index f1782801..11744e66 100644 --- a/nodescraper/models/analyzerargs.py +++ b/nodescraper/models/analyzerargs.py @@ -25,7 +25,13 @@ ############################################################################### from typing import Any -from pydantic import BaseModel, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + SerializerFunctionWrapHandler, + model_serializer, + model_validator, +) class AnalyzerArgs(BaseModel): @@ -37,7 +43,18 @@ class AnalyzerArgs(BaseModel): """ - model_config = {"extra": "forbid", "exclude_none": True} + model_config = ConfigDict(extra="forbid") + + @model_serializer(mode="wrap") + def serialize_model(self, handler: SerializerFunctionWrapHandler) -> dict[str, object]: + serialized = handler(self) + remove_keys = [] + for key, value in serialized.items(): + if value is None: + remove_keys.append(key) + for key in remove_keys: + del serialized[key] + return serialized @model_validator(mode="before") @classmethod @@ -89,5 +106,5 @@ def build_from_model(cls, datamodel): NotImplementedError: Not implemented error """ raise NotImplementedError( - "Setting analyzer args from datamodel is not implemented for class: %s", cls.__name__ + f"Setting analyzer args from datamodel is not implemented for class: {cls.__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/pluginrecipe/__init__.py b/nodescraper/pluginrecipe/__init__.py index b37e91fd..b28ca993 100644 --- a/nodescraper/pluginrecipe/__init__.py +++ b/nodescraper/pluginrecipe/__init__.py @@ -8,15 +8,6 @@ from nodescraper.models import PluginConfig from .all_plugins import AllPlugins -from .discovery import ( - load_plugin_class, - plugin_has_analyzer, - plugin_has_collector, - plugin_names_matching, - plugins_with_analyzer, - plugins_with_collector, - registered_plugin_names, -) from .node_status import NodeStatus from .pluginrecipe import ( ANALYZE_ONLY, @@ -40,12 +31,5 @@ "PluginConfig", "PluginRecipe", "PluginRunFlags", - "load_plugin_class", "merge_plugin_configs", - "plugin_has_analyzer", - "plugin_has_collector", - "plugin_names_matching", - "plugins_with_analyzer", - "plugins_with_collector", - "registered_plugin_names", ] diff --git a/nodescraper/pluginrecipe/all_plugins.py b/nodescraper/pluginrecipe/all_plugins.py index 491ced5d..948c12e7 100644 --- a/nodescraper/pluginrecipe/all_plugins.py +++ b/nodescraper/pluginrecipe/all_plugins.py @@ -7,7 +7,7 @@ ############################################################################### from __future__ import annotations -from .discovery import registered_plugin_names +from .discovery import PluginDiscovery from .pluginrecipe import PluginRecipe @@ -21,4 +21,4 @@ def plugin_names(cls) -> tuple[str, ...]: Returns: tuple[str, ...]: Sorted names of all plugins in the plugin registry. """ - return registered_plugin_names() + return PluginDiscovery().registered_plugin_names() diff --git a/nodescraper/pluginrecipe/discovery.py b/nodescraper/pluginrecipe/discovery.py index be0eaa03..65d0186e 100644 --- a/nodescraper/pluginrecipe/discovery.py +++ b/nodescraper/pluginrecipe/discovery.py @@ -4,105 +4,150 @@ # # Copyright (c) 2025 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 __future__ import annotations - -from typing import Iterable - - -def registered_plugin_names() -> tuple[str, ...]: - """Return all plugin names known to :class:`~nodescraper.pluginregistry.PluginRegistry`. - - Returns: - tuple[str, ...]: Sorted registered plugin names. - """ - from nodescraper.pluginregistry import PluginRegistry - - return tuple(sorted(PluginRegistry().plugins)) - - -def plugin_names_matching(names: Iterable[str]) -> tuple[str, ...]: - """Return plugin names from ``names`` that are registered at runtime. - - Args: - names (Iterable[str]): Candidate plugin names to filter. - - Returns: - tuple[str, ...]: Sorted subset of ``names`` present in the plugin registry. - """ - available = set(registered_plugin_names()) - return tuple(sorted(name for name in names if name in available)) +from __future__ import annotations -def load_plugin_class(plugin_name: str) -> type | None: - """Return a registered plugin class by name. - - Args: - plugin_name (str): Registered plugin name. - - Returns: - type | None: Plugin class, or ``None`` if the name is not registered. - """ - from nodescraper.pluginregistry import PluginRegistry - - return PluginRegistry().plugins.get(plugin_name) - - -def plugin_has_collector(plugin_name: str) -> bool: - """Return whether the plugin exposes a collector task. - - Args: - plugin_name (str): Registered plugin name. +import threading +from typing import TYPE_CHECKING, Iterable - Returns: - bool: ``True`` when the plugin class defines ``COLLECTOR``. - """ - plugin_class = load_plugin_class(plugin_name) - if plugin_class is None: - return False - collectors = getattr(plugin_class, "get_collector_classes", None) - if callable(collectors): - return bool(collectors()) - collector = getattr(plugin_class, "COLLECTOR", None) - if collector is None: - return False - if isinstance(collector, (tuple, list)): - return len(collector) > 0 - return True - - -def plugin_has_analyzer(plugin_name: str) -> bool: - """Return whether the plugin exposes an analyzer task. - - Args: - plugin_name (str): Registered plugin name. - - Returns: - bool: ``True`` when the plugin class defines ``ANALYZER``. - """ - plugin_class = load_plugin_class(plugin_name) - return plugin_class is not None and getattr(plugin_class, "ANALYZER", None) is not None +from nodescraper.pluginregistry import PluginRegistry +if TYPE_CHECKING: + from nodescraper.interfaces import PluginInterface -def plugins_with_collector(plugin_names: Iterable[str]) -> tuple[str, ...]: - """Filter plugin names to those that define a collector. - Args: - plugin_names (Iterable[str]): Candidate plugin names. +class PluginDiscovery: + """Allows for the discovery of plugins and their capabilities. These external plugins must be + registered with the :class:`~nodescraper.pluginregistry.PluginRegistry` before they can be discovered. - Returns: - tuple[str, ...]: Sorted plugin names with a ``COLLECTOR`` implementation. + This class can use a cache to avoid repeated PluginRegistry lookups, which can be expensive. + If use_cache is False, it will query the PluginRegistry each time. """ - return tuple(sorted(name for name in plugin_names if plugin_has_collector(name))) - -def plugins_with_analyzer(plugin_names: Iterable[str]) -> tuple[str, ...]: - """Filter plugin names to those that define an analyzer. - - Args: - plugin_names (Iterable[str]): Candidate plugin names. - - Returns: - tuple[str, ...]: Sorted plugin names with an ``ANALYZER`` implementation. - """ - return tuple(sorted(name for name in plugin_names if plugin_has_analyzer(name))) + _plugin_cache: dict[str, type[PluginInterface]] | None = None + _cache_lock = threading.Lock() + COLLECTOR_ATTRIBUTE = "COLLECTOR" + ANALYZER_ATTRIBUTE = "ANALYZER" + + def __init__(self, use_cache: bool = True) -> None: + """Initialize the PluginDiscovery instance. + + Args: + use_cache: If True, cache plugin lookups to improve performance. Defaults to True. + """ + self._use_cache = use_cache + + def load_plugin_class(self, plugin_name: str) -> type | None: + if not self._use_cache: + return PluginRegistry().plugins.get(plugin_name) + + with PluginDiscovery._cache_lock: + if PluginDiscovery._plugin_cache is None: + PluginDiscovery._plugin_cache = PluginRegistry().plugins.copy() + return PluginDiscovery._plugin_cache.get(plugin_name) + + def plugin_has_collector(self, plugin_name: str) -> bool: + """Check if a plugin has a COLLECTOR attribute. + + Args: + plugin_name: The name of the plugin to check. + + Returns: + True if the plugin exists and has a COLLECTOR attribute, False otherwise. + """ + plugin_class = self.load_plugin_class(plugin_name) + return ( + plugin_class is not None + and getattr(plugin_class, self.COLLECTOR_ATTRIBUTE, None) is not None + ) + + def plugin_has_analyzer(self, plugin_name: str) -> bool: + """Check if a plugin has an ANALYZER attribute. + + Args: + plugin_name: The name of the plugin to check. + + Returns: + True if the plugin exists and has an ANALYZER attribute, False otherwise. + """ + plugin_class = self.load_plugin_class(plugin_name) + return ( + plugin_class is not None + and getattr(plugin_class, self.ANALYZER_ATTRIBUTE, None) is not None + ) + + def plugins_with_collector(self, plugin_names: Iterable[str]) -> tuple[str, ...]: + """Filter a list of plugin names to those that have a COLLECTOR attribute. + + Args: + plugin_names: An iterable of plugin names to filter. + + Returns: + A sorted tuple of plugin names that have a COLLECTOR attribute. + """ + return tuple(sorted(name for name in plugin_names if self.plugin_has_collector(name))) + + def plugins_with_analyzer(self, plugin_names: Iterable[str]) -> tuple[str, ...]: + """Filter a list of plugin names to those that have an ANALYZER attribute. + + Args: + plugin_names: An iterable of plugin names to filter. + + Returns: + A sorted tuple of plugin names that have an ANALYZER attribute. + """ + return tuple(sorted(name for name in plugin_names if self.plugin_has_analyzer(name))) + + @staticmethod + def clear_cache() -> None: + """Clears the plugin cache, forcing future lookups to query the PluginRegistry again. + + Thread-safe: Acquires the cache lock to ensure no other thread is accessing the cache. + """ + with PluginDiscovery._cache_lock: + PluginDiscovery._plugin_cache = None + + def registered_plugin_names(self) -> tuple[str, ...]: + """Return all plugin names known to :class:`~nodescraper.pluginregistry.PluginRegistry`. + + Returns: + tuple[str, ...]: Sorted registered plugin names. + """ + if not self._use_cache: + return tuple(sorted(PluginRegistry().plugins.keys())) + + with PluginDiscovery._cache_lock: + if PluginDiscovery._plugin_cache is None: + PluginDiscovery._plugin_cache = PluginRegistry().plugins + return tuple(sorted(PluginDiscovery._plugin_cache.keys())) + + def plugin_names_matching(self, names: Iterable[str]) -> tuple[str, ...]: + """Return plugin names from ``names`` that are registered at runtime. + + Args: + names (Iterable[str]): Candidate plugin names to filter. + + Returns: + tuple[str, ...]: Sorted subset of ``names`` present in the plugin registry. + """ + available = set(self.registered_plugin_names()) + return tuple(sorted(name for name in names if name in available)) diff --git a/nodescraper/pluginrecipe/node_status.py b/nodescraper/pluginrecipe/node_status.py index 42be8d01..d0237758 100644 --- a/nodescraper/pluginrecipe/node_status.py +++ b/nodescraper/pluginrecipe/node_status.py @@ -7,7 +7,7 @@ ############################################################################### from __future__ import annotations -from .discovery import plugin_names_matching +from .discovery import PluginDiscovery from .pluginrecipe import PluginRecipe _NODE_STATUS_PLUGINS = ( @@ -35,4 +35,4 @@ def plugin_names(cls) -> tuple[str, ...]: Returns: tuple[str, ...]: Sorted node-status plugin names registered in the plugin registry. """ - return plugin_names_matching(_NODE_STATUS_PLUGINS) + return PluginDiscovery().plugin_names_matching(_NODE_STATUS_PLUGINS) diff --git a/nodescraper/pluginrecipe/pluginrecipe.py b/nodescraper/pluginrecipe/pluginrecipe.py index e9b5a3ba..5c9bc34e 100644 --- a/nodescraper/pluginrecipe/pluginrecipe.py +++ b/nodescraper/pluginrecipe/pluginrecipe.py @@ -5,27 +5,29 @@ # Copyright (c) 2025 Advanced Micro Devices, Inc. # ############################################################################### + from __future__ import annotations import abc -from dataclasses import dataclass from typing import Any, Iterable +from pydantic import BaseModel, ConfigDict + from nodescraper.models import PluginConfig +from nodescraper.pluginrecipe.discovery import PluginDiscovery -@dataclass(frozen=True) -class PluginRunFlags: - """Collection/analysis toggles passed to nodescraper ``DataPlugin.run``.""" +class PluginRunFlags(BaseModel): + model_config = ConfigDict(frozen=True) collection: bool = True analysis: bool = True def as_config(self) -> dict[str, bool]: - """Return nodescraper per-plugin config fields. + """Return collection and analysis fields for one plugin entry. Returns: - dict[str, bool]: ``collection`` and ``analysis`` entries for a plugin config. + dict[str, bool]: ``collection`` and ``analysis`` entries for a plugin entry. """ return {"collection": self.collection, "analysis": self.analysis} @@ -71,7 +73,7 @@ def description(cls) -> str: @classmethod def flags_for_plugin(cls, plugin_name: str) -> PluginRunFlags: - """Return collection/analysis flags for one plugin. + """Return collection and analysis flags for the given plugin. Args: plugin_name (str): Registered plugin name. @@ -91,18 +93,17 @@ def extra_plugin_args(cls, plugin_name: str) -> dict[str, Any]: Returns: dict[str, Any]: Extra plugin config kwargs merged into the entry dict. """ - del plugin_name + _plugin_name = plugin_name # Avoid unused variable warning return {} @classmethod def plugin_entry(cls, plugin_name: str) -> dict[str, Any]: - """Build the nodescraper plugin config entry for one plugin. - + """Build the per-plugin config entry for one plugin.plugins: type[PluginInterface] | None Args: plugin_name (str): Registered plugin name. Returns: - dict[str, Any]: Per-plugin config passed to node-scraper. + dict[str, Any]: Per-plugin config for a single plugin. """ entry: dict[str, Any] = dict(cls.flags_for_plugin(plugin_name).as_config()) entry.update(cls.extra_plugin_args(plugin_name)) @@ -110,11 +111,10 @@ def plugin_entry(cls, plugin_name: str) -> dict[str, Any]: @classmethod def plugin_config(cls) -> PluginConfig: - """Build a node-scraper plugin config at runtime. + """Build the full plugin config for this recipe. Returns: - PluginConfig: Plugin config with ``name``, ``desc``, ``global_args``, - ``plugins``, and ``result_collators`` fields. + PluginConfig: Config with recipe name, description, and per-plugin entries. """ return PluginConfig( name=cls.name(), @@ -138,11 +138,10 @@ def filter_plugin_names(cls, names: Iterable[str]) -> tuple[str, ...]: names (Iterable[str]): Candidate plugin names. Returns: - tuple[str, ...]: Sorted names with a ``COLLECTOR`` implementation. + tuple[str, ...]: Sorted names that implement collection. """ - from .discovery import plugins_with_collector - return plugins_with_collector(names) + return PluginDiscovery().plugins_with_collector(names) class AnalyzerOnlyPluginRecipe(PluginRecipe): @@ -158,11 +157,10 @@ def filter_plugin_names(cls, names: Iterable[str]) -> tuple[str, ...]: names (Iterable[str]): Candidate plugin names. Returns: - tuple[str, ...]: Sorted names with an ``ANALYZER`` implementation. + tuple[str, ...]: Sorted names that implement analysis. """ - from .discovery import plugins_with_analyzer - return plugins_with_analyzer(names) + return PluginDiscovery().plugins_with_analyzer(names) def merge_plugin_configs(*configs: PluginConfig | dict[str, Any]) -> PluginConfig: diff --git a/nodescraper/pluginregistry.py b/nodescraper/pluginregistry.py index 5abc2f84..cca5bbf2 100644 --- a/nodescraper/pluginregistry.py +++ b/nodescraper/pluginregistry.py @@ -27,9 +27,16 @@ import importlib.metadata import inspect import pkgutil +import threading import types from typing import Iterable, Optional +# Python 3.9 compatibility: EntryPoints type was added in 3.10 +try: + from importlib.metadata import EntryPoints # type: ignore[attr-defined] +except ImportError: + EntryPoints = Iterable # type: ignore[misc, assignment] + import nodescraper.connection as internal_connections import nodescraper.plugins as internal_plugins import nodescraper.resultcollators as internal_collators @@ -39,8 +46,32 @@ PluginResultCollator, ) +# Entry point group names +ENTRY_POINT_PLUGINS = "nodescraper.plugins" +ENTRY_POINT_CONNECTION_MANAGERS = "nodescraper.connection_managers" + class PluginRegistry: + """This class dynamically loads plugins. Internal plugins are loaded by default using + the ``nodescraper.plugins``, ``nodescraper.connection``, and ``nodescraper.resultcollators`` packages. + A caller of node-scraper can also specify entry points for plugins and connection managers. The + user could also define entrypoints which ``nodescraper.connection_managers`` or ``nodescraper.plugins`` + entry point groups. The PluginRegistry will load these plugins and connection managers as well. + """ + + # Class-level caches for entry points (shared across all instances) + _entry_point_plugins_cache: Optional[dict[str, type]] = None + _entry_point_connection_managers_cache: Optional[dict[str, type]] = None + # Cache for loaded modules to avoid re-importing + _module_cache: dict[str, types.ModuleType] = {} + # Cache for entry points by group name + _entry_points_cache: dict[str, EntryPoints] = {} + + # Global cache control switch + _use_cache: bool = True + + # Single lock for all cache operations to ensure atomicity + _cache_lock = threading.RLock() def __init__( self, @@ -59,7 +90,11 @@ def __init__( ``nodescraper.connection_managers`` entry-point group. Defaults to True. """ if load_internal_plugins: - self.plugin_pkg = [internal_plugins, internal_connections, internal_collators] + self.plugin_pkg = [ + internal_plugins, + internal_connections, + internal_collators, + ] else: self.plugin_pkg = [] @@ -105,12 +140,22 @@ def load_plugins( def _recurse_pkg(pkg: types.ModuleType, base_class: type) -> None: for _, module_name, ispkg in pkgutil.iter_modules(pkg.__path__, pkg.__name__ + "."): - module = importlib.import_module(module_name) + # Check module cache first with thread safety (if caching enabled) + if PluginRegistry._use_cache: + with PluginRegistry._cache_lock: + if module_name in PluginRegistry._module_cache: + module = PluginRegistry._module_cache[module_name] + else: + module = importlib.import_module(module_name) + PluginRegistry._module_cache[module_name] = module + else: + module = importlib.import_module(module_name) + for _, plugin in inspect.getmembers( module, - lambda x: inspect.isclass(x) - and issubclass(x, base_class) - and not inspect.isabstract(x), + lambda x: PluginRegistry._valid_sub_class_check( + in_cls=x, base_class=base_class + ), ): if hasattr(plugin, "is_valid") and not plugin.is_valid(): continue @@ -122,6 +167,45 @@ def _recurse_pkg(pkg: types.ModuleType, base_class: type) -> None: _recurse_pkg(pkg, base_class) return registry + @staticmethod + def _valid_sub_class_check(in_cls: type, base_class: type) -> bool: + """Check if a class is a subclass of the specified base class. + + Args: + cls (type): The class to check. + base_class (type): The base class to check against. + + Returns: + bool: True if cls is a subclass of base_class, False otherwise. + """ + if not inspect.isclass(in_cls): + return False + try: + return issubclass(in_cls, base_class) and not inspect.isabstract(in_cls) + except TypeError: + return False + + @staticmethod + def _load_connection_managers_uncached() -> dict[str, type]: + """Internal: Load connection managers without caching logic.""" + managers: dict[str, type] = {} + eps: Iterable = PluginRegistry.load_entry_points(ENTRY_POINT_CONNECTION_MANAGERS) + + for entry_point in eps: + loaded = entry_point.load() # type: ignore[attr-defined, union-attr] + if not PluginRegistry._valid_sub_class_check( + in_cls=loaded, base_class=ConnectionManager + ): + continue + if hasattr(loaded, "is_valid") and not loaded.is_valid(): + continue + cls = loaded + managers[cls.__name__] = cls + ep_name = getattr(entry_point, "name", None) + if ep_name and ep_name != cls.__name__: + managers[ep_name] = cls + return managers.copy() + @staticmethod def load_connection_managers_from_entry_points() -> dict[str, type]: """Load ConnectionManager subclasses from ``nodescraper.connection_managers`` entry points. @@ -132,41 +216,77 @@ def load_connection_managers_from_entry_points() -> dict[str, type]: Returns: dict[str, type]: Map of lookup key to connection manager class. """ - managers: dict[str, type] = {} + # Return cached result if caching is enabled and cache exists + if ( + PluginRegistry._use_cache + and PluginRegistry._entry_point_connection_managers_cache is not None + ): + return PluginRegistry._entry_point_connection_managers_cache + + # If caching disabled, skip lock and always reload + if not PluginRegistry._use_cache: + return PluginRegistry._load_connection_managers_uncached() + + with PluginRegistry._cache_lock: + # Check again inside the lock to prevent duplicate work + if PluginRegistry._entry_point_connection_managers_cache is not None: + return PluginRegistry._entry_point_connection_managers_cache + + managers = PluginRegistry._load_connection_managers_uncached() + + # Cache the result + PluginRegistry._entry_point_connection_managers_cache = managers + return managers.copy() + @staticmethod + def _load_entry_points_uncached(entry_point: str) -> EntryPoints: + """Internal: Load entry points without caching logic.""" try: - eps: Iterable - try: - eps = importlib.metadata.entry_points( # type: ignore[call-arg] - group="nodescraper.connection_managers" - ) - except TypeError: - all_eps = importlib.metadata.entry_points() # type: ignore[assignment] - eps = all_eps.get("nodescraper.connection_managers", []) # type: ignore[assignment, attr-defined, arg-type] - - for entry_point in eps: - try: - loaded = entry_point.load() # type: ignore[attr-defined, union-attr] - if not ( - inspect.isclass(loaded) - and issubclass(loaded, ConnectionManager) - and not inspect.isabstract(loaded) - ): - continue - if hasattr(loaded, "is_valid") and not loaded.is_valid(): - continue - cls = loaded - managers[cls.__name__] = cls - ep_name = getattr(entry_point, "name", None) - if ep_name and ep_name != cls.__name__: - managers[ep_name] = cls - except Exception: - pass + eps: EntryPoints = importlib.metadata.entry_points(group=entry_point) # type: ignore[call-arg] + except TypeError: + all_eps: EntryPoints = importlib.metadata.entry_points() # type: ignore[assignment] + eps = all_eps.get(entry_point, []) # type: ignore[assignment, attr-defined, arg-type] + return eps + + @staticmethod + def load_entry_points(entry_point: str) -> EntryPoints: + # Return cached result if caching is enabled and cache exists + if PluginRegistry._use_cache and entry_point in PluginRegistry._entry_points_cache: + return PluginRegistry._entry_points_cache[entry_point] + + # If caching disabled, skip lock and always reload + if not PluginRegistry._use_cache: + return PluginRegistry._load_entry_points_uncached(entry_point) + + with PluginRegistry._cache_lock: + # Check again inside the lock to prevent duplicate work + if entry_point in PluginRegistry._entry_points_cache: + return PluginRegistry._entry_points_cache[entry_point] + + eps = PluginRegistry._load_entry_points_uncached(entry_point) + + # Cache the result + PluginRegistry._entry_points_cache[entry_point] = eps + return eps + + @staticmethod + def _load_plugins_uncached() -> dict[str, type]: + """Internal: Load plugins without caching logic.""" + plugins = {} + eps: Iterable = PluginRegistry.load_entry_points(ENTRY_POINT_PLUGINS) + + for entry_point in eps: + plugin_class = entry_point.load() # type: ignore[attr-defined, union-attr] - except Exception: - pass + if not PluginRegistry._valid_sub_class_check( + in_cls=plugin_class, base_class=PluginInterface + ): + continue + if hasattr(plugin_class, "is_valid") and not plugin_class.is_valid(): + continue - return managers + plugins[plugin_class.__name__] = plugin_class + return plugins @staticmethod def load_plugins_from_entry_points() -> dict[str, type]: @@ -175,35 +295,33 @@ def load_plugins_from_entry_points() -> dict[str, type]: Returns: dict[str, type]: A dictionary mapping plugin names to their classes. """ - plugins = {} + # Return cached result if caching is enabled and cache exists + if PluginRegistry._use_cache and PluginRegistry._entry_point_plugins_cache is not None: + return PluginRegistry._entry_point_plugins_cache.copy() - try: - eps: Iterable - # Python 3.10+ supports group parameter - try: - eps = importlib.metadata.entry_points(group="nodescraper.plugins") # type: ignore[call-arg] - except TypeError: - # Python 3.9 - entry_points() returns dict-like object - all_eps = importlib.metadata.entry_points() # type: ignore[assignment] - eps = all_eps.get("nodescraper.plugins", []) # type: ignore[assignment, attr-defined, arg-type] - - for entry_point in eps: - try: - plugin_class = entry_point.load() # type: ignore[attr-defined, union-attr] - - if ( - inspect.isclass(plugin_class) - and issubclass(plugin_class, PluginInterface) - and not inspect.isabstract(plugin_class) - ): - if hasattr(plugin_class, "is_valid") and not plugin_class.is_valid(): - continue - - plugins[plugin_class.__name__] = plugin_class - except Exception: - pass - - except Exception: - pass + # If caching disabled, skip lock and always reload + if not PluginRegistry._use_cache: + return PluginRegistry._load_plugins_uncached() - return plugins + with PluginRegistry._cache_lock: + # Check again inside the lock to prevent duplicate work + if PluginRegistry._entry_point_plugins_cache is not None: + return PluginRegistry._entry_point_plugins_cache.copy() + + plugins = PluginRegistry._load_plugins_uncached() + + # Cache the result - no need to copy before caching + PluginRegistry._entry_point_plugins_cache = plugins + return plugins.copy() + + @classmethod + def clear_caches(cls) -> None: + """Clear all caches. Useful for testing or when plugins are dynamically installed. + + Thread-safe: Acquires all locks to ensure no other thread is accessing caches. + """ + with cls._cache_lock: + cls._entry_point_plugins_cache = None + cls._entry_point_connection_managers_cache = None + cls._module_cache.clear() + cls._entry_points_cache.clear() diff --git a/nodescraper/plugins/inband/dmesg/analyzer_args.py b/nodescraper/plugins/inband/dmesg/analyzer_args.py index b68aec27..ca2294f6 100644 --- a/nodescraper/plugins/inband/dmesg/analyzer_args.py +++ b/nodescraper/plugins/inband/dmesg/analyzer_args.py @@ -27,6 +27,7 @@ from pydantic import Field +from nodescraper.base.match_ignore import IgnoreMatchRuleSpec from nodescraper.base.regexanalyzer import ErrorRegex from nodescraper.models import TimeRangeAnalysisArgs @@ -62,3 +63,19 @@ class DmesgAnalyzerArgs(TimeRangeAnalysisArgs): "or 'NO_CHANGE' to leave the priority unchanged." ), ) + mce_threshold: Optional[int] = Field( + default=None, + description=( + "When set, raise ERROR if correctable MCE/RAS error count for any component " + "(CPU, GPU BDF/block, etc.) reaches or exceeds this value." + ), + ) + ignore_match_rules: Optional[list[IgnoreMatchRuleSpec]] = Field( + default=None, + description=( + "Rules that skip regex matches during analysis. Each rule may use line_regex, " + "match_regex, message, and/or mce_banks. Within a rule all specified fields must " + "match; any matching rule suppresses the hit. mce_banks accepts bank ids and " + 'inclusive ranges such as "60-63".' + ), + ) diff --git a/nodescraper/plugins/inband/dmesg/dmesg_analyzer.py b/nodescraper/plugins/inband/dmesg/dmesg_analyzer.py index 68e77702..65a613f9 100644 --- a/nodescraper/plugins/inband/dmesg/dmesg_analyzer.py +++ b/nodescraper/plugins/inband/dmesg/dmesg_analyzer.py @@ -27,6 +27,7 @@ import re from typing import Optional +from nodescraper.base.match_ignore import parse_ignore_match_rules from nodescraper.base.regexanalyzer import ErrorRegex, RegexAnalyzer from nodescraper.connection.inband import TextFileArtifact from nodescraper.enums import EventCategory, EventPriority @@ -34,6 +35,7 @@ from .analyzer_args import DmesgAnalyzerArgs from .dmesgdata import DmesgData +from .mce_utils import parse_correctable_mce_counts, parse_uncorrectable_mce_counts class DmesgAnalyzer(RegexAnalyzer[DmesgData, DmesgAnalyzerArgs]): @@ -640,6 +642,37 @@ def _resolve_priority( return current_priority # if no rules are matched, keep the current priority + def _check_mce_threshold( + self, + dmesg_content: str, + threshold: int, + ignore_mce_banks: frozenset[int], + ) -> None: + """Raise ERROR events when correctable MCE counts per component reach the threshold.""" + correctable_counts = parse_correctable_mce_counts( + dmesg_content, ignore_banks=ignore_mce_banks + ) + uncorrectable_counts = parse_uncorrectable_mce_counts( + dmesg_content, ignore_banks=ignore_mce_banks + ) + + for part, count in sorted(correctable_counts.items()): + if count >= threshold: + self._log_event( + category=EventCategory.RAS, + description=( + f"{part} has {count} correctable MCE(s), " f"mce_threshold={threshold}" + ), + priority=EventPriority.ERROR, + data={ + "part": part, + "correctable_mce_count": count, + "uncorrectable_mce_count": uncorrectable_counts.get(part, 0), + "mce_threshold": threshold, + }, + console_log=True, + ) + def analyze_data( self, data: DmesgData, @@ -680,12 +713,15 @@ def analyze_data( else: dmesg_content = data.dmesg_content + ignore_match_rules, ignore_mce_banks = parse_ignore_match_rules(args.ignore_match_rules) + known_err_events = self.check_all_regexes( content=dmesg_content, source="dmesg", error_regex=final_error_regex, num_timestamps=args.num_timestamps, interval_to_collapse_event=args.interval_to_collapse_event, + ignore_match_rules=ignore_match_rules, ) if args.exclude_category: known_err_events = [ @@ -715,6 +751,7 @@ def analyze_data( error_regex=unknown_dmesg_error_regexes, num_timestamps=args.num_timestamps, interval_to_collapse_event=args.interval_to_collapse_event, + ignore_match_rules=ignore_match_rules, ) for err_event in err_events: @@ -722,4 +759,7 @@ def analyze_data( if not self._is_known_error(known_err_events, match_content, final_error_regex): self.result.events.append(err_event) + if args.mce_threshold is not None: + self._check_mce_threshold(dmesg_content, args.mce_threshold, ignore_mce_banks) + return self.result diff --git a/nodescraper/plugins/inband/dmesg/mce_utils.py b/nodescraper/plugins/inband/dmesg/mce_utils.py new file mode 100644 index 00000000..4519e6e2 --- /dev/null +++ b/nodescraper/plugins/inband/dmesg/mce_utils.py @@ -0,0 +1,176 @@ +############################################################################### +# +# 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 FrozenSet, Optional + +from nodescraper.base.match_ignore import extract_mce_bank_from_line + +_CORRECTABLE_SUMMARY_RE = re.compile( + r"(?P\d+)\s+correctable hardware errors detected in total in (?P\w+) block" + r"(?:\s+on\s+(?PCPU\d+))?", + re.IGNORECASE, +) + +_UNCORRECTABLE_SUMMARY_RE = re.compile( + r"(?P\d+)\s+uncorrectable hardware errors detected in (?P\w+) block", + re.IGNORECASE, +) + +_GPU_CORRECTABLE_RE = re.compile( + r"amdgpu\s+(?P[\w:.]+):.*?(?P\d+)\s+correctable hardware errors detected in total in " + r"(?P\w+) block", + re.IGNORECASE, +) + +_GPU_UNCORRECTABLE_RE = re.compile( + r"amdgpu\s+(?P[\w:.]+):.*?(?P\d+)\s+uncorrectable hardware errors detected in " + r"(?P\w+) block", + re.IGNORECASE, +) + +_MCE_CE_STATUS_RE = re.compile( + r"\[Hardware Error\]:.*?(?PCPU\d+).*?MC\d+_STATUS\[[^\]]*\|CE\|[^\]]*\]", + re.IGNORECASE, +) + +_MCE_UC_STATUS_RE = re.compile( + r"\[Hardware Error\]:.*?(?PCPU\d+).*?MC\d+_STATUS\[[^\]]*\|UC\|[^\]]*\]", + re.IGNORECASE, +) + + +def _add_count(counts: dict[str, int], part: str, amount: int) -> None: + counts[part] = counts.get(part, 0) + amount + + +def _part_label( + *, + cpu: Optional[str] = None, + block: Optional[str] = None, + bdf: Optional[str] = None, + gpu_index: Optional[int] = None, +) -> str: + if bdf is not None: + block_suffix = f"/{block}" if block else "" + if gpu_index is not None: + return f"GPU{gpu_index}{block_suffix}" + return f"GPU {bdf}{block_suffix}" + if cpu and block: + return f"{cpu}/{block}" + if cpu: + return cpu + if block: + return block + return "unknown" + + +def _gpu_index_for_bdf(bdf: str, bdf_order: list[str]) -> int: + if bdf not in bdf_order: + bdf_order.append(bdf) + return bdf_order.index(bdf) + + +def parse_correctable_mce_counts( + content: str, + ignore_banks: Optional[FrozenSet[int]] = None, +) -> dict[str, int]: + """Count correctable MCE / RAS hardware errors per component from dmesg text. + + Handles summary lines (for example ``mce: 3 correctable ... on CPU1``), + amdgpu block summaries, and per-event ``MCn_STATUS[|CE|]`` hardware error lines. + """ + counts: dict[str, int] = {} + gpu_bdf_order: list[str] = [] + ignored = ignore_banks or frozenset() + + for line in content.splitlines(): + gpu_match = _GPU_CORRECTABLE_RE.search(line) + if gpu_match: + bdf = gpu_match.group("bdf") + part = _part_label( + bdf=bdf, + block=gpu_match.group("block"), + gpu_index=_gpu_index_for_bdf(bdf, gpu_bdf_order), + ) + _add_count(counts, part, int(gpu_match.group("count"))) + continue + + summary_match = _CORRECTABLE_SUMMARY_RE.search(line) + if summary_match: + part = _part_label( + cpu=summary_match.group("cpu"), + block=summary_match.group("block"), + ) + _add_count(counts, part, int(summary_match.group("count"))) + continue + + status_match = _MCE_CE_STATUS_RE.search(line) + if status_match: + bank = extract_mce_bank_from_line(line) + if bank is not None and bank in ignored: + continue + part = status_match.group("cpu") if status_match.group("cpu") else "unknown" + _add_count(counts, part, 1) + + return counts + + +def parse_uncorrectable_mce_counts( + content: str, + ignore_banks: Optional[FrozenSet[int]] = None, +) -> dict[str, int]: + """Count uncorrectable MCE / RAS hardware errors per component from dmesg text.""" + counts: dict[str, int] = {} + gpu_bdf_order: list[str] = [] + ignored = ignore_banks or frozenset() + + for line in content.splitlines(): + gpu_match = _GPU_UNCORRECTABLE_RE.search(line) + if gpu_match: + bdf = gpu_match.group("bdf") + part = _part_label( + bdf=bdf, + block=gpu_match.group("block"), + gpu_index=_gpu_index_for_bdf(bdf, gpu_bdf_order), + ) + _add_count(counts, part, int(gpu_match.group("count"))) + continue + + summary_match = _UNCORRECTABLE_SUMMARY_RE.search(line) + if summary_match: + part = _part_label(block=summary_match.group("block")) + _add_count(counts, part, int(summary_match.group("count"))) + continue + + status_match = _MCE_UC_STATUS_RE.search(line) + if status_match: + bank = extract_mce_bank_from_line(line) + if bank is not None and bank in ignored: + continue + part = status_match.group("cpu") if status_match.group("cpu") else "unknown" + _add_count(counts, part, 1) + + return counts diff --git a/nodescraper/plugins/inband/memory/memory_analyzer.py b/nodescraper/plugins/inband/memory/memory_analyzer.py index f9c941e4..f0de6013 100644 --- a/nodescraper/plugins/inband/memory/memory_analyzer.py +++ b/nodescraper/plugins/inband/memory/memory_analyzer.py @@ -57,9 +57,9 @@ def analyze_data( def _bytes_to_gb(n: float) -> float: return n / (1024**3) - free_memory = convert_to_bytes(data.mem_free) + available_memory = convert_to_bytes(data.mem_available or data.mem_free) total_memory = convert_to_bytes(data.mem_total) - used_memory = total_memory - free_memory + used_memory = total_memory - available_memory threshold_bytes = convert_to_bytes(args.memory_threshold) @@ -93,7 +93,8 @@ def _bytes_to_gb(n: float) -> float: "used_memory": used_memory, "max_allowed_used_mem": max_allowed_used_mem, "total_memory": total_memory, - "free_memory": free_memory, + "available_memory": available_memory, + "mem_free": convert_to_bytes(data.mem_free), }, ) diff --git a/nodescraper/plugins/inband/memory/memory_collector.py b/nodescraper/plugins/inband/memory/memory_collector.py index 43dd39ad..35c279d9 100644 --- a/nodescraper/plugins/inband/memory/memory_collector.py +++ b/nodescraper/plugins/inband/memory/memory_collector.py @@ -60,7 +60,7 @@ def collect_data(self, args=None) -> tuple[TaskResult, Optional[MemoryDataModel] Returns: tuple[TaskResult, Optional[MemoryDataModel]]: tuple containing the task result and memory data model or None if data is not available. """ - mem_free, mem_total = None, None + mem_free, mem_total, mem_available = None, None, None if self.system_info.os_family == OSFamily.WINDOWS: os_memory_cmd = self._run_sut_cmd(self.CMD_WINDOWS) if os_memory_cmd.exit_code == 0: @@ -71,9 +71,12 @@ def collect_data(self, args=None) -> tuple[TaskResult, Optional[MemoryDataModel] else: os_memory_cmd = self._run_sut_cmd(self.CMD) if os_memory_cmd.exit_code == 0: - pattern = r"Mem:\s+(\d\.?\d*\w+)\s+\d\.?\d*\w+\s+(\d\.?\d*\w+)" - mem_free = re.search(pattern, os_memory_cmd.stdout).group(2) - mem_total = re.search(pattern, os_memory_cmd.stdout).group(1) + pattern = r"Mem:\s+(\S+)\s+\S+\s+(\S+)(?:\s+\S+\s+\S+\s+(\S+))?" + match = re.search(pattern, os_memory_cmd.stdout) + if match: + mem_total = match.group(1) + mem_free = match.group(2) + mem_available = match.group(3) if os_memory_cmd.exit_code != 0: self._log_event( @@ -163,6 +166,7 @@ def collect_data(self, args=None) -> tuple[TaskResult, Optional[MemoryDataModel] mem_data = MemoryDataModel( mem_free=mem_free, mem_total=mem_total, + mem_available=mem_available, lsmem_data=lsmem_data, numa_topology=numa_topology, ) @@ -172,7 +176,10 @@ def collect_data(self, args=None) -> tuple[TaskResult, Optional[MemoryDataModel] data=mem_data.model_dump(), priority=EventPriority.INFO, ) - self.result.message = f"Memory: mem_free={mem_free}, mem_total={mem_total}" + if mem_available: + self.result.message = f"Memory: mem_total={mem_total}, mem_available={mem_available}, mem_free={mem_free}" + else: + self.result.message = f"Memory: mem_free={mem_free}, mem_total={mem_total}" self.result.status = ExecutionStatus.OK else: mem_data = None diff --git a/nodescraper/plugins/inband/memory/memorydata.py b/nodescraper/plugins/inband/memory/memorydata.py index 2687beaf..9e9209bb 100644 --- a/nodescraper/plugins/inband/memory/memorydata.py +++ b/nodescraper/plugins/inband/memory/memorydata.py @@ -86,5 +86,6 @@ class MemoryDataModel(DataModel): mem_free: str mem_total: str + mem_available: Optional[str] = None lsmem_data: Optional[LsmemData] = None numa_topology: Optional[NumaTopology] = None 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 ``