diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..0f0f7ca8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# node-scraper line ending enforcement + +# Default: auto-detect text, normalize to LF on checkout for all text files. +* text=auto eol=lf + +# Explicit binary so git never applies EOL conversion or line-diffing. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.zip binary +*.gz binary +*.whl binary diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2ee95c0c..0f8773e5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,72 +1,72 @@ -name: Bug report -description: Report a reproducible problem with Node Scraper -title: "[Bug]: " -labels: ["bug"] -body: - - type: markdown - attributes: - value: | - Thanks for taking the time to report a bug. Please include enough detail to reproduce the issue. - - type: textarea - id: summary - attributes: - label: Summary - description: What happened and what did you expect to happen? - placeholder: Clear and concise description of the bug. - validations: - required: true - - type: textarea - id: steps - attributes: - label: Steps to reproduce - description: Minimal, concrete steps to reproduce the issue. - placeholder: | - 1. ... - 2. ... - 3. ... - validations: - required: true - - type: textarea - id: logs - attributes: - label: CLI output / logs - description: Paste relevant output (redact secrets/tokens/hosts as needed). - render: shell - validations: - required: false - - type: input - id: version - attributes: - label: Version - description: Output of `node-scraper --version` (or the git commit hash). - placeholder: "e.g. 0.6.1" - validations: - required: false - - type: dropdown - id: install_method - attributes: - label: Install method - options: - - PyPI (pip install amd-node-scraper) - - Editable install from source (pip install -e .) - - Other - validations: - required: false - - type: input - id: python - attributes: - label: Python version - placeholder: "e.g. 3.11.8" - validations: - required: false - - type: textarea - id: environment - attributes: - label: Environment - description: OS, target system type (LOCAL/REMOTE), and anything else relevant. - placeholder: | - OS: - Target: - Notes: - validations: - required: false +name: Bug report +description: Report a reproducible problem with Node Scraper +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug. Please include enough detail to reproduce the issue. + - type: textarea + id: summary + attributes: + label: Summary + description: What happened and what did you expect to happen? + placeholder: Clear and concise description of the bug. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Minimal, concrete steps to reproduce the issue. + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: textarea + id: logs + attributes: + label: CLI output / logs + description: Paste relevant output (redact secrets/tokens/hosts as needed). + render: shell + validations: + required: false + - type: input + id: version + attributes: + label: Version + description: Output of `node-scraper --version` (or the git commit hash). + placeholder: "e.g. 0.6.1" + validations: + required: false + - type: dropdown + id: install_method + attributes: + label: Install method + options: + - PyPI (pip install amd-node-scraper) + - Editable install from source (pip install -e .) + - Other + validations: + required: false + - type: input + id: python + attributes: + label: Python version + placeholder: "e.g. 3.11.8" + validations: + required: false + - type: textarea + id: environment + attributes: + label: Environment + description: OS, target system type (LOCAL/REMOTE), and anything else relevant. + placeholder: | + OS: + Target: + Notes: + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 7a048e76..f83f075c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ -blank_issues_enabled: false -contact_links: - - name: Questions - url: https://github.com/amd/node-scraper/issues - about: If you're not sure it's a bug/feature request yet, start with an issue and add details. - - name: Security reports - url: https://github.com/amd/node-scraper/security/policy - about: Please report security issues privately (see SECURITY.md). +blank_issues_enabled: false +contact_links: + - name: Questions + url: https://github.com/amd/node-scraper/issues + about: If you're not sure it's a bug/feature request yet, start with an issue and add details. + - name: Security reports + url: https://github.com/amd/node-scraper/security/policy + about: Please report security issues privately (see SECURITY.md). diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index b9249856..14d3e688 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,38 +1,38 @@ -name: Feature request -description: Suggest an enhancement or new capability -title: "[Feature]: " -labels: ["enhancement"] -body: - - type: markdown - attributes: - value: | - Thanks for the suggestion. Please describe the problem you're trying to solve and what success looks like. - - type: textarea - id: problem - attributes: - label: Problem / motivation - description: What problem are you trying to solve? - placeholder: "I want to..." - validations: - required: true - - type: textarea - id: proposal - attributes: - label: Proposed solution - description: What would you like Node Scraper to do? - validations: - required: true - - type: textarea - id: alternatives - attributes: - label: Alternatives considered - description: What else have you tried or considered? - validations: - required: false - - type: textarea - id: scope - attributes: - label: Scope / impact - description: What plugins/OSes/environments would this affect? - validations: - required: false +name: Feature request +description: Suggest an enhancement or new capability +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for the suggestion. Please describe the problem you're trying to solve and what success looks like. + - type: textarea + id: problem + attributes: + label: Problem / motivation + description: What problem are you trying to solve? + placeholder: "I want to..." + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What would you like Node Scraper to do? + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: What else have you tried or considered? + validations: + required: false + - type: textarea + id: scope + attributes: + label: Scope / impact + description: What plugins/OSes/environments would this affect? + validations: + required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 269ee465..dabd6d0e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,12 +1,12 @@ -## Summary -- - -## Test plan -- [ ] `pytest test/unit` -- [ ] `pytest test/functional` (if applicable) -- [ ] `pre-commit run --all-files` - -## Checklist -- [ ] Added/updated tests (or explained why not) -- [ ] Updated docs/README if behavior changed -- [ ] No secrets or credentials committed +## Summary +- + +## Test plan +- [ ] `pytest test/unit` +- [ ] `pytest test/functional` (if applicable) +- [ ] `pre-commit run --all-files` + +## Checklist +- [ ] Added/updated tests (or explained why not) +- [ ] Updated docs/README if behavior changed +- [ ] No secrets or credentials committed diff --git a/.github/workflows/release-trusted-publisher.yml b/.github/workflows/release-trusted-publisher.yml index 014df7c5..8be52141 100644 --- a/.github/workflows/release-trusted-publisher.yml +++ b/.github/workflows/release-trusted-publisher.yml @@ -1,124 +1,124 @@ -name: Release (Trusted Publisher) - -permissions: - contents: write # Required for creating releases and pushing tags - id-token: write # Required for PyPI Trusted Publishing - -on: - workflow_dispatch: - inputs: - release_type: - description: 'Release type (major, minor, patch)' - required: true - type: choice - options: - - patch - - minor - - major - -jobs: - release: - runs-on: ubuntu-latest - # Only allow this workflow to run on main branch - if: github.ref == 'refs/heads/main' - - steps: - - name: Checkout code - uses: actions/checkout@v5 - with: - fetch-depth: 0 # Fetch all history and tags - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Fetch all tags - run: | - git fetch --tags --force - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.9' - - - name: Configure Git - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - - name: Calculate next version - id: next_version - shell: bash - run: | - # Get the highest semantic version tag (not most recent, but highest version number) - LATEST_TAG=$(git tag -l 'v*' | sort -V | tail -n1) - if [ -z "$LATEST_TAG" ]; then - LATEST_TAG="v0.0.0" - fi - echo "Latest tag: $LATEST_TAG" - - # Remove 'v' prefix and split into components - VERSION=${LATEST_TAG#v} - IFS='.' read -r -a VERSION_PARTS <<< "$VERSION" - MAJOR="${VERSION_PARTS[0]:-0}" - MINOR="${VERSION_PARTS[1]:-0}" - PATCH="${VERSION_PARTS[2]:-0}" - - echo "Current version: $MAJOR.$MINOR.$PATCH" - - # Increment based on release type - case "${{ github.event.inputs.release_type }}" in - major) - MAJOR=$((MAJOR + 1)) - MINOR=0 - PATCH=0 - ;; - minor) - MINOR=$((MINOR + 1)) - PATCH=0 - ;; - patch) - PATCH=$((PATCH + 1)) - ;; - esac - - NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}" - echo "New version: $NEW_VERSION" - echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT - - - name: Create and push tag - run: | - git tag ${{ steps.next_version.outputs.version }} - git push origin ${{ steps.next_version.outputs.version }} - - - name: Set up uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 - - - name: Build package - run: uv build - - - name: Upload to PyPI using Trusted Publisher - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: dist/ - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create ${{ steps.next_version.outputs.version }} \ - --title "${{ steps.next_version.outputs.version }}" \ - --generate-notes \ - dist/* - - - name: Print summary - if: success() - run: | - echo "### :rocket: Release ${{ steps.next_version.outputs.version }} completed successfully!" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- **Release Type:** ${{ github.event.inputs.release_type }}" >> $GITHUB_STEP_SUMMARY - echo "- **New Version:** ${{ steps.next_version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "- **PyPI Package:** Published via Trusted Publisher" >> $GITHUB_STEP_SUMMARY - echo "- **GitHub Release:** Created with auto-generated notes" >> $GITHUB_STEP_SUMMARY - - - name: Print failure message - if: failure() - run: | - echo "### :x: Release failed. Please check the logs above." >> $GITHUB_STEP_SUMMARY +name: Release (Trusted Publisher) + +permissions: + contents: write # Required for creating releases and pushing tags + id-token: write # Required for PyPI Trusted Publishing + +on: + workflow_dispatch: + inputs: + release_type: + description: 'Release type (major, minor, patch)' + required: true + type: choice + options: + - patch + - minor + - major + +jobs: + release: + runs-on: ubuntu-latest + # Only allow this workflow to run on main branch + if: github.ref == 'refs/heads/main' + + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 # Fetch all history and tags + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Fetch all tags + run: | + git fetch --tags --force + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + + - name: Configure Git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Calculate next version + id: next_version + shell: bash + run: | + # Get the highest semantic version tag (not most recent, but highest version number) + LATEST_TAG=$(git tag -l 'v*' | sort -V | tail -n1) + if [ -z "$LATEST_TAG" ]; then + LATEST_TAG="v0.0.0" + fi + echo "Latest tag: $LATEST_TAG" + + # Remove 'v' prefix and split into components + VERSION=${LATEST_TAG#v} + IFS='.' read -r -a VERSION_PARTS <<< "$VERSION" + MAJOR="${VERSION_PARTS[0]:-0}" + MINOR="${VERSION_PARTS[1]:-0}" + PATCH="${VERSION_PARTS[2]:-0}" + + echo "Current version: $MAJOR.$MINOR.$PATCH" + + # Increment based on release type + case "${{ github.event.inputs.release_type }}" in + major) + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + ;; + minor) + MINOR=$((MINOR + 1)) + PATCH=0 + ;; + patch) + PATCH=$((PATCH + 1)) + ;; + esac + + NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}" + echo "New version: $NEW_VERSION" + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Create and push tag + run: | + git tag ${{ steps.next_version.outputs.version }} + git push origin ${{ steps.next_version.outputs.version }} + + - name: Set up uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + + - name: Build package + run: uv build + + - name: Upload to PyPI using Trusted Publisher + uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # release/v1 + with: + packages-dir: dist/ + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create ${{ steps.next_version.outputs.version }} \ + --title "${{ steps.next_version.outputs.version }}" \ + --generate-notes \ + dist/* + + - name: Print summary + if: success() + run: | + echo "### :rocket: Release ${{ steps.next_version.outputs.version }} completed successfully!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Release Type:** ${{ github.event.inputs.release_type }}" >> $GITHUB_STEP_SUMMARY + echo "- **New Version:** ${{ steps.next_version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "- **PyPI Package:** Published via Trusted Publisher" >> $GITHUB_STEP_SUMMARY + echo "- **GitHub Release:** Created with auto-generated notes" >> $GITHUB_STEP_SUMMARY + + - name: Print failure message + if: failure() + run: | + echo "### :x: Release failed. Please check the logs above." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/update-plugin-docs.yml b/.github/workflows/update-plugin-docs.yml index a5e01347..57608204 100644 --- a/.github/workflows/update-plugin-docs.yml +++ b/.github/workflows/update-plugin-docs.yml @@ -56,7 +56,7 @@ jobs: pre-commit run --files docs/PLUGIN_DOC.md README.md || true - name: Create Pull Request - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v8 with: token: ${{ secrets.GITHUB_TOKEN }} commit-message: "docs: Update plugin documentation [automated]" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 85a64e4f..103c4391 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,6 +14,8 @@ repos: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace + - id: mixed-line-ending + args: [--fix=lf] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.11.6 hooks: diff --git a/SECURITY.md b/SECURITY.md index 89d7b1ba..6e7fffa5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,34 +1,34 @@ -# Security Policy - -## Supported Versions - -We provide security updates for the **latest released version** and for the **default branch**. - -| Version | Supported | -| ------- | ------------------ | -| Default branch (`main` / `development`) | :white_check_mark: | -| Latest PyPI release (`amd-node-scraper`) | :white_check_mark: | -| Older releases | :x: | - -## Reporting a Vulnerability - -Please **do not** open a public issue for security vulnerabilities. - -### Preferred: private vulnerability report (GitHub) - -If enabled for this repository, use GitHub’s private vulnerability reporting: - -- `https://github.com/amd/node-scraper/security/advisories/new` - -### What to include - -- A clear description of the issue and potential impact -- Steps to reproduce (or a proof-of-concept, if safe to share) -- The output of `node-scraper --version` (or a git commit hash) -- Any relevant configuration details (please redact secrets/credentials/hostnames/IPs) - -### What to expect - -- We will acknowledge receipt as soon as practical. -- If the report is accepted, we’ll work on a fix and coordinate disclosure timing. -- If the report is declined (e.g., not a security issue), we’ll explain why. +# Security Policy + +## Supported Versions + +We provide security updates for the **latest released version** and for the **default branch**. + +| Version | Supported | +| ------- | ------------------ | +| Default branch (`main` / `development`) | :white_check_mark: | +| Latest PyPI release (`amd-node-scraper`) | :white_check_mark: | +| Older releases | :x: | + +## Reporting a Vulnerability + +Please **do not** open a public issue for security vulnerabilities. + +### Preferred: private vulnerability report (GitHub) + +If enabled for this repository, use GitHub’s private vulnerability reporting: + +- `https://github.com/amd/node-scraper/security/advisories/new` + +### What to include + +- A clear description of the issue and potential impact +- Steps to reproduce (or a proof-of-concept, if safe to share) +- The output of `node-scraper --version` (or a git commit hash) +- Any relevant configuration details (please redact secrets/credentials/hostnames/IPs) + +### What to expect + +- We will acknowledge receipt as soon as practical. +- If the report is accepted, we’ll work on a fix and coordinate disclosure timing. +- If the report is declined (e.g., not a security issue), we’ll explain why. diff --git a/nodescraper/base/oobanddataplugin.py b/nodescraper/base/oobanddataplugin.py index c1d9905c..da1a0e46 100644 --- a/nodescraper/base/oobanddataplugin.py +++ b/nodescraper/base/oobanddataplugin.py @@ -1,169 +1,169 @@ -############################################################################### -# -# MIT License -# -# Copyright (c) 2026 Advanced Micro Devices, Inc. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -############################################################################### -from pathlib import Path -from typing import Any, Generic, Optional, Union - -from nodescraper.base.redfishcollectortask import _target_dir_name -from nodescraper.connection.redfish import ( - RedfishConnectionManager, - RedfishConnectionParams, - collected_multi_target_data, -) -from nodescraper.enums import EventPriority, ExecutionStatus, SystemInteractionLevel -from nodescraper.generictypes import TAnalyzeArg, TCollectArg, TDataModel -from nodescraper.interfaces import DataPlugin -from nodescraper.models import TaskResult -from nodescraper.taskresulthooks.filesystemloghook import hooks_for_fixed_directory -from nodescraper.utils import resolve_log_dir_name - - -class OOBandDataPlugin( - DataPlugin[ - RedfishConnectionManager, - RedfishConnectionParams, - TDataModel, - TCollectArg, - TAnalyzeArg, - ], - Generic[TDataModel, TCollectArg, TAnalyzeArg], -): - """Base class for OOB plugins (Redfish). Adds multi-target support. - - Multi-target collection is handled transparently by RedfishDataCollector's - __init_subclass__ wrapper; this class only needs to override analyze() to run - the analyzer once per target after collection completes. - """ - - CONNECTION_TYPE = RedfishConnectionManager - - def run( # type: ignore[override] - self, - collection: bool = True, - analysis: bool = True, - max_event_priority_level: Union[EventPriority, str] = EventPriority.CRITICAL, - system_interaction_level: Union[ - SystemInteractionLevel, str - ] = SystemInteractionLevel.INTERACTIVE, - preserve_connection: bool = False, - data: Optional[Any] = None, - collection_args: Optional[Any] = None, - analysis_args: Optional[Any] = None, - ): - """Run plugin. For multi-target OK runs, the summary includes per-target collection detail.""" - result = super().run( - collection=collection, - analysis=analysis, - max_event_priority_level=max_event_priority_level, - system_interaction_level=system_interaction_level, - preserve_connection=preserve_connection, - data=data, - collection_args=collection_args, - analysis_args=analysis_args, - ) - cm = self.connection_manager - if collected_multi_target_data(cm): - # DataPlugin.run() replaces the message with "Plugin tasks completed successfully" - # for OK status, discarding per-target detail. Restore it for multi-target runs. - if result.status == ExecutionStatus.OK and getattr( - self.collection_result, "message", None - ): - result.message = self.collection_result.message - - return result - - def analyze( - self, - max_event_priority_level: Optional[Union[EventPriority, str]] = EventPriority.CRITICAL, - analysis_args: Optional[Union[TAnalyzeArg, dict]] = None, - data: Optional[Any] = None, - ) -> TaskResult: - """Analyze collected data for one BMC or once per Redfish target. - - Args: - max_event_priority_level: Priority limit for events. - analysis_args: Analyzer arguments. - data: Pre-collected data for a single-target run. - - Returns: - TaskResult: Analysis result for the targets that returned data. - """ - cm = self.connection_manager - multi_target_data: dict = collected_multi_target_data(cm) - - if not multi_target_data: - return super().analyze( - max_event_priority_level=max_event_priority_level, - analysis_args=analysis_args, - data=data, - ) - - if self.ANALYZER is None: - self.analysis_result = TaskResult( - status=ExecutionStatus.NOT_RAN, - parent=self.__class__.__name__, - message=f"Data analysis not supported for {self.__class__.__name__}", - ) - return self.analysis_result - - if ( - analysis_args is not None - and isinstance(analysis_args, dict) - and hasattr(self, "ANALYZER_ARGS") - and self.ANALYZER_ARGS is not None - ): - analysis_args = self.ANALYZER_ARGS.model_validate(analysis_args) # type: ignore[assignment] - - plugin_log_dir = ( - Path(self.log_path) / resolve_log_dir_name(self.__class__.__name__) - if self.log_path - else None - ) - analyzer_name = resolve_log_dir_name(self.ANALYZER.__name__) - - analysis_results: list[TaskResult] = [] - for target_key, target_data in multi_target_data.items(): - safe_key = _target_dir_name(target_key) - target_hooks = self.task_result_hooks - if plugin_log_dir is not None: - target_hooks = hooks_for_fixed_directory( - self.task_result_hooks, - str(plugin_log_dir / analyzer_name / safe_key), - ) - analyzer_task = self.ANALYZER( - system_info=self.system_info.model_copy(), - logger=self.logger, - max_event_priority_level=max_event_priority_level or EventPriority.CRITICAL, - parent=safe_key, - task_result_hooks=target_hooks, - event_reporter=self.event_reporter, - session_id=self.session_id, - ) - analysis_results.append(analyzer_task.analyze_data(target_data, analysis_args)) - - self.analysis_result = self._aggregate_collection_results( - self.__class__.__name__, analysis_results - ) - return self.analysis_result +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from pathlib import Path +from typing import Any, Generic, Optional, Union + +from nodescraper.base.redfishcollectortask import _target_dir_name +from nodescraper.connection.redfish import ( + RedfishConnectionManager, + RedfishConnectionParams, + collected_multi_target_data, +) +from nodescraper.enums import EventPriority, ExecutionStatus, SystemInteractionLevel +from nodescraper.generictypes import TAnalyzeArg, TCollectArg, TDataModel +from nodescraper.interfaces import DataPlugin +from nodescraper.models import TaskResult +from nodescraper.taskresulthooks.filesystemloghook import hooks_for_fixed_directory +from nodescraper.utils import resolve_log_dir_name + + +class OOBandDataPlugin( + DataPlugin[ + RedfishConnectionManager, + RedfishConnectionParams, + TDataModel, + TCollectArg, + TAnalyzeArg, + ], + Generic[TDataModel, TCollectArg, TAnalyzeArg], +): + """Base class for OOB plugins (Redfish). Adds multi-target support. + + Multi-target collection is handled transparently by RedfishDataCollector's + __init_subclass__ wrapper; this class only needs to override analyze() to run + the analyzer once per target after collection completes. + """ + + CONNECTION_TYPE = RedfishConnectionManager + + def run( # type: ignore[override] + self, + collection: bool = True, + analysis: bool = True, + max_event_priority_level: Union[EventPriority, str] = EventPriority.CRITICAL, + system_interaction_level: Union[ + SystemInteractionLevel, str + ] = SystemInteractionLevel.INTERACTIVE, + preserve_connection: bool = False, + data: Optional[Any] = None, + collection_args: Optional[Any] = None, + analysis_args: Optional[Any] = None, + ): + """Run plugin. For multi-target OK runs, the summary includes per-target collection detail.""" + result = super().run( + collection=collection, + analysis=analysis, + max_event_priority_level=max_event_priority_level, + system_interaction_level=system_interaction_level, + preserve_connection=preserve_connection, + data=data, + collection_args=collection_args, + analysis_args=analysis_args, + ) + cm = self.connection_manager + if collected_multi_target_data(cm): + # DataPlugin.run() replaces the message with "Plugin tasks completed successfully" + # for OK status, discarding per-target detail. Restore it for multi-target runs. + if result.status == ExecutionStatus.OK and getattr( + self.collection_result, "message", None + ): + result.message = self.collection_result.message + + return result + + def analyze( + self, + max_event_priority_level: Optional[Union[EventPriority, str]] = EventPriority.CRITICAL, + analysis_args: Optional[Union[TAnalyzeArg, dict]] = None, + data: Optional[Any] = None, + ) -> TaskResult: + """Analyze collected data for one BMC or once per Redfish target. + + Args: + max_event_priority_level: Priority limit for events. + analysis_args: Analyzer arguments. + data: Pre-collected data for a single-target run. + + Returns: + TaskResult: Analysis result for the targets that returned data. + """ + cm = self.connection_manager + multi_target_data: dict = collected_multi_target_data(cm) + + if not multi_target_data: + return super().analyze( + max_event_priority_level=max_event_priority_level, + analysis_args=analysis_args, + data=data, + ) + + if self.ANALYZER is None: + self.analysis_result = TaskResult( + status=ExecutionStatus.NOT_RAN, + parent=self.__class__.__name__, + message=f"Data analysis not supported for {self.__class__.__name__}", + ) + return self.analysis_result + + if ( + analysis_args is not None + and isinstance(analysis_args, dict) + and hasattr(self, "ANALYZER_ARGS") + and self.ANALYZER_ARGS is not None + ): + analysis_args = self.ANALYZER_ARGS.model_validate(analysis_args) # type: ignore[assignment] + + plugin_log_dir = ( + Path(self.log_path) / resolve_log_dir_name(self.__class__.__name__) + if self.log_path + else None + ) + analyzer_name = resolve_log_dir_name(self.ANALYZER.__name__) + + analysis_results: list[TaskResult] = [] + for target_key, target_data in multi_target_data.items(): + safe_key = _target_dir_name(target_key) + target_hooks = self.task_result_hooks + if plugin_log_dir is not None: + target_hooks = hooks_for_fixed_directory( + self.task_result_hooks, + str(plugin_log_dir / analyzer_name / safe_key), + ) + analyzer_task = self.ANALYZER( + system_info=self.system_info.model_copy(), + logger=self.logger, + max_event_priority_level=max_event_priority_level or EventPriority.CRITICAL, + parent=safe_key, + task_result_hooks=target_hooks, + event_reporter=self.event_reporter, + session_id=self.session_id, + ) + analysis_results.append(analyzer_task.analyze_data(target_data, analysis_args)) + + self.analysis_result = self._aggregate_collection_results( + self.__class__.__name__, analysis_results + ) + return self.analysis_result diff --git a/nodescraper/base/redfishcollectortask.py b/nodescraper/base/redfishcollectortask.py index 0da50b92..478ca6d2 100644 --- a/nodescraper/base/redfishcollectortask.py +++ b/nodescraper/base/redfishcollectortask.py @@ -1,376 +1,376 @@ -############################################################################### -# -# 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 logging -import re -from concurrent.futures import ThreadPoolExecutor, as_completed -from functools import wraps -from pathlib import Path -from typing import Any, Generic, Optional, Union - -from nodescraper.connection.redfish import ( - MultiTargetRedfishConnection, - RedfishConnection, - RedfishGetResult, -) -from nodescraper.connection.redfish.redfish_connection import RedfishPostResult -from nodescraper.constants import DEFAULT_EVENT_REPORTER -from nodescraper.enums import EventPriority, ExecutionStatus -from nodescraper.generictypes import TCollectArg, TDataModel -from nodescraper.interfaces import DataCollector, TaskResultHook -from nodescraper.interfaces.dataplugin import DataPlugin -from nodescraper.models import SystemInfo, TaskResult -from nodescraper.taskresulthooks.filesystemloghook import hooks_for_fixed_directory - -_HARD_FAIL = {ExecutionStatus.ERROR, ExecutionStatus.EXECUTION_FAILURE} -_TARGET_SUCCESS = {ExecutionStatus.OK, ExecutionStatus.WARNING} - - -class _TargetPrefixAdapter(logging.LoggerAdapter): - """Prepend ``[target_key]`` to every log message emitted by a per-target collector thread.""" - - def __init__(self, logger: logging.Logger, extra: dict[str, Any]) -> None: - super().__init__(logger, extra) - self._target_key: str = extra["target_key"] - - def process(self, msg: str, kwargs: Any) -> tuple[str, Any]: - return f"[{self._target_key}] {msg}", kwargs - - -def _target_dir_name(target_key: str) -> str: - """Return a filesystem-safe directory name for a target key. - - Args: - target_key: Target identifier from the connection config. - - Returns: - str: Directory name containing only letters, numbers, dot, underscore, and dash. - """ - # Collapse characters that are unsafe in a log directory name. - cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", target_key).strip("._") - return cleaned or "target" - - -def combine_target_results(parent: str, results: list[TaskResult]) -> TaskResult: - """Merge per-target task results. - - Args: - parent: Parent name stored on the combined result. - results: Per-target task results. - - Returns: - TaskResult: Combined result. Mixed success and failure stays at warning. - """ - aggregated = DataPlugin._aggregate_collection_results(parent, results) - hard_fail = any(result.status in _HARD_FAIL for result in results) - succeeded = any(result.status in _TARGET_SUCCESS for result in results) - - # Display one line per target so the summary table is easy to read. - messages = [r.message for r in results if r.message] - if messages: - prefix = ( - "One or more targets failed; continuing with the rest.\n" - if (hard_fail and succeeded) - else "" - ) - aggregated.message = prefix + "\n".join(messages) - if hard_fail and succeeded: - aggregated.status = ExecutionStatus.WARNING - - return aggregated - - -class RedfishDataCollector( - DataCollector[RedfishConnection, TDataModel, TCollectArg], - Generic[TDataModel, TCollectArg], -): - """Base class for data collectors that use a Redfish connection.""" - - def __init__( - self, - system_info: SystemInfo, - connection: RedfishConnection, - logger: Optional[logging.Logger] = None, - max_event_priority_level: Union[EventPriority, str] = EventPriority.CRITICAL, - parent: Optional[str] = None, - task_result_hooks: Optional[list[TaskResultHook]] = None, - event_reporter: str = DEFAULT_EVENT_REPORTER, - session_id: Optional[str] = None, - **kwargs, - ): - """Creates a RedfishDataCollector instance. - - Args: - system_info (SystemInfo): system info object for target system for data collection - connection (TConnection): connection object for the data collector - logger (Optional[logging.Logger], optional): python logger object. Defaults to None. - max_event_priority_level (Union[EventPriority, str], optional): priority limit for events. Defaults to EventPriority.CRITICAL. - parent (Optional[str], optional): parent task identifier. Defaults to None. - task_result_hooks (Optional[list[TaskResultHook]], optional): list of task result hooks. Defaults to None. - event_reporter (str, optional): Reporter string stored on emitted events. Defaults to DEFAULT_EVENT_REPORTER. - session_id (Optional[str], optional): session identifier. Defaults to None. - """ - super().__init__( - system_info=system_info, - connection=connection, - logger=logger, - max_event_priority_level=max_event_priority_level, - parent=parent, - task_result_hooks=task_result_hooks, - event_reporter=event_reporter, - session_id=session_id, - **kwargs, - ) - - def __init_subclass__(cls, **kwargs: Any) -> None: - """Wrap each concrete collector's collect_data with multi-target detection. - - Execution order when collect_data is called: - 1. _multi_target_wrapper (outermost) — checks for MultiTargetRedfishConnection - 2. collect_decorator (from DataCollector) — handles hooks, finalization - 3. Concrete collect_data implementation (innermost) - - In multi-target mode a fresh collector instance is created per target so that - concurrent threads never share mutable state. - """ - super().__init_subclass__(**kwargs) # applies collect_decorator via DataCollector - if "collect_data" not in vars(cls): - return - inner = cls.collect_data # already wrapped by collect_decorator at this point - - @wraps(inner) - def _multi_target_wrapper( - collector: "RedfishDataCollector", - args: Any = None, - *, - _fn: Any = inner, - ) -> tuple[TaskResult, Any]: - if not isinstance(collector.connection, MultiTargetRedfishConnection): - return _fn(collector, args) - - multi_conn: MultiTargetRedfishConnection = collector.connection # type: ignore[assignment] - parent_name = collector.parent or type(collector).__name__ - all_results: list[TaskResult] = [ - TaskResult( - status=ExecutionStatus.WARNING, - parent=f"{parent_name}[{target_key}]", - message=message, - ) - for target_key, message in multi_conn.failed_targets.items() - ] - target_count = len(multi_conn.target_connections) - configured_workers = multi_conn.max_workers or target_count - max_workers = min(configured_workers, target_count, 32) if target_count else 1 - - def _run_for_target( - target_key: str, conn: RedfishConnection - ) -> tuple[str, TaskResult, Any]: - safe_key = _target_dir_name(target_key) - collector_log = getattr(collector, "log_path", None) - target_log = str(Path(collector_log) / safe_key) if collector_log else None - per_target_hooks = ( - hooks_for_fixed_directory(collector.task_result_hooks, target_log) - if target_log - else list(collector.task_result_hooks) - ) - - target_collector = type(collector)( - system_info=collector.system_info.model_copy(), - connection=conn, - logger=_TargetPrefixAdapter(collector.logger, {"target_key": target_key}), # type: ignore[arg-type] - max_event_priority_level=getattr( - collector, "max_event_priority_level", EventPriority.CRITICAL - ), - parent=safe_key, - task_result_hooks=per_target_hooks, - event_reporter=collector.event_reporter, - session_id=collector.session_id, - log_path=target_log, - system_interaction_level=getattr(collector, "system_interaction_level", None), - ) - collector.logger.info( - "Starting collection for target %r using %s", target_key, parent_name - ) - result, data = _fn(target_collector, args) - collector.logger.info("Finished collection for target %r", target_key) - - # Prefix result message with target key for attribution in the summary table. - if result.message: - result.message = f"[{target_key}] {result.message}" - - return target_key, result, data - - if multi_conn.target_connections: - with ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = { - executor.submit(_run_for_target, tk, conn): tk - for tk, conn in multi_conn.target_connections.items() - } - for future in as_completed(futures): - target_key = futures[future] - try: - tk, result, data = future.result() - all_results.append(result) - if data is not None: - multi_conn.multi_target_data[tk] = data - except Exception as exc: - collector.logger.error( - "Collection failed for target %r: %s", target_key, exc - ) - all_results.append( - TaskResult( - status=ExecutionStatus.EXECUTION_FAILURE, - parent=f"{parent_name}[{target_key}]", - message=f"Collection failed for target {target_key!r}: {exc}", - ) - ) - - if not all_results: - return ( - TaskResult( - status=ExecutionStatus.NOT_RAN, - parent=parent_name, - message="No Redfish targets were collected", - ), - None, - ) - return combine_target_results(parent_name, all_results), None - - cls.collect_data = _multi_target_wrapper # type: ignore[method-assign, assignment] - - 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) - 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 _run_redfish_get_paged( - self, - 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. - - Args: - 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) - 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 - - def _run_redfish_post( - self, - path: str, - payload: Optional[dict] = None, - log_artifact: bool = True, - ) -> RedfishPostResult: - """Run a Redfish POST request and return the result. - - Args: - path: Redfish URI path. - payload: JSON payload body. Defaults to an empty dict. - log_artifact: If True, append the result to self.result.artifacts. - - Returns: - RedfishPostResult: path, success, data (or error), status_code. - """ - path_norm = path.strip() - if not path_norm.startswith("/"): - path_norm = "/" + path_norm - try: - resp = self.connection.post(path_norm, json=payload or {}) - success = resp.ok - status_code = resp.status_code - try: - data = resp.json() if resp.content else None - if not isinstance(data, dict): - data = None - except Exception: - data = None - error = ( - None if success else f"POST {path_norm} failed: {resp.status_code} {resp.reason}" - ) - result = RedfishPostResult( - path=path_norm, - success=success, - data=data, - error=error, - status_code=status_code, - ) - except Exception as exc: - result = RedfishPostResult( - path=path_norm, - success=False, - error=str(exc), - status_code=None, - ) - if log_artifact: - self.result.artifacts.append(result) - return result +############################################################################### +# +# 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 logging +import re +from concurrent.futures import ThreadPoolExecutor, as_completed +from functools import wraps +from pathlib import Path +from typing import Any, Generic, Optional, Union + +from nodescraper.connection.redfish import ( + MultiTargetRedfishConnection, + RedfishConnection, + RedfishGetResult, +) +from nodescraper.connection.redfish.redfish_connection import RedfishPostResult +from nodescraper.constants import DEFAULT_EVENT_REPORTER +from nodescraper.enums import EventPriority, ExecutionStatus +from nodescraper.generictypes import TCollectArg, TDataModel +from nodescraper.interfaces import DataCollector, TaskResultHook +from nodescraper.interfaces.dataplugin import DataPlugin +from nodescraper.models import SystemInfo, TaskResult +from nodescraper.taskresulthooks.filesystemloghook import hooks_for_fixed_directory + +_HARD_FAIL = {ExecutionStatus.ERROR, ExecutionStatus.EXECUTION_FAILURE} +_TARGET_SUCCESS = {ExecutionStatus.OK, ExecutionStatus.WARNING} + + +class _TargetPrefixAdapter(logging.LoggerAdapter): + """Prepend ``[target_key]`` to every log message emitted by a per-target collector thread.""" + + def __init__(self, logger: logging.Logger, extra: dict[str, Any]) -> None: + super().__init__(logger, extra) + self._target_key: str = extra["target_key"] + + def process(self, msg: str, kwargs: Any) -> tuple[str, Any]: + return f"[{self._target_key}] {msg}", kwargs + + +def _target_dir_name(target_key: str) -> str: + """Return a filesystem-safe directory name for a target key. + + Args: + target_key: Target identifier from the connection config. + + Returns: + str: Directory name containing only letters, numbers, dot, underscore, and dash. + """ + # Collapse characters that are unsafe in a log directory name. + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", target_key).strip("._") + return cleaned or "target" + + +def combine_target_results(parent: str, results: list[TaskResult]) -> TaskResult: + """Merge per-target task results. + + Args: + parent: Parent name stored on the combined result. + results: Per-target task results. + + Returns: + TaskResult: Combined result. Mixed success and failure stays at warning. + """ + aggregated = DataPlugin._aggregate_collection_results(parent, results) + hard_fail = any(result.status in _HARD_FAIL for result in results) + succeeded = any(result.status in _TARGET_SUCCESS for result in results) + + # Display one line per target so the summary table is easy to read. + messages = [r.message for r in results if r.message] + if messages: + prefix = ( + "One or more targets failed; continuing with the rest.\n" + if (hard_fail and succeeded) + else "" + ) + aggregated.message = prefix + "\n".join(messages) + if hard_fail and succeeded: + aggregated.status = ExecutionStatus.WARNING + + return aggregated + + +class RedfishDataCollector( + DataCollector[RedfishConnection, TDataModel, TCollectArg], + Generic[TDataModel, TCollectArg], +): + """Base class for data collectors that use a Redfish connection.""" + + def __init__( + self, + system_info: SystemInfo, + connection: RedfishConnection, + logger: Optional[logging.Logger] = None, + max_event_priority_level: Union[EventPriority, str] = EventPriority.CRITICAL, + parent: Optional[str] = None, + task_result_hooks: Optional[list[TaskResultHook]] = None, + event_reporter: str = DEFAULT_EVENT_REPORTER, + session_id: Optional[str] = None, + **kwargs, + ): + """Creates a RedfishDataCollector instance. + + Args: + system_info (SystemInfo): system info object for target system for data collection + connection (TConnection): connection object for the data collector + logger (Optional[logging.Logger], optional): python logger object. Defaults to None. + max_event_priority_level (Union[EventPriority, str], optional): priority limit for events. Defaults to EventPriority.CRITICAL. + parent (Optional[str], optional): parent task identifier. Defaults to None. + task_result_hooks (Optional[list[TaskResultHook]], optional): list of task result hooks. Defaults to None. + event_reporter (str, optional): Reporter string stored on emitted events. Defaults to DEFAULT_EVENT_REPORTER. + session_id (Optional[str], optional): session identifier. Defaults to None. + """ + super().__init__( + system_info=system_info, + connection=connection, + logger=logger, + max_event_priority_level=max_event_priority_level, + parent=parent, + task_result_hooks=task_result_hooks, + event_reporter=event_reporter, + session_id=session_id, + **kwargs, + ) + + def __init_subclass__(cls, **kwargs: Any) -> None: + """Wrap each concrete collector's collect_data with multi-target detection. + + Execution order when collect_data is called: + 1. _multi_target_wrapper (outermost) — checks for MultiTargetRedfishConnection + 2. collect_decorator (from DataCollector) — handles hooks, finalization + 3. Concrete collect_data implementation (innermost) + + In multi-target mode a fresh collector instance is created per target so that + concurrent threads never share mutable state. + """ + super().__init_subclass__(**kwargs) # applies collect_decorator via DataCollector + if "collect_data" not in vars(cls): + return + inner = cls.collect_data # already wrapped by collect_decorator at this point + + @wraps(inner) + def _multi_target_wrapper( + collector: "RedfishDataCollector", + args: Any = None, + *, + _fn: Any = inner, + ) -> tuple[TaskResult, Any]: + if not isinstance(collector.connection, MultiTargetRedfishConnection): + return _fn(collector, args) + + multi_conn: MultiTargetRedfishConnection = collector.connection # type: ignore[assignment] + parent_name = collector.parent or type(collector).__name__ + all_results: list[TaskResult] = [ + TaskResult( + status=ExecutionStatus.WARNING, + parent=f"{parent_name}[{target_key}]", + message=message, + ) + for target_key, message in multi_conn.failed_targets.items() + ] + target_count = len(multi_conn.target_connections) + configured_workers = multi_conn.max_workers or target_count + max_workers = min(configured_workers, target_count, 32) if target_count else 1 + + def _run_for_target( + target_key: str, conn: RedfishConnection + ) -> tuple[str, TaskResult, Any]: + safe_key = _target_dir_name(target_key) + collector_log = getattr(collector, "log_path", None) + target_log = str(Path(collector_log) / safe_key) if collector_log else None + per_target_hooks = ( + hooks_for_fixed_directory(collector.task_result_hooks, target_log) + if target_log + else list(collector.task_result_hooks) + ) + + target_collector = type(collector)( + system_info=collector.system_info.model_copy(), + connection=conn, + logger=_TargetPrefixAdapter(collector.logger, {"target_key": target_key}), # type: ignore[arg-type] + max_event_priority_level=getattr( + collector, "max_event_priority_level", EventPriority.CRITICAL + ), + parent=safe_key, + task_result_hooks=per_target_hooks, + event_reporter=collector.event_reporter, + session_id=collector.session_id, + log_path=target_log, + system_interaction_level=getattr(collector, "system_interaction_level", None), + ) + collector.logger.info( + "Starting collection for target %r using %s", target_key, parent_name + ) + result, data = _fn(target_collector, args) + collector.logger.info("Finished collection for target %r", target_key) + + # Prefix result message with target key for attribution in the summary table. + if result.message: + result.message = f"[{target_key}] {result.message}" + + return target_key, result, data + + if multi_conn.target_connections: + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit(_run_for_target, tk, conn): tk + for tk, conn in multi_conn.target_connections.items() + } + for future in as_completed(futures): + target_key = futures[future] + try: + tk, result, data = future.result() + all_results.append(result) + if data is not None: + multi_conn.multi_target_data[tk] = data + except Exception as exc: + collector.logger.error( + "Collection failed for target %r: %s", target_key, exc + ) + all_results.append( + TaskResult( + status=ExecutionStatus.EXECUTION_FAILURE, + parent=f"{parent_name}[{target_key}]", + message=f"Collection failed for target {target_key!r}: {exc}", + ) + ) + + if not all_results: + return ( + TaskResult( + status=ExecutionStatus.NOT_RAN, + parent=parent_name, + message="No Redfish targets were collected", + ), + None, + ) + return combine_target_results(parent_name, all_results), None + + cls.collect_data = _multi_target_wrapper # type: ignore[method-assign, assignment] + + 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) + 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 _run_redfish_get_paged( + self, + 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. + + Args: + 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) + 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 + + def _run_redfish_post( + self, + path: str, + payload: Optional[dict] = None, + log_artifact: bool = True, + ) -> RedfishPostResult: + """Run a Redfish POST request and return the result. + + Args: + path: Redfish URI path. + payload: JSON payload body. Defaults to an empty dict. + log_artifact: If True, append the result to self.result.artifacts. + + Returns: + RedfishPostResult: path, success, data (or error), status_code. + """ + path_norm = path.strip() + if not path_norm.startswith("/"): + path_norm = "/" + path_norm + try: + resp = self.connection.post(path_norm, json=payload or {}) + success = resp.ok + status_code = resp.status_code + try: + data = resp.json() if resp.content else None + if not isinstance(data, dict): + data = None + except Exception: + data = None + error = ( + None if success else f"POST {path_norm} failed: {resp.status_code} {resp.reason}" + ) + result = RedfishPostResult( + path=path_norm, + success=success, + data=data, + error=error, + status_code=status_code, + ) + except Exception as exc: + result = RedfishPostResult( + path=path_norm, + success=False, + error=str(exc), + status_code=None, + ) + if log_artifact: + self.result.artifacts.append(result) + return result diff --git a/nodescraper/connection/redfish/__init__.py b/nodescraper/connection/redfish/__init__.py index f4b85243..cd875a0f 100644 --- a/nodescraper/connection/redfish/__init__.py +++ b/nodescraper/connection/redfish/__init__.py @@ -1,88 +1,88 @@ -############################################################################### -# -# 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 .redfish_clear_log import ( - ClearLogEndpoint, - ClearLogResult, - clear_redfish_logs, - discover_clear_log_endpoints, -) -from .redfish_connection import ( - RedfishConnection, - RedfishConnectionError, - RedfishGetResult, - RedfishPostResult, -) -from .redfish_constants import ( - RF_MEMBERS, - RF_MEMBERS_COUNT, - RF_MEMBERS_NEXT_LINK, - RF_ODATA_ID, -) -from .redfish_manager import ( - MultiTargetRedfishConnection, - RedfishConnectionManager, - collected_multi_target_data, -) -from .redfish_oem_diag import ( - collect_oem_diagnostic_data, - get_oem_diagnostic_allowable_values, -) -from .redfish_params import ( - RedfishConnectionParams, - RedfishTargetParams, - redfish_params_to_ssh, -) -from .redfish_path import RedfishPath -from .ssh_proxy_connection import SshProxyRedfishConnection -from .ssh_proxy_manager import RedfishSshProxyConnectionManager -from .ssh_proxy_params import RedfishSshProxyConnectionParams - -__all__ = [ - "ClearLogEndpoint", - "ClearLogResult", - "clear_redfish_logs", - "discover_clear_log_endpoints", - "RedfishConnection", - "RedfishConnectionError", - "RedfishGetResult", - "RedfishPostResult", - "MultiTargetRedfishConnection", - "RedfishConnectionManager", - "collected_multi_target_data", - "RedfishSshProxyConnectionManager", - "RedfishSshProxyConnectionParams", - "SshProxyRedfishConnection", - "RedfishConnectionParams", - "RedfishTargetParams", - "redfish_params_to_ssh", - "RedfishPath", - "collect_oem_diagnostic_data", - "get_oem_diagnostic_allowable_values", - "RF_MEMBERS", - "RF_MEMBERS_COUNT", - "RF_MEMBERS_NEXT_LINK", - "RF_ODATA_ID", -] +############################################################################### +# +# 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 .redfish_clear_log import ( + ClearLogEndpoint, + ClearLogResult, + clear_redfish_logs, + discover_clear_log_endpoints, +) +from .redfish_connection import ( + RedfishConnection, + RedfishConnectionError, + RedfishGetResult, + RedfishPostResult, +) +from .redfish_constants import ( + RF_MEMBERS, + RF_MEMBERS_COUNT, + RF_MEMBERS_NEXT_LINK, + RF_ODATA_ID, +) +from .redfish_manager import ( + MultiTargetRedfishConnection, + RedfishConnectionManager, + collected_multi_target_data, +) +from .redfish_oem_diag import ( + collect_oem_diagnostic_data, + get_oem_diagnostic_allowable_values, +) +from .redfish_params import ( + RedfishConnectionParams, + RedfishTargetParams, + redfish_params_to_ssh, +) +from .redfish_path import RedfishPath +from .ssh_proxy_connection import SshProxyRedfishConnection +from .ssh_proxy_manager import RedfishSshProxyConnectionManager +from .ssh_proxy_params import RedfishSshProxyConnectionParams + +__all__ = [ + "ClearLogEndpoint", + "ClearLogResult", + "clear_redfish_logs", + "discover_clear_log_endpoints", + "RedfishConnection", + "RedfishConnectionError", + "RedfishGetResult", + "RedfishPostResult", + "MultiTargetRedfishConnection", + "RedfishConnectionManager", + "collected_multi_target_data", + "RedfishSshProxyConnectionManager", + "RedfishSshProxyConnectionParams", + "SshProxyRedfishConnection", + "RedfishConnectionParams", + "RedfishTargetParams", + "redfish_params_to_ssh", + "RedfishPath", + "collect_oem_diagnostic_data", + "get_oem_diagnostic_allowable_values", + "RF_MEMBERS", + "RF_MEMBERS_COUNT", + "RF_MEMBERS_NEXT_LINK", + "RF_ODATA_ID", +] diff --git a/nodescraper/connection/redfish/redfish_connection.py b/nodescraper/connection/redfish/redfish_connection.py index 511f1459..8999534e 100644 --- a/nodescraper/connection/redfish/redfish_connection.py +++ b/nodescraper/connection/redfish/redfish_connection.py @@ -1,386 +1,386 @@ -############################################################################### -# -# 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 __future__ import annotations - -import json -import socket -from typing import Any, Callable, ClassVar, Optional, Protocol, TypeVar, Union -from urllib.parse import urljoin, urlparse - -import requests -import urllib3 # type: ignore[import-untyped] -from pydantic import BaseModel -from requests.auth import HTTPBasicAuth - -from .redfish_constants import RF_MEMBERS, RF_MEMBERS_COUNT, RF_MEMBERS_NEXT_LINK -from .redfish_path import RedfishPath - -DEFAULT_REDFISH_API_ROOT = "redfish/v1" - -_T = TypeVar("_T") - - -class RedfishGetResult(BaseModel): - """Artifact for the result of a Redfish GET request. - Logged under the same filename as inband command artifacts (command_artifacts.json). - """ - - ARTIFACT_LOG_BASENAME: ClassVar[str] = "command_artifacts" - - path: str - success: bool - 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 RedfishPostResult(BaseModel): - """Artifact for the result of a Redfish POST request.""" - - ARTIFACT_LOG_BASENAME: ClassVar[str] = "command_artifacts" - - path: str - success: bool - data: Optional[dict[str, Any]] = None - error: Optional[str] = None - status_code: Optional[int] = None - - 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"POST {self.path}", - "stdout": stdout, - "stderr": stderr, - "exit_code": exit_code, - } - - -class RedfishHttpResponse(Protocol): - """Status, headers, and body from a Redfish GET or POST.""" - - status_code: int - headers: Any - - @property - def content(self) -> bytes: ... - - @property - def ok(self) -> bool: ... - - @property - def reason(self) -> str: ... - - @property - def text(self) -> str: ... - - def json(self) -> Any: ... - - -class RedfishConnectionError(Exception): - """Raised when a Redfish API request fails.""" - - def __init__(self, message: str, response: Optional[RedfishHttpResponse] = None): - super().__init__(message) - self.response = response - - -class RedfishConnection: - """Redfish REST client for GET requests.""" - - def __init__( - self, - base_url: str, - username: str, - password: Optional[str] = None, - timeout: float = 10.0, - use_session_auth: bool = True, - verify_ssl: bool = True, - api_root: Optional[str] = None, - ): - """Creates a RedfishConnection instance. - - Args: - base_url (str): The base url for the redfish service typically this is /redfish/v1 - username (str): The username for the redfish service. - password (Optional[str], optional): The password for the redfish service. Defaults to None. - timeout (float, optional): The timeout for redfish requests in seconds. Defaults to 10.0. - use_session_auth (bool, optional): Whether to use session-based authentication. Defaults to True. - verify_ssl (bool, optional): Whether to verify the SSL certificate. Defaults to True. - api_root (Optional[str], optional): The API root for the redfish services. Defaults to None. - """ - self.base_url: str = base_url.rstrip("/") - self.api_root: str = (api_root or DEFAULT_REDFISH_API_ROOT).strip("/") - self.username: str = username - self.password: str = password or "" - self.timeout: float = timeout - self.use_session_auth: bool = use_session_auth - self.verify_ssl: bool = verify_ssl - self._session: Optional[requests.Session] = None - self._session_token: Optional[str] = None - self._session_uri: Optional[str] = None # For logout DELETE - - def _host_label(self) -> str: - return urlparse(self.base_url).hostname or self.base_url - - @staticmethod - def _is_name_resolution_error(exc: BaseException) -> bool: - current: Optional[BaseException] = exc - seen: set[int] = set() - while current is not None and id(current) not in seen: - if isinstance(current, socket.gaierror): - return True - if type(current).__name__ == "NameResolutionError": - return True - seen.add(id(current)) - current = current.__cause__ or current.__context__ - message = str(exc) - return "Failed to resolve" in message or "No address associated with hostname" in message - - def _transport_error_message(self, exc: Exception) -> str: - host = self._host_label() - if isinstance(exc, socket.gaierror) or self._is_name_resolution_error(exc): - return f"Redfish hostname could not be resolved: {host}" - if isinstance(exc, requests.exceptions.Timeout): - return f"Redfish connection timed out: {host}" - if isinstance(exc, requests.exceptions.ConnectionError): - return f"Redfish connection failed: {host}" - return f"Redfish connection failed: {exc}" - - def _execute_request(self, request_fn: Callable[[], _T]) -> _T: - try: - return request_fn() - except ( - requests.exceptions.Timeout, - requests.exceptions.ConnectionError, - ) as exc: - raise RedfishConnectionError(self._transport_error_message(exc)) from exc - except socket.gaierror as exc: - raise RedfishConnectionError(self._transport_error_message(exc)) from exc - - def _ensure_session(self) -> requests.Session: - if self._session is None: - if not self.verify_ssl: - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - self._session = requests.Session() - self._session.verify = self.verify_ssl - if not self.verify_ssl: - self._session.trust_env = False - self._session.headers["Content-Type"] = "application/json" - self._session.headers["Accept"] = "application/json" - if self.use_session_auth and self.password: - self._login_session() - elif self.password: - self._session.auth = HTTPBasicAuth(self.username, self.password) - return self._session - - def _login_session(self) -> None: - """Create a Redfish session and set X-Auth-Token.""" - session = self._session - assert session is not None - sess_url = urljoin(self.base_url + "/", f"{self.api_root}/SessionService/Sessions") - payload = {"UserName": self.username, "Password": self.password} - resp = self._execute_request( - lambda: session.post( - sess_url, - json=payload, - timeout=self.timeout, - ) - ) - if not resp.ok: - raise RedfishConnectionError( - f"Session login failed: {resp.status_code} {resp.reason}", response=resp - ) - self._session_token = resp.headers.get("X-Auth-Token") - location = resp.headers.get("Location") - if location: - self._session_uri = ( - location - if location.startswith("http") - else urljoin(self.base_url + "/", location.lstrip("/")) - ) - if self._session_token: - session.headers["X-Auth-Token"] = self._session_token - else: - session.auth = HTTPBasicAuth(self.username, self.password) - - def get(self, path: RedfishPath) -> dict[str, Any]: - """GET a Redfish path and return the JSON body. path must be a RedfishPath.""" - path_str = str(path) - resp = self.get_response(path_str) - if not resp.ok: - raise RedfishConnectionError( - f"GET {path_str} failed: {resp.status_code} {resp.reason}", - response=resp, - ) - return resp.json() - - def get_response(self, path: Union[str, "RedfishPath"]) -> RedfishHttpResponse: - """GET a Redfish path and return the raw Response. path may be a string or RedfishPath.""" - path = str(path) - session = self._ensure_session() - url = path if path.startswith("http") else urljoin(self.base_url + "/", path.lstrip("/")) - return self._execute_request(lambda: session.get(url, timeout=self.timeout)) - - def post( - self, path: Union[str, "RedfishPath"], json: Optional[dict[str, Any]] = None - ) -> RedfishHttpResponse: - """POST to a Redfish path and return the raw Response. path may be a string or RedfishPath.""" - path = str(path) - session = self._ensure_session() - url = path if path.startswith("http") else urljoin(self.base_url + "/", path.lstrip("/")) - return self._execute_request( - lambda: session.post(url, json=json or {}, timeout=self.timeout) - ) - - def run_get(self, path: Union[str, RedfishPath]) -> RedfishGetResult: - """Run a Redfish GET request and return a result object. path may be a string or RedfishPath.""" - path_norm = str(path).strip() - if not path_norm.startswith("/"): - path_norm = "/" + path_norm - path_obj = RedfishPath(path_norm.strip("/")) if isinstance(path, str) else path - try: - data = self.get(path_obj) - return RedfishGetResult( - path=path_norm, - success=True, - data=data, - status_code=200, - ) - except RedfishConnectionError as e: - status = e.response.status_code if e.response is not None else None - return RedfishGetResult( - path=path_norm, - success=False, - error=str(e), - status_code=status, - ) - except Exception as e: - return RedfishGetResult( - path=path_norm, - success=False, - error=str(e), - status_code=None, - ) - - def run_get_paged( - self, - path: Union[str, RedfishPath], - max_pages: int = 200, - ) -> RedfishGetResult: - """Run a Redfish GET and transparently follow Members@odata.nextLink pagination. - - Each subsequent page's Members list is appended to the first page's Members list - so the caller receives a single merged response body. The Members@odata.nextLink key - and Members@odata.count are updated to reflect the merged result. If there is no - Members@odata.nextLink in the first response this behaves identically to run_get. - max_pages is a safety cap on the number of pages to fetch (default 200). - """ - first = self.run_get(path) - if not first.success or first.data is None: - return first - - # Short-circuit when there is nothing to page through. - if RF_MEMBERS_NEXT_LINK not in first.data: - return first - - merged_members: list = list(first.data.get(RF_MEMBERS) or []) - merged_data: dict = dict(first.data) - pages_fetched = 1 - next_link: Optional[str] = first.data.get(RF_MEMBERS_NEXT_LINK) - last_status_code = first.status_code - - while next_link and pages_fetched < max_pages: - page_result = self.run_get(next_link) - last_status_code = page_result.status_code - if not page_result.success or page_result.data is None: - break - merged_members.extend(page_result.data.get(RF_MEMBERS) or []) - next_link = page_result.data.get(RF_MEMBERS_NEXT_LINK) - pages_fetched += 1 - - merged_data[RF_MEMBERS] = merged_members - merged_data[RF_MEMBERS_COUNT] = len(merged_members) - merged_data.pop(RF_MEMBERS_NEXT_LINK, None) - - return RedfishGetResult( - path=first.path, - success=True, - data=merged_data, - status_code=last_status_code, - ) - - def copy(self) -> "RedfishConnection": - """Return a new connection with the same config and its own session (for concurrent use).""" - return RedfishConnection( - base_url=self.base_url, - username=self.username, - password=self.password, - timeout=self.timeout, - use_session_auth=self.use_session_auth, - verify_ssl=self.verify_ssl, - api_root=self.api_root, - ) - - def get_service_root(self) -> dict[str, Any]: - """GET service root (e.g. /redfish/v1/).""" - return self.get(RedfishPath(self.api_root)) - - def close(self) -> None: - """Release session and logout if session auth was used.""" - if self._session and self._session_uri: - try: - self._session.delete(self._session_uri, timeout=self.timeout) - except Exception: - pass - - if self._session: - self._session.close() - self._session = None - self._session_token = None - self._session_uri = None - - def __enter__(self) -> RedfishConnection: - self._ensure_session() - return self - - def __exit__(self, *args: Any) -> None: - self.close() +############################################################################### +# +# 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 __future__ import annotations + +import json +import socket +from typing import Any, Callable, ClassVar, Optional, Protocol, TypeVar, Union +from urllib.parse import urljoin, urlparse + +import requests +import urllib3 # type: ignore[import-untyped] +from pydantic import BaseModel +from requests.auth import HTTPBasicAuth + +from .redfish_constants import RF_MEMBERS, RF_MEMBERS_COUNT, RF_MEMBERS_NEXT_LINK +from .redfish_path import RedfishPath + +DEFAULT_REDFISH_API_ROOT = "redfish/v1" + +_T = TypeVar("_T") + + +class RedfishGetResult(BaseModel): + """Artifact for the result of a Redfish GET request. + Logged under the same filename as inband command artifacts (command_artifacts.json). + """ + + ARTIFACT_LOG_BASENAME: ClassVar[str] = "command_artifacts" + + path: str + success: bool + 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 RedfishPostResult(BaseModel): + """Artifact for the result of a Redfish POST request.""" + + ARTIFACT_LOG_BASENAME: ClassVar[str] = "command_artifacts" + + path: str + success: bool + data: Optional[dict[str, Any]] = None + error: Optional[str] = None + status_code: Optional[int] = None + + 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"POST {self.path}", + "stdout": stdout, + "stderr": stderr, + "exit_code": exit_code, + } + + +class RedfishHttpResponse(Protocol): + """Status, headers, and body from a Redfish GET or POST.""" + + status_code: int + headers: Any + + @property + def content(self) -> bytes: ... + + @property + def ok(self) -> bool: ... + + @property + def reason(self) -> str: ... + + @property + def text(self) -> str: ... + + def json(self) -> Any: ... + + +class RedfishConnectionError(Exception): + """Raised when a Redfish API request fails.""" + + def __init__(self, message: str, response: Optional[RedfishHttpResponse] = None): + super().__init__(message) + self.response = response + + +class RedfishConnection: + """Redfish REST client for GET requests.""" + + def __init__( + self, + base_url: str, + username: str, + password: Optional[str] = None, + timeout: float = 10.0, + use_session_auth: bool = True, + verify_ssl: bool = True, + api_root: Optional[str] = None, + ): + """Creates a RedfishConnection instance. + + Args: + base_url (str): The base url for the redfish service typically this is /redfish/v1 + username (str): The username for the redfish service. + password (Optional[str], optional): The password for the redfish service. Defaults to None. + timeout (float, optional): The timeout for redfish requests in seconds. Defaults to 10.0. + use_session_auth (bool, optional): Whether to use session-based authentication. Defaults to True. + verify_ssl (bool, optional): Whether to verify the SSL certificate. Defaults to True. + api_root (Optional[str], optional): The API root for the redfish services. Defaults to None. + """ + self.base_url: str = base_url.rstrip("/") + self.api_root: str = (api_root or DEFAULT_REDFISH_API_ROOT).strip("/") + self.username: str = username + self.password: str = password or "" + self.timeout: float = timeout + self.use_session_auth: bool = use_session_auth + self.verify_ssl: bool = verify_ssl + self._session: Optional[requests.Session] = None + self._session_token: Optional[str] = None + self._session_uri: Optional[str] = None # For logout DELETE + + def _host_label(self) -> str: + return urlparse(self.base_url).hostname or self.base_url + + @staticmethod + def _is_name_resolution_error(exc: BaseException) -> bool: + current: Optional[BaseException] = exc + seen: set[int] = set() + while current is not None and id(current) not in seen: + if isinstance(current, socket.gaierror): + return True + if type(current).__name__ == "NameResolutionError": + return True + seen.add(id(current)) + current = current.__cause__ or current.__context__ + message = str(exc) + return "Failed to resolve" in message or "No address associated with hostname" in message + + def _transport_error_message(self, exc: Exception) -> str: + host = self._host_label() + if isinstance(exc, socket.gaierror) or self._is_name_resolution_error(exc): + return f"Redfish hostname could not be resolved: {host}" + if isinstance(exc, requests.exceptions.Timeout): + return f"Redfish connection timed out: {host}" + if isinstance(exc, requests.exceptions.ConnectionError): + return f"Redfish connection failed: {host}" + return f"Redfish connection failed: {exc}" + + def _execute_request(self, request_fn: Callable[[], _T]) -> _T: + try: + return request_fn() + except ( + requests.exceptions.Timeout, + requests.exceptions.ConnectionError, + ) as exc: + raise RedfishConnectionError(self._transport_error_message(exc)) from exc + except socket.gaierror as exc: + raise RedfishConnectionError(self._transport_error_message(exc)) from exc + + def _ensure_session(self) -> requests.Session: + if self._session is None: + if not self.verify_ssl: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + self._session = requests.Session() + self._session.verify = self.verify_ssl + if not self.verify_ssl: + self._session.trust_env = False + self._session.headers["Content-Type"] = "application/json" + self._session.headers["Accept"] = "application/json" + if self.use_session_auth and self.password: + self._login_session() + elif self.password: + self._session.auth = HTTPBasicAuth(self.username, self.password) + return self._session + + def _login_session(self) -> None: + """Create a Redfish session and set X-Auth-Token.""" + session = self._session + assert session is not None + sess_url = urljoin(self.base_url + "/", f"{self.api_root}/SessionService/Sessions") + payload = {"UserName": self.username, "Password": self.password} + resp = self._execute_request( + lambda: session.post( + sess_url, + json=payload, + timeout=self.timeout, + ) + ) + if not resp.ok: + raise RedfishConnectionError( + f"Session login failed: {resp.status_code} {resp.reason}", response=resp + ) + self._session_token = resp.headers.get("X-Auth-Token") + location = resp.headers.get("Location") + if location: + self._session_uri = ( + location + if location.startswith("http") + else urljoin(self.base_url + "/", location.lstrip("/")) + ) + if self._session_token: + session.headers["X-Auth-Token"] = self._session_token + else: + session.auth = HTTPBasicAuth(self.username, self.password) + + def get(self, path: RedfishPath) -> dict[str, Any]: + """GET a Redfish path and return the JSON body. path must be a RedfishPath.""" + path_str = str(path) + resp = self.get_response(path_str) + if not resp.ok: + raise RedfishConnectionError( + f"GET {path_str} failed: {resp.status_code} {resp.reason}", + response=resp, + ) + return resp.json() + + def get_response(self, path: Union[str, "RedfishPath"]) -> RedfishHttpResponse: + """GET a Redfish path and return the raw Response. path may be a string or RedfishPath.""" + path = str(path) + session = self._ensure_session() + url = path if path.startswith("http") else urljoin(self.base_url + "/", path.lstrip("/")) + return self._execute_request(lambda: session.get(url, timeout=self.timeout)) + + def post( + self, path: Union[str, "RedfishPath"], json: Optional[dict[str, Any]] = None + ) -> RedfishHttpResponse: + """POST to a Redfish path and return the raw Response. path may be a string or RedfishPath.""" + path = str(path) + session = self._ensure_session() + url = path if path.startswith("http") else urljoin(self.base_url + "/", path.lstrip("/")) + return self._execute_request( + lambda: session.post(url, json=json or {}, timeout=self.timeout) + ) + + def run_get(self, path: Union[str, RedfishPath]) -> RedfishGetResult: + """Run a Redfish GET request and return a result object. path may be a string or RedfishPath.""" + path_norm = str(path).strip() + if not path_norm.startswith("/"): + path_norm = "/" + path_norm + path_obj = RedfishPath(path_norm.strip("/")) if isinstance(path, str) else path + try: + data = self.get(path_obj) + return RedfishGetResult( + path=path_norm, + success=True, + data=data, + status_code=200, + ) + except RedfishConnectionError as e: + status = e.response.status_code if e.response is not None else None + return RedfishGetResult( + path=path_norm, + success=False, + error=str(e), + status_code=status, + ) + except Exception as e: + return RedfishGetResult( + path=path_norm, + success=False, + error=str(e), + status_code=None, + ) + + def run_get_paged( + self, + path: Union[str, RedfishPath], + max_pages: int = 200, + ) -> RedfishGetResult: + """Run a Redfish GET and transparently follow Members@odata.nextLink pagination. + + Each subsequent page's Members list is appended to the first page's Members list + so the caller receives a single merged response body. The Members@odata.nextLink key + and Members@odata.count are updated to reflect the merged result. If there is no + Members@odata.nextLink in the first response this behaves identically to run_get. + max_pages is a safety cap on the number of pages to fetch (default 200). + """ + first = self.run_get(path) + if not first.success or first.data is None: + return first + + # Short-circuit when there is nothing to page through. + if RF_MEMBERS_NEXT_LINK not in first.data: + return first + + merged_members: list = list(first.data.get(RF_MEMBERS) or []) + merged_data: dict = dict(first.data) + pages_fetched = 1 + next_link: Optional[str] = first.data.get(RF_MEMBERS_NEXT_LINK) + last_status_code = first.status_code + + while next_link and pages_fetched < max_pages: + page_result = self.run_get(next_link) + last_status_code = page_result.status_code + if not page_result.success or page_result.data is None: + break + merged_members.extend(page_result.data.get(RF_MEMBERS) or []) + next_link = page_result.data.get(RF_MEMBERS_NEXT_LINK) + pages_fetched += 1 + + merged_data[RF_MEMBERS] = merged_members + merged_data[RF_MEMBERS_COUNT] = len(merged_members) + merged_data.pop(RF_MEMBERS_NEXT_LINK, None) + + return RedfishGetResult( + path=first.path, + success=True, + data=merged_data, + status_code=last_status_code, + ) + + def copy(self) -> "RedfishConnection": + """Return a new connection with the same config and its own session (for concurrent use).""" + return RedfishConnection( + base_url=self.base_url, + username=self.username, + password=self.password, + timeout=self.timeout, + use_session_auth=self.use_session_auth, + verify_ssl=self.verify_ssl, + api_root=self.api_root, + ) + + def get_service_root(self) -> dict[str, Any]: + """GET service root (e.g. /redfish/v1/).""" + return self.get(RedfishPath(self.api_root)) + + def close(self) -> None: + """Release session and logout if session auth was used.""" + if self._session and self._session_uri: + try: + self._session.delete(self._session_uri, timeout=self.timeout) + except Exception: + pass + + if self._session: + self._session.close() + self._session = None + self._session_token = None + self._session_uri = None + + def __enter__(self) -> RedfishConnection: + self._ensure_session() + return self + + def __exit__(self, *args: Any) -> None: + self.close() diff --git a/nodescraper/connection/redfish/redfish_manager.py b/nodescraper/connection/redfish/redfish_manager.py index 55d0ff06..681c4bb9 100644 --- a/nodescraper/connection/redfish/redfish_manager.py +++ b/nodescraper/connection/redfish/redfish_manager.py @@ -1,269 +1,269 @@ -############################################################################### -# -# 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 __future__ import annotations - -from logging import Logger -from typing import Any, Mapping, Optional, Union - -from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus -from nodescraper.interfaces.connectionmanager import ConnectionManager -from nodescraper.interfaces.taskresulthook import TaskResultHook -from nodescraper.models import SystemInfo, TaskResult - -from .redfish_connection import RedfishConnection, RedfishConnectionError -from .redfish_params import RedfishConnectionParams - - -def _build_base_url(host: str, port: Optional[int], use_https: bool) -> str: - scheme = "https" if use_https else "http" - host_str = str(host) - if port is not None: - return f"{scheme}://{host_str}:{port}" - return f"{scheme}://{host_str}" - - -class MultiTargetRedfishConnection: - """Sentinel placed on RedfishConnectionManager.connection in multi-target mode. - - Carrying this object (rather than leaving connection=None) allows the standard - DataPlugin.collect() guard to pass, while RedfishDataCollector's __init_subclass__ - wrapper intercepts collect_data and iterates the individual target connections. - """ - - def __init__( - self, - target_connections: Mapping[str, RedfishConnection], - max_workers: Optional[int] = None, - failed_targets: Optional[dict[str, str]] = None, - ) -> None: - self.target_connections = dict(target_connections) - self.multi_target_data: dict[str, Any] = {} - self.max_workers = max_workers - self.failed_targets = dict(failed_targets or {}) - - -def collected_multi_target_data(connection_manager: Any) -> dict[str, Any]: - """Return per-target data from an open connection or from the copy made at disconnect. - - Args: - connection_manager: Redfish connection manager for this plugin. - - Returns: - dict[str, Any]: Target key to collected data. Empty for single-target runs. - """ - conn = getattr(connection_manager, "connection", None) - if isinstance(conn, MultiTargetRedfishConnection) and conn.multi_target_data: - return conn.multi_target_data - return getattr(connection_manager, "_multi_target_data", None) or {} - - -class RedfishConnectionManager(ConnectionManager[RedfishConnection, RedfishConnectionParams]): - """Connection manager for Redfish (BMC) API.""" - - def __init__( - self, - system_info: SystemInfo, - logger: Optional[Logger] = None, - max_event_priority_level: Union[EventPriority, str] = EventPriority.CRITICAL, - parent: Optional[str] = None, - task_result_hooks: Optional[list[TaskResultHook]] = None, - connection_args: Optional[RedfishConnectionParams] = None, - **kwargs, - ): - """Creates a RedfishConnectionManager Task instance. - - Args: - system_info (SystemInfo): System information for the connection manager task. - logger (Optional[logging.Logger], optional): _description_. Defaults to None. - max_event_priority_level (Union[EventPriority, str], optional): _description_. Defaults to EventPriority.CRITICAL. - parent (Optional[str], optional): _description_. Defaults to None. - task_result_hooks (Optional[list[TaskResultHook], None], optional): _description_. Defaults to None. - connection_args (Optional[Union[TConnectArg, dict]], optional): _description_. Defaults to None. - kwargs (dict, optional): Additional keyword arguments passed to the parent class. - - Raises: - ValueError: Will raise a ValueError when the connection_args cannot be mapped to the expected model. - """ - super().__init__( - system_info, - logger, - max_event_priority_level, - parent, - task_result_hooks, - connection_args, - **kwargs, - ) - - def connect(self) -> TaskResult: - """Connect to Redfish (single-target or multi-target).""" - if not self.connection_args: - self._log_event( - category=EventCategory.RUNTIME, - description="No Redfish connection parameters provided", - priority=EventPriority.CRITICAL, - console_log=True, - ) - self.result.status = ExecutionStatus.EXECUTION_FAILURE - return self.result - - raw = self.connection_args - if isinstance(raw, dict): - params = RedfishConnectionParams.model_validate(raw) - elif isinstance(raw, RedfishConnectionParams): - params = raw - else: - self._log_event( - category=EventCategory.RUNTIME, - description="Redfish connection_args must be dict or RedfishConnectionParams", - priority=EventPriority.CRITICAL, - console_log=True, - ) - self.result.status = ExecutionStatus.EXECUTION_FAILURE - return self.result - - if params.is_multi_target: - self.target_connections: dict[str, RedfishConnection] = {} - failed_targets: dict[str, str] = {} - for target in params.targets: # type: ignore[union-attr] - key, conn, error = self._connect_target(target) - if conn is None: - failed_targets[key] = error or f"Redfish connection failed for target {key!r}" - continue - if key in self.target_connections: - self._log_event( - category=EventCategory.RUNTIME, - description=f"Duplicate Redfish target key {key!r}; keeping the first connection", - priority=EventPriority.WARNING, - console_log=True, - ) - conn.close() - continue - self.target_connections[key] = conn - if not self.target_connections: - self.result.status = ExecutionStatus.EXECUTION_FAILURE - lines = "\n".join(f"[{k}] {v}" for k, v in failed_targets.items()) - self.result.message = f"Redfish connection failed for every target\n{lines}" - self.result.events.clear() - return self.result - if failed_targets: - lines = "\n".join(f"[{k}] {v}" for k, v in failed_targets.items()) - self.result.status = ExecutionStatus.WARNING - self.result.message = f"Some Redfish targets failed to connect\n{lines}" - self.result.events.clear() - self.connection = MultiTargetRedfishConnection( # type: ignore[assignment] - self.target_connections, - max_workers=params.max_workers, - failed_targets=failed_targets, - ) - return self.result - - return self._connect_single(params) - - def _connect_target( - self, target: RedfishConnectionParams - ) -> tuple[str, Optional[RedfishConnection], Optional[str]]: - """Connect one target. - - Args: - target: Connection parameters for a single BMC. - - Returns: - tuple[str, Optional[RedfishConnection], Optional[str]]: Target key, connection, and error text. - """ - key = target.target_key or str(target.host) - password = target.password.get_secret_value() if target.password else None - base_url = _build_base_url(str(target.host), target.port, target.use_https) - try: - self.logger.info("Connecting to Redfish at %s (target=%r)", base_url, key) - conn = RedfishConnection( - base_url=base_url, - username=target.username or "", - password=password, - timeout=target.timeout_seconds, - use_session_auth=target.use_session_auth, - verify_ssl=target.verify_ssl, - api_root=target.api_root, - ) - conn._ensure_session() - conn.get_service_root() - return key, conn, None - except (RedfishConnectionError, Exception) as exc: # noqa: BLE001 - description = f"Redfish connection failed for target {key!r}: {exc}" - self._log_event( - category=EventCategory.RUNTIME, - description=description, - priority=EventPriority.WARNING, - console_log=True, - ) - return key, None, description - - def _connect_single(self, params: RedfishConnectionParams) -> TaskResult: - """Connect in single-target mode (original connect logic).""" - password = params.password.get_secret_value() if params.password else None - base_url = _build_base_url(str(params.host), params.port, params.use_https) - try: - self.logger.info("Connecting to Redfish at %s", base_url) - self.connection = RedfishConnection( - base_url=base_url, - username=params.username or "", - password=password, - timeout=params.timeout_seconds, - use_session_auth=params.use_session_auth, - verify_ssl=params.verify_ssl, - api_root=params.api_root, - ) - self.connection._ensure_session() - self.connection.get_service_root() - except RedfishConnectionError as exc: - self._log_event( - category=EventCategory.RUNTIME, - description=str(exc), - priority=EventPriority.CRITICAL, - console_log=True, - ) - self.result.status = ExecutionStatus.EXECUTION_FAILURE - self.connection = None - except Exception as exc: - self._log_event( - category=EventCategory.RUNTIME, - description=f"Redfish connection failed: {exc}", - priority=EventPriority.CRITICAL, - console_log=True, - ) - self.result.status = ExecutionStatus.EXECUTION_FAILURE - self.connection = None - return self.result - - def disconnect(self) -> None: - """Disconnect Redfish sessions and keep collected multi-target data on the manager.""" - if isinstance(self.connection, MultiTargetRedfishConnection): - self._multi_target_data: dict[str, Any] = dict(self.connection.multi_target_data) - for conn in self.connection.target_connections.values(): - conn.close() - self.target_connections = {} - elif self.connection is not None: - self.connection.close() - super().disconnect() +############################################################################### +# +# 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 __future__ import annotations + +from logging import Logger +from typing import Any, Mapping, Optional, Union + +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.interfaces.connectionmanager import ConnectionManager +from nodescraper.interfaces.taskresulthook import TaskResultHook +from nodescraper.models import SystemInfo, TaskResult + +from .redfish_connection import RedfishConnection, RedfishConnectionError +from .redfish_params import RedfishConnectionParams + + +def _build_base_url(host: str, port: Optional[int], use_https: bool) -> str: + scheme = "https" if use_https else "http" + host_str = str(host) + if port is not None: + return f"{scheme}://{host_str}:{port}" + return f"{scheme}://{host_str}" + + +class MultiTargetRedfishConnection: + """Sentinel placed on RedfishConnectionManager.connection in multi-target mode. + + Carrying this object (rather than leaving connection=None) allows the standard + DataPlugin.collect() guard to pass, while RedfishDataCollector's __init_subclass__ + wrapper intercepts collect_data and iterates the individual target connections. + """ + + def __init__( + self, + target_connections: Mapping[str, RedfishConnection], + max_workers: Optional[int] = None, + failed_targets: Optional[dict[str, str]] = None, + ) -> None: + self.target_connections = dict(target_connections) + self.multi_target_data: dict[str, Any] = {} + self.max_workers = max_workers + self.failed_targets = dict(failed_targets or {}) + + +def collected_multi_target_data(connection_manager: Any) -> dict[str, Any]: + """Return per-target data from an open connection or from the copy made at disconnect. + + Args: + connection_manager: Redfish connection manager for this plugin. + + Returns: + dict[str, Any]: Target key to collected data. Empty for single-target runs. + """ + conn = getattr(connection_manager, "connection", None) + if isinstance(conn, MultiTargetRedfishConnection) and conn.multi_target_data: + return conn.multi_target_data + return getattr(connection_manager, "_multi_target_data", None) or {} + + +class RedfishConnectionManager(ConnectionManager[RedfishConnection, RedfishConnectionParams]): + """Connection manager for Redfish (BMC) API.""" + + def __init__( + self, + system_info: SystemInfo, + logger: Optional[Logger] = None, + max_event_priority_level: Union[EventPriority, str] = EventPriority.CRITICAL, + parent: Optional[str] = None, + task_result_hooks: Optional[list[TaskResultHook]] = None, + connection_args: Optional[RedfishConnectionParams] = None, + **kwargs, + ): + """Creates a RedfishConnectionManager Task instance. + + Args: + system_info (SystemInfo): System information for the connection manager task. + logger (Optional[logging.Logger], optional): _description_. Defaults to None. + max_event_priority_level (Union[EventPriority, str], optional): _description_. Defaults to EventPriority.CRITICAL. + parent (Optional[str], optional): _description_. Defaults to None. + task_result_hooks (Optional[list[TaskResultHook], None], optional): _description_. Defaults to None. + connection_args (Optional[Union[TConnectArg, dict]], optional): _description_. Defaults to None. + kwargs (dict, optional): Additional keyword arguments passed to the parent class. + + Raises: + ValueError: Will raise a ValueError when the connection_args cannot be mapped to the expected model. + """ + super().__init__( + system_info, + logger, + max_event_priority_level, + parent, + task_result_hooks, + connection_args, + **kwargs, + ) + + def connect(self) -> TaskResult: + """Connect to Redfish (single-target or multi-target).""" + if not self.connection_args: + self._log_event( + category=EventCategory.RUNTIME, + description="No Redfish connection parameters provided", + priority=EventPriority.CRITICAL, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + return self.result + + raw = self.connection_args + if isinstance(raw, dict): + params = RedfishConnectionParams.model_validate(raw) + elif isinstance(raw, RedfishConnectionParams): + params = raw + else: + self._log_event( + category=EventCategory.RUNTIME, + description="Redfish connection_args must be dict or RedfishConnectionParams", + priority=EventPriority.CRITICAL, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + return self.result + + if params.is_multi_target: + self.target_connections: dict[str, RedfishConnection] = {} + failed_targets: dict[str, str] = {} + for target in params.targets: # type: ignore[union-attr] + key, conn, error = self._connect_target(target) + if conn is None: + failed_targets[key] = error or f"Redfish connection failed for target {key!r}" + continue + if key in self.target_connections: + self._log_event( + category=EventCategory.RUNTIME, + description=f"Duplicate Redfish target key {key!r}; keeping the first connection", + priority=EventPriority.WARNING, + console_log=True, + ) + conn.close() + continue + self.target_connections[key] = conn + if not self.target_connections: + self.result.status = ExecutionStatus.EXECUTION_FAILURE + lines = "\n".join(f"[{k}] {v}" for k, v in failed_targets.items()) + self.result.message = f"Redfish connection failed for every target\n{lines}" + self.result.events.clear() + return self.result + if failed_targets: + lines = "\n".join(f"[{k}] {v}" for k, v in failed_targets.items()) + self.result.status = ExecutionStatus.WARNING + self.result.message = f"Some Redfish targets failed to connect\n{lines}" + self.result.events.clear() + self.connection = MultiTargetRedfishConnection( # type: ignore[assignment] + self.target_connections, + max_workers=params.max_workers, + failed_targets=failed_targets, + ) + return self.result + + return self._connect_single(params) + + def _connect_target( + self, target: RedfishConnectionParams + ) -> tuple[str, Optional[RedfishConnection], Optional[str]]: + """Connect one target. + + Args: + target: Connection parameters for a single BMC. + + Returns: + tuple[str, Optional[RedfishConnection], Optional[str]]: Target key, connection, and error text. + """ + key = target.target_key or str(target.host) + password = target.password.get_secret_value() if target.password else None + base_url = _build_base_url(str(target.host), target.port, target.use_https) + try: + self.logger.info("Connecting to Redfish at %s (target=%r)", base_url, key) + conn = RedfishConnection( + base_url=base_url, + username=target.username or "", + password=password, + timeout=target.timeout_seconds, + use_session_auth=target.use_session_auth, + verify_ssl=target.verify_ssl, + api_root=target.api_root, + ) + conn._ensure_session() + conn.get_service_root() + return key, conn, None + except (RedfishConnectionError, Exception) as exc: # noqa: BLE001 + description = f"Redfish connection failed for target {key!r}: {exc}" + self._log_event( + category=EventCategory.RUNTIME, + description=description, + priority=EventPriority.WARNING, + console_log=True, + ) + return key, None, description + + def _connect_single(self, params: RedfishConnectionParams) -> TaskResult: + """Connect in single-target mode (original connect logic).""" + password = params.password.get_secret_value() if params.password else None + base_url = _build_base_url(str(params.host), params.port, params.use_https) + try: + self.logger.info("Connecting to Redfish at %s", base_url) + self.connection = RedfishConnection( + base_url=base_url, + username=params.username or "", + password=password, + timeout=params.timeout_seconds, + use_session_auth=params.use_session_auth, + verify_ssl=params.verify_ssl, + api_root=params.api_root, + ) + self.connection._ensure_session() + self.connection.get_service_root() + except RedfishConnectionError as exc: + self._log_event( + category=EventCategory.RUNTIME, + description=str(exc), + priority=EventPriority.CRITICAL, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + self.connection = None + except Exception as exc: + self._log_event( + category=EventCategory.RUNTIME, + description=f"Redfish connection failed: {exc}", + priority=EventPriority.CRITICAL, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + self.connection = None + return self.result + + def disconnect(self) -> None: + """Disconnect Redfish sessions and keep collected multi-target data on the manager.""" + if isinstance(self.connection, MultiTargetRedfishConnection): + self._multi_target_data: dict[str, Any] = dict(self.connection.multi_target_data) + for conn in self.connection.target_connections.values(): + conn.close() + self.target_connections = {} + elif self.connection is not None: + self.connection.close() + super().disconnect() diff --git a/nodescraper/connection/redfish/redfish_oem_diag.py b/nodescraper/connection/redfish/redfish_oem_diag.py index a04fd6f3..67561170 100644 --- a/nodescraper/connection/redfish/redfish_oem_diag.py +++ b/nodescraper/connection/redfish/redfish_oem_diag.py @@ -1,640 +1,640 @@ -############################################################################### -# -# 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 __future__ import annotations - -import json -import logging -import re -import time -from pathlib import Path -from typing import Any, Optional - -from requests.status_codes import codes - -from nodescraper.enums import TaskState - -from .redfish_connection import ( - RedfishConnection, - RedfishConnectionError, - RedfishHttpResponse, -) -from .redfish_constants import RF_ODATA_ID -from .redfish_path import RedfishPath - -_module_logger = logging.getLogger(__name__) - -_LOG_RESPONSE_BODY_LIMIT = 1500 - - -def _log_collect_diag_response( - log: logging.Logger, - status: int, - body: Any, - raw_text: str = "", -) -> None: - """Log CollectDiagnosticData response at INFO when no Location/TaskMonitor found.""" - if isinstance(body, dict): - snippet = json.dumps(body, indent=2) - else: - snippet = raw_text or str(body) - if len(snippet) > _LOG_RESPONSE_BODY_LIMIT: - snippet = snippet[:_LOG_RESPONSE_BODY_LIMIT] + "... (truncated)" - log.info( - "CollectDiagnosticData response (no Location/TaskMonitor): status=%s body=%s", - status, - snippet, - ) - - -# @Redfish.AllowableValues: Redfish annotation for the list of allowable values for a string -REDFISH_ANNOTATION_ALLOWABLE_VALUES = "Redfish.AllowableValues" - -# OEMDiagnosticDataType: LogService CollectDiagnosticData action parameter -OEM_DIAGNOSTIC_DATA_TYPE_PARAM = "OEMDiagnosticDataType" - -RF_ANNOTATION_ALLOWABLE = f"{OEM_DIAGNOSTIC_DATA_TYPE_PARAM}@{REDFISH_ANNOTATION_ALLOWABLE_VALUES}" - -# Default max wait for BMC task (seconds) -DEFAULT_TASK_TIMEOUT_S = 1800 - - -def get_oem_diagnostic_allowable_values( - conn: RedfishConnection, - log_service_path: str, -) -> Optional[list[str]]: - """GET the LogService and return OEMDiagnosticDataType@Redfish.AllowableValues if present. - - Args: - conn: Redfish connection (session established). - log_service_path: Path to the LogService (e.g. redfish/v1/Systems/UBB/LogServices/DiagLogs). - - Returns: - List of allowable type strings, or None if not found / GET failed. - """ - path = log_service_path.strip().strip("/") - try: - data = conn.get(RedfishPath(path)) - except RedfishConnectionError: - return None - if not isinstance(data, dict): - return None - actions = data.get("Actions") or {} - collect_action = actions.get("LogService.CollectDiagnosticData") or actions.get( - "#LogService.CollectDiagnosticData" - ) - if isinstance(collect_action, dict): - allow = collect_action.get(RF_ANNOTATION_ALLOWABLE) - if isinstance(allow, list) and all(isinstance(x, str) for x in allow): - return list(allow) - return None - - -def _resolve_path(conn: RedfishConnection, path: str) -> str: - """Return full URL for a path (relative to base_url).""" - if path.startswith("http"): - return path - path = path.lstrip("/") - base = conn.base_url.rstrip("/") - return f"{base}/{path}" - - -def _get_path_from_connection(conn: RedfishConnection, path: str) -> str: - """Return path relative to BMC (no host). For use with conn.get_response(path).""" - if path.startswith("http"): - # Strip base URL to get path under /redfish/v1/... - base = conn.base_url.rstrip("/") - if path.startswith(base + "/"): - return path[len(base) :].lstrip("/") - return path - return path.lstrip("/") - - -def _get_task_monitor_uri(body: dict, conn: RedfishConnection) -> Optional[str]: - """Extract task monitor URI from a Task-like body (DSP0266 or OEM variants). - - TaskMonitor may be a string URI or an object with @odata.id (e.g. TaskService/TaskMonitors/378). - """ - - def _resolve_uri(uri: str) -> str: - if not uri.startswith("http"): - uri = _resolve_path(conn, uri.strip().lstrip("/")) - return uri - - for key in ("TaskMonitor", "Monitor", "TaskMonitorUri"): - val = body.get(key) - if isinstance(val, str) and val.strip(): - return _resolve_uri(val) - if isinstance(val, dict): - odata_id = val.get(RF_ODATA_ID) - if isinstance(odata_id, str) and odata_id.strip(): - return _resolve_uri(odata_id) - oem = body.get("Oem") - if isinstance(oem, dict): - for vendor_dict in oem.values(): - if isinstance(vendor_dict, dict): - for k in ("TaskMonitor", "Monitor", "TaskMonitorUri"): - val = vendor_dict.get(k) - if isinstance(val, str) and val.strip(): - return _resolve_uri(val) - if isinstance(val, dict): - odata_id = val.get(RF_ODATA_ID) - if isinstance(odata_id, str) and odata_id.strip(): - return _resolve_uri(odata_id) - odata_id = body.get(RF_ODATA_ID) - if isinstance(odata_id, str) and odata_id.strip(): - base_uri = _resolve_uri(odata_id) - return f"{base_uri.rstrip('/')}/Monitor" - return None - - -def _task_resource_path(path: str) -> Optional[str]: - """Return a TaskService/Tasks path when path is a Task member, else None. - - Args: - path: Absolute or relative Redfish URI. - - Returns: - Normalized Task path, or None. - """ - stripped = path.strip().lstrip("/") - if "TaskService/Tasks/" in stripped and "TaskMonitors" not in stripped: - return stripped - return None - - -def _poll_task_resource( - conn: RedfishConnection, - task_path: str, - timeout_s: int, - sleep_s: int, -) -> tuple[Optional[dict[str, Any]], Optional[str]]: - """GET a Task resource until TaskState is Completed or a terminal failure. - - Args: - conn: Redfish connection. - task_path: Path to a TaskService/Tasks member. - timeout_s: Max seconds to wait. - sleep_s: Seconds between GETs. - - Returns: - (task JSON, None) on success, or (None, error). - """ - start = time.time() - interval = max(int(sleep_s), 1) - while True: - if time.time() - start > timeout_s: - return None, f"Task did not complete within {timeout_s}s" - try: - poll_resp = conn.get_response(task_path) - except RedfishConnectionError as exc: - return None, str(exc) - if poll_resp.status_code == codes.ok: - try: - body = poll_resp.json() - except Exception: - body = {} - if isinstance(body, dict): - state = body.get("TaskState") - if state == TaskState.completed.value: - return body, None - if state in ( - TaskState.exception.value, - TaskState.cancelled.value, - TaskState.killed.value, - ): - return None, f"Task did not complete: TaskState={state}" - elif poll_resp.status_code != codes.accepted: - return None, f"Task GET failed: {poll_resp.status_code}" - time.sleep(interval) - - -# Workaround for LogEntry URL: some BMCs 404 when URL includes port -def _strip_port_from_url(url: str) -> Optional[str]: - """Return URL with port removed from authority (e.g. host:443 -> host).""" - if re.search(r"://[^/]+:\d+", url): - return re.sub(r"(://[^:/]+):\d+", r"\1", url, count=1) - return None - - -_LOG_ENTRY_GET_ATTEMPTS = 4 -_LOG_ENTRY_GET_RETRY_SLEEP_S = 2 - - -def _entries_collection_path(log_entry_path: str) -> Optional[str]: - """Return the Entries collection path for a LogEntry URI. - - Args: - log_entry_path (str): Task Location path or URL for a LogEntry. - - Returns: - Optional[str]: collection path such as redfish/v1/.../Dump/Entries, or None. - """ - path = log_entry_path.split("?", 1)[0] - if "://" in path: - path = path.split("://", 1)[1] - path = path.split("/", 1)[1] if "/" in path else path - path = path.lstrip("/") - if "/Entries/" not in f"/{path}": - return None - return path[: path.rfind("/Entries/") + len("/Entries")].lstrip("/") - - -def _entry_id_from_path(log_entry_path: str) -> Optional[str]: - """Return the LogEntry Id from a Location path. - - Args: - log_entry_path (str): Task Location path or URL. - - Returns: - Optional[str]: entry Id, or None. - """ - tail = log_entry_path.split("?", 1)[0].rstrip("/").rsplit("/", 1) - if len(tail) != 2 or tail[1] in ("", "Entries"): - return None - return tail[1] - - -def _pick_member_href(body: dict[str, Any], wanted_id: Optional[str]) -> Optional[str]: - """Pick a Members @odata.id, preferring wanted_id then the last member. - - Args: - body (dict[str, Any]): Entries collection JSON. - wanted_id (Optional[str]): LogEntry Id from the task Location. - - Returns: - Optional[str]: member href, or None. - """ - members = body.get("Members") or [] - hrefs: list[str] = [] - for member in members: - if not isinstance(member, dict): - continue - href = member.get(RF_ODATA_ID) - if isinstance(href, str) and href.strip(): - hrefs.append(href.strip()) - extra = member.get("Id") - if wanted_id and extra is not None and str(extra) == wanted_id: - return href.strip() - if wanted_id: - suffix = "/" + wanted_id - for href in hrefs: - if href.rstrip("/").endswith(suffix): - return href - return None - return hrefs[-1] if hrefs else None - - -def _get_json_if_ok(conn: RedfishConnection, path: str) -> tuple[Optional[dict[str, Any]], int]: - """GET path and return JSON when the status is 200. - - Args: - conn (RedfishConnection): Redfish connection. - path (str): relative Redfish path. - - Returns: - tuple[Optional[dict[str, Any]], int]: parsed object (or None) and status. - """ - resp = conn.get_response(path) - if resp.status_code != codes.ok: - return None, resp.status_code - try: - body = resp.json() - except Exception: - return None, resp.status_code - if isinstance(body, dict): - return body, resp.status_code - return None, resp.status_code - - -def _fetch_log_entry_json( - conn: RedfishConnection, - log_entry_path: str, - log: logging.Logger, -) -> tuple[Optional[dict[str, Any]], Optional[int], Optional[str]]: - """GET a LogEntry, retrying 404s and falling back to the Entries collection. - - Args: - conn (RedfishConnection): Redfish connection. - log_entry_path (str): path from the completed task Location header. - log (logging.Logger): logger. - - Returns: - tuple[Optional[dict[str, Any]], Optional[int], Optional[str]]: entry JSON, - last HTTP status, and last exception string. - """ - paths_to_try: list[str] = [] - log_entry_alt = _strip_port_from_url(log_entry_path) - if log_entry_alt is None and not log_entry_path.startswith("http"): - log_entry_alt = _strip_port_from_url( - conn.base_url.rstrip("/") + "/" + log_entry_path.lstrip("/") - ) - if log_entry_alt: - paths_to_try.append(_get_path_from_connection(conn, log_entry_alt)) - rel_path = _get_path_from_connection(conn, log_entry_path) - if rel_path not in paths_to_try: - paths_to_try.append(rel_path) - - last_status: Optional[int] = codes.not_found - last_error = "" - for attempt in range(_LOG_ENTRY_GET_ATTEMPTS): - for try_path in paths_to_try: - if not try_path: - continue - try: - body, last_status = _get_json_if_ok(conn, try_path) - if body is not None: - return body, codes.ok, None - except Exception as e: - last_status = None - last_error = str(e) - if attempt < _LOG_ENTRY_GET_ATTEMPTS - 1: - time.sleep(_LOG_ENTRY_GET_RETRY_SLEEP_S) - - coll_path = _entries_collection_path(rel_path) - wanted_id = _entry_id_from_path(rel_path) - if coll_path: - log.info("LogEntry GET 404; listing %s for Id %s", coll_path, wanted_id) - try: - coll, coll_status = _get_json_if_ok(conn, coll_path) - except Exception as e: - last_error = str(e) - coll = None - coll_status = None - if coll is not None: - href = _pick_member_href(coll, wanted_id) - if href: - member_path = _get_path_from_connection(conn, href) - try: - body, member_status = _get_json_if_ok(conn, member_path) - if body is not None: - return body, codes.ok, None - extra = None - for member in coll.get("Members") or []: - if isinstance(member, dict) and member.get(RF_ODATA_ID) == href: - extra = member - break - if extra and extra.get("AdditionalDataURI"): - return extra, codes.ok, None - last_error = f"member GET status {member_status} for {member_path}" - except Exception as e: - last_error = str(e) - else: - last_error = f"Id {wanted_id} not in {coll_path}" - elif coll_status is not None: - last_error = f"Entries collection GET status {coll_status}" - - return None, last_status, last_error or None - - -def _download_log_and_save( - conn: RedfishConnection, - log_entry_json: dict[str, Any], - oem_diagnostic_type: str, - output_dir: Optional[Path], - log: logging.Logger, -) -> Optional[bytes]: - # Download binary log if AdditionalDataURI present - log_bytes: Optional[bytes] = None - data_uri = log_entry_json.get("AdditionalDataURI") - if data_uri: - data_path = _get_path_from_connection(conn, data_uri) - data_resp = conn.get_response(data_path) - if data_resp.status_code == codes.ok: - log_bytes = data_resp.content - - if output_dir: - output_dir = Path(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - if log_bytes is not None: - archive_path = output_dir / f"{oem_diagnostic_type}.tar.xz" - archive_path.write_bytes(log_bytes) - log.info("Log written to disk: %s -> %s", oem_diagnostic_type, archive_path.name) - metadata_file = output_dir / f"{oem_diagnostic_type}_log_entry.json" - try: - metadata_file.write_text(json.dumps(log_entry_json, indent=2), encoding="utf-8") - log.info( - "Log metadata written to disk: %s -> %s", - oem_diagnostic_type, - metadata_file.name, - ) - except Exception as e: - log.exception("Failed to write log metadata to %s: %s", metadata_file, e) - - return log_bytes - - -def collect_oem_diagnostic_data( - conn: RedfishConnection, - log_service_path: str, - oem_diagnostic_type: Optional[str] = None, - task_timeout_s: int = DEFAULT_TASK_TIMEOUT_S, - output_dir: Optional[Path] = None, - validate_type: bool = False, - allowed_types: Optional[list[str]] = None, - logger: Optional[logging.Logger] = None, - diagnostic_data_type: str = "OEM", -) -> tuple[Optional[bytes], Optional[dict[str, Any]], Optional[str]]: - """ - Initiate CollectDiagnosticData, poll until done, download log and metadata. - - Args: - conn: Redfish connection (session already established). - log_service_path: Path to LogService under Systems, e.g. - "redfish/v1/Systems/UBB/LogServices/DiagLogs" (no leading slash). - oem_diagnostic_type: OEM type when diagnostic_data_type is OEM (e.g. JournalControl, AllLogs). - task_timeout_s: Max seconds to wait for BMC task - output_dir: If set, save log archive and LogEntry JSON here. - validate_type: If True, require oem_diagnostic_type to be in allowed_types. - allowed_types: Allowable OEM diagnostic types for validation when validate_type is True. - logger: Logger - diagnostic_data_type: DMTF DiagnosticDataType (OEM, Manager, and similar). - - Returns: - (log_bytes, log_entry_metadata_dict, error_message). - On success: (bytes, dict, None). On failure: (None, None, error_str). - """ - SLEEP_S_DEFAULT = 1 - log = logger if logger is not None else _module_logger - diag_type = (diagnostic_data_type or "OEM").strip() or "OEM" - oem_type = (oem_diagnostic_type or "").strip() - if diag_type == "OEM" and not oem_type: - return None, None, "oem_diagnostic_type is required" - if validate_type and allowed_types and oem_type and oem_type not in allowed_types: - return ( - None, - None, - f"oem_diagnostic_type {oem_type!r} not in allowed types", - ) - path_prefix = log_service_path.rstrip("/") - action_path = f"{path_prefix}/Actions/LogService.CollectDiagnosticData" - payload: dict[str, Any] = {"DiagnosticDataType": diag_type} - if oem_type: - payload["OEMDiagnosticDataType"] = oem_type - - try: - resp: RedfishHttpResponse = conn.post(action_path, json=payload) - except RedfishConnectionError as e: - return None, None, str(e) - - if resp.status_code not in (codes.ok, codes.accepted): - return ( - None, - None, - f"Unexpected status {resp.status_code} for CollectDiagnosticData: {resp.text}", - ) - - # DSP0266 12.2: 202 shall include Location (task monitor URI), optionally Retry-After - location_header = resp.headers.get("Location") or resp.headers.get("Content-Location") - if location_header and not location_header.startswith("http"): - location_header = _resolve_path(conn, location_header) - try: - sleep_s = int(resp.headers.get("Retry-After", SLEEP_S_DEFAULT) or SLEEP_S_DEFAULT) - except ValueError: - sleep_s = SLEEP_S_DEFAULT - try: - oem_response = resp.json() - except Exception: - oem_response = {} - - # 200 OK with TaskState=Completed: synchronous completion, body is the Task - task_json: Optional[dict[str, Any]] = None - if resp.status_code == codes.ok and isinstance(oem_response, dict): - if oem_response.get("TaskState") == TaskState.completed.value: - headers_list = oem_response.get("Payload", {}).get("HttpHeaders", []) or [] - if any(isinstance(h, str) and "Location:" in h for h in headers_list): - task_json = oem_response - - task_monitor: Optional[str] = None - task_path: Optional[str] = None - if task_json is None: - if isinstance(oem_response, dict) and oem_response.get(RF_ODATA_ID): - task_path = _task_resource_path( - _get_path_from_connection(conn, oem_response[RF_ODATA_ID]) - ) - if location_header: - loc_path = _get_path_from_connection(conn, location_header) - loc_task = _task_resource_path(loc_path) - if loc_task: - task_path = loc_task - else: - task_monitor = location_header - if not task_monitor and not task_path and isinstance(oem_response, dict): - task_monitor = _get_task_monitor_uri(oem_response, conn) - if task_path: - task_json, poll_err = _poll_task_resource(conn, task_path, task_timeout_s, sleep_s) - if poll_err: - return None, None, poll_err - elif task_monitor: - start = time.time() - poll_resp = None - while True: - if time.time() - start > task_timeout_s: - return None, None, f"Task did not complete within {task_timeout_s}s" - monitor_path = _get_path_from_connection(conn, task_monitor) - try: - poll_resp = conn.get_response(monitor_path) - except RedfishConnectionError as exc: - return None, None, str(exc) - if poll_resp.status_code == codes.not_found: - return None, None, f"TaskMonitor GET failed: status {codes.not_found}" - if poll_resp.status_code != codes.accepted: - break - time.sleep(max(int(sleep_s), 1)) - try: - monitor_body = poll_resp.json() if poll_resp else {} - except Exception: - monitor_body = {} - task_uri_from_monitor = ( - monitor_body.get(RF_ODATA_ID) if isinstance(monitor_body, dict) else None - ) - if isinstance(task_uri_from_monitor, str) and task_uri_from_monitor.strip(): - follow_path = _get_path_from_connection(conn, task_uri_from_monitor.strip()) - else: - follow_path = _get_path_from_connection( - conn, task_monitor.rstrip("/").rsplit("/", 1)[0] - ) - follow_task = _task_resource_path(follow_path) - if follow_task: - task_json, poll_err = _poll_task_resource( - conn, follow_task, task_timeout_s, sleep_s - ) - if poll_err: - return None, None, poll_err - else: - try: - task_resp = conn.get_response(follow_path) - except RedfishConnectionError as exc: - return None, None, str(exc) - if task_resp.status_code != codes.ok: - return None, None, f"Task GET failed: {task_resp.status_code}" - task_json = task_resp.json() - if task_json.get("TaskState") != TaskState.completed.value: - return ( - None, - None, - f"Task did not complete: TaskState={task_json.get('TaskState')}", - ) - else: - _log_collect_diag_response( - log, resp.status_code, oem_response, getattr(resp, "text", "") or "" - ) - return None, None, "No TaskMonitor in response and no Location header" - - if not isinstance(task_json, dict): - return None, None, "Task did not complete: missing task body" - - # LogEntry location from Payload.HttpHeaders - headers_list = task_json.get("Payload", {}).get("HttpHeaders", []) or [] - location = None - for header in headers_list: - if isinstance(header, str) and "Location:" in header: - location = header.split("Location:", 1)[-1].strip() - break - if not location: - return None, None, "Location header missing in task Payload.HttpHeaders" - if location.startswith("http"): - log_entry_path = location - else: - log_entry_path = location.lstrip("/") - - log_entry_json, first_status, first_error = _fetch_log_entry_json(conn, log_entry_path, log) - if log_entry_json is None: - if first_error: - err = first_error - if first_status is not None: - err = f"{first_error} (status {first_status})" - elif first_status is not None: - err = f"status {first_status}" - else: - err = "unknown" - return None, None, f"LogEntry GET failed: {err} (GET {log_entry_path})" - - file_stem = oem_type or diag_type - try: - log_bytes = _download_log_and_save(conn, log_entry_json, file_stem, output_dir, log) - except RedfishConnectionError as exc: - return None, None, str(exc) - return log_bytes, log_entry_json, None +############################################################################### +# +# 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 __future__ import annotations + +import json +import logging +import re +import time +from pathlib import Path +from typing import Any, Optional + +from requests.status_codes import codes + +from nodescraper.enums import TaskState + +from .redfish_connection import ( + RedfishConnection, + RedfishConnectionError, + RedfishHttpResponse, +) +from .redfish_constants import RF_ODATA_ID +from .redfish_path import RedfishPath + +_module_logger = logging.getLogger(__name__) + +_LOG_RESPONSE_BODY_LIMIT = 1500 + + +def _log_collect_diag_response( + log: logging.Logger, + status: int, + body: Any, + raw_text: str = "", +) -> None: + """Log CollectDiagnosticData response at INFO when no Location/TaskMonitor found.""" + if isinstance(body, dict): + snippet = json.dumps(body, indent=2) + else: + snippet = raw_text or str(body) + if len(snippet) > _LOG_RESPONSE_BODY_LIMIT: + snippet = snippet[:_LOG_RESPONSE_BODY_LIMIT] + "... (truncated)" + log.info( + "CollectDiagnosticData response (no Location/TaskMonitor): status=%s body=%s", + status, + snippet, + ) + + +# @Redfish.AllowableValues: Redfish annotation for the list of allowable values for a string +REDFISH_ANNOTATION_ALLOWABLE_VALUES = "Redfish.AllowableValues" + +# OEMDiagnosticDataType: LogService CollectDiagnosticData action parameter +OEM_DIAGNOSTIC_DATA_TYPE_PARAM = "OEMDiagnosticDataType" + +RF_ANNOTATION_ALLOWABLE = f"{OEM_DIAGNOSTIC_DATA_TYPE_PARAM}@{REDFISH_ANNOTATION_ALLOWABLE_VALUES}" + +# Default max wait for BMC task (seconds) +DEFAULT_TASK_TIMEOUT_S = 1800 + + +def get_oem_diagnostic_allowable_values( + conn: RedfishConnection, + log_service_path: str, +) -> Optional[list[str]]: + """GET the LogService and return OEMDiagnosticDataType@Redfish.AllowableValues if present. + + Args: + conn: Redfish connection (session established). + log_service_path: Path to the LogService (e.g. redfish/v1/Systems/UBB/LogServices/DiagLogs). + + Returns: + List of allowable type strings, or None if not found / GET failed. + """ + path = log_service_path.strip().strip("/") + try: + data = conn.get(RedfishPath(path)) + except RedfishConnectionError: + return None + if not isinstance(data, dict): + return None + actions = data.get("Actions") or {} + collect_action = actions.get("LogService.CollectDiagnosticData") or actions.get( + "#LogService.CollectDiagnosticData" + ) + if isinstance(collect_action, dict): + allow = collect_action.get(RF_ANNOTATION_ALLOWABLE) + if isinstance(allow, list) and all(isinstance(x, str) for x in allow): + return list(allow) + return None + + +def _resolve_path(conn: RedfishConnection, path: str) -> str: + """Return full URL for a path (relative to base_url).""" + if path.startswith("http"): + return path + path = path.lstrip("/") + base = conn.base_url.rstrip("/") + return f"{base}/{path}" + + +def _get_path_from_connection(conn: RedfishConnection, path: str) -> str: + """Return path relative to BMC (no host). For use with conn.get_response(path).""" + if path.startswith("http"): + # Strip base URL to get path under /redfish/v1/... + base = conn.base_url.rstrip("/") + if path.startswith(base + "/"): + return path[len(base) :].lstrip("/") + return path + return path.lstrip("/") + + +def _get_task_monitor_uri(body: dict, conn: RedfishConnection) -> Optional[str]: + """Extract task monitor URI from a Task-like body (DSP0266 or OEM variants). + + TaskMonitor may be a string URI or an object with @odata.id (e.g. TaskService/TaskMonitors/378). + """ + + def _resolve_uri(uri: str) -> str: + if not uri.startswith("http"): + uri = _resolve_path(conn, uri.strip().lstrip("/")) + return uri + + for key in ("TaskMonitor", "Monitor", "TaskMonitorUri"): + val = body.get(key) + if isinstance(val, str) and val.strip(): + return _resolve_uri(val) + if isinstance(val, dict): + odata_id = val.get(RF_ODATA_ID) + if isinstance(odata_id, str) and odata_id.strip(): + return _resolve_uri(odata_id) + oem = body.get("Oem") + if isinstance(oem, dict): + for vendor_dict in oem.values(): + if isinstance(vendor_dict, dict): + for k in ("TaskMonitor", "Monitor", "TaskMonitorUri"): + val = vendor_dict.get(k) + if isinstance(val, str) and val.strip(): + return _resolve_uri(val) + if isinstance(val, dict): + odata_id = val.get(RF_ODATA_ID) + if isinstance(odata_id, str) and odata_id.strip(): + return _resolve_uri(odata_id) + odata_id = body.get(RF_ODATA_ID) + if isinstance(odata_id, str) and odata_id.strip(): + base_uri = _resolve_uri(odata_id) + return f"{base_uri.rstrip('/')}/Monitor" + return None + + +def _task_resource_path(path: str) -> Optional[str]: + """Return a TaskService/Tasks path when path is a Task member, else None. + + Args: + path: Absolute or relative Redfish URI. + + Returns: + Normalized Task path, or None. + """ + stripped = path.strip().lstrip("/") + if "TaskService/Tasks/" in stripped and "TaskMonitors" not in stripped: + return stripped + return None + + +def _poll_task_resource( + conn: RedfishConnection, + task_path: str, + timeout_s: int, + sleep_s: int, +) -> tuple[Optional[dict[str, Any]], Optional[str]]: + """GET a Task resource until TaskState is Completed or a terminal failure. + + Args: + conn: Redfish connection. + task_path: Path to a TaskService/Tasks member. + timeout_s: Max seconds to wait. + sleep_s: Seconds between GETs. + + Returns: + (task JSON, None) on success, or (None, error). + """ + start = time.time() + interval = max(int(sleep_s), 1) + while True: + if time.time() - start > timeout_s: + return None, f"Task did not complete within {timeout_s}s" + try: + poll_resp = conn.get_response(task_path) + except RedfishConnectionError as exc: + return None, str(exc) + if poll_resp.status_code == codes.ok: + try: + body = poll_resp.json() + except Exception: + body = {} + if isinstance(body, dict): + state = body.get("TaskState") + if state == TaskState.completed.value: + return body, None + if state in ( + TaskState.exception.value, + TaskState.cancelled.value, + TaskState.killed.value, + ): + return None, f"Task did not complete: TaskState={state}" + elif poll_resp.status_code != codes.accepted: + return None, f"Task GET failed: {poll_resp.status_code}" + time.sleep(interval) + + +# Workaround for LogEntry URL: some BMCs 404 when URL includes port +def _strip_port_from_url(url: str) -> Optional[str]: + """Return URL with port removed from authority (e.g. host:443 -> host).""" + if re.search(r"://[^/]+:\d+", url): + return re.sub(r"(://[^:/]+):\d+", r"\1", url, count=1) + return None + + +_LOG_ENTRY_GET_ATTEMPTS = 4 +_LOG_ENTRY_GET_RETRY_SLEEP_S = 2 + + +def _entries_collection_path(log_entry_path: str) -> Optional[str]: + """Return the Entries collection path for a LogEntry URI. + + Args: + log_entry_path (str): Task Location path or URL for a LogEntry. + + Returns: + Optional[str]: collection path such as redfish/v1/.../Dump/Entries, or None. + """ + path = log_entry_path.split("?", 1)[0] + if "://" in path: + path = path.split("://", 1)[1] + path = path.split("/", 1)[1] if "/" in path else path + path = path.lstrip("/") + if "/Entries/" not in f"/{path}": + return None + return path[: path.rfind("/Entries/") + len("/Entries")].lstrip("/") + + +def _entry_id_from_path(log_entry_path: str) -> Optional[str]: + """Return the LogEntry Id from a Location path. + + Args: + log_entry_path (str): Task Location path or URL. + + Returns: + Optional[str]: entry Id, or None. + """ + tail = log_entry_path.split("?", 1)[0].rstrip("/").rsplit("/", 1) + if len(tail) != 2 or tail[1] in ("", "Entries"): + return None + return tail[1] + + +def _pick_member_href(body: dict[str, Any], wanted_id: Optional[str]) -> Optional[str]: + """Pick a Members @odata.id, preferring wanted_id then the last member. + + Args: + body (dict[str, Any]): Entries collection JSON. + wanted_id (Optional[str]): LogEntry Id from the task Location. + + Returns: + Optional[str]: member href, or None. + """ + members = body.get("Members") or [] + hrefs: list[str] = [] + for member in members: + if not isinstance(member, dict): + continue + href = member.get(RF_ODATA_ID) + if isinstance(href, str) and href.strip(): + hrefs.append(href.strip()) + extra = member.get("Id") + if wanted_id and extra is not None and str(extra) == wanted_id: + return href.strip() + if wanted_id: + suffix = "/" + wanted_id + for href in hrefs: + if href.rstrip("/").endswith(suffix): + return href + return None + return hrefs[-1] if hrefs else None + + +def _get_json_if_ok(conn: RedfishConnection, path: str) -> tuple[Optional[dict[str, Any]], int]: + """GET path and return JSON when the status is 200. + + Args: + conn (RedfishConnection): Redfish connection. + path (str): relative Redfish path. + + Returns: + tuple[Optional[dict[str, Any]], int]: parsed object (or None) and status. + """ + resp = conn.get_response(path) + if resp.status_code != codes.ok: + return None, resp.status_code + try: + body = resp.json() + except Exception: + return None, resp.status_code + if isinstance(body, dict): + return body, resp.status_code + return None, resp.status_code + + +def _fetch_log_entry_json( + conn: RedfishConnection, + log_entry_path: str, + log: logging.Logger, +) -> tuple[Optional[dict[str, Any]], Optional[int], Optional[str]]: + """GET a LogEntry, retrying 404s and falling back to the Entries collection. + + Args: + conn (RedfishConnection): Redfish connection. + log_entry_path (str): path from the completed task Location header. + log (logging.Logger): logger. + + Returns: + tuple[Optional[dict[str, Any]], Optional[int], Optional[str]]: entry JSON, + last HTTP status, and last exception string. + """ + paths_to_try: list[str] = [] + log_entry_alt = _strip_port_from_url(log_entry_path) + if log_entry_alt is None and not log_entry_path.startswith("http"): + log_entry_alt = _strip_port_from_url( + conn.base_url.rstrip("/") + "/" + log_entry_path.lstrip("/") + ) + if log_entry_alt: + paths_to_try.append(_get_path_from_connection(conn, log_entry_alt)) + rel_path = _get_path_from_connection(conn, log_entry_path) + if rel_path not in paths_to_try: + paths_to_try.append(rel_path) + + last_status: Optional[int] = codes.not_found + last_error = "" + for attempt in range(_LOG_ENTRY_GET_ATTEMPTS): + for try_path in paths_to_try: + if not try_path: + continue + try: + body, last_status = _get_json_if_ok(conn, try_path) + if body is not None: + return body, codes.ok, None + except Exception as e: + last_status = None + last_error = str(e) + if attempt < _LOG_ENTRY_GET_ATTEMPTS - 1: + time.sleep(_LOG_ENTRY_GET_RETRY_SLEEP_S) + + coll_path = _entries_collection_path(rel_path) + wanted_id = _entry_id_from_path(rel_path) + if coll_path: + log.info("LogEntry GET 404; listing %s for Id %s", coll_path, wanted_id) + try: + coll, coll_status = _get_json_if_ok(conn, coll_path) + except Exception as e: + last_error = str(e) + coll = None + coll_status = None + if coll is not None: + href = _pick_member_href(coll, wanted_id) + if href: + member_path = _get_path_from_connection(conn, href) + try: + body, member_status = _get_json_if_ok(conn, member_path) + if body is not None: + return body, codes.ok, None + extra = None + for member in coll.get("Members") or []: + if isinstance(member, dict) and member.get(RF_ODATA_ID) == href: + extra = member + break + if extra and extra.get("AdditionalDataURI"): + return extra, codes.ok, None + last_error = f"member GET status {member_status} for {member_path}" + except Exception as e: + last_error = str(e) + else: + last_error = f"Id {wanted_id} not in {coll_path}" + elif coll_status is not None: + last_error = f"Entries collection GET status {coll_status}" + + return None, last_status, last_error or None + + +def _download_log_and_save( + conn: RedfishConnection, + log_entry_json: dict[str, Any], + oem_diagnostic_type: str, + output_dir: Optional[Path], + log: logging.Logger, +) -> Optional[bytes]: + # Download binary log if AdditionalDataURI present + log_bytes: Optional[bytes] = None + data_uri = log_entry_json.get("AdditionalDataURI") + if data_uri: + data_path = _get_path_from_connection(conn, data_uri) + data_resp = conn.get_response(data_path) + if data_resp.status_code == codes.ok: + log_bytes = data_resp.content + + if output_dir: + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + if log_bytes is not None: + archive_path = output_dir / f"{oem_diagnostic_type}.tar.xz" + archive_path.write_bytes(log_bytes) + log.info("Log written to disk: %s -> %s", oem_diagnostic_type, archive_path.name) + metadata_file = output_dir / f"{oem_diagnostic_type}_log_entry.json" + try: + metadata_file.write_text(json.dumps(log_entry_json, indent=2), encoding="utf-8") + log.info( + "Log metadata written to disk: %s -> %s", + oem_diagnostic_type, + metadata_file.name, + ) + except Exception as e: + log.exception("Failed to write log metadata to %s: %s", metadata_file, e) + + return log_bytes + + +def collect_oem_diagnostic_data( + conn: RedfishConnection, + log_service_path: str, + oem_diagnostic_type: Optional[str] = None, + task_timeout_s: int = DEFAULT_TASK_TIMEOUT_S, + output_dir: Optional[Path] = None, + validate_type: bool = False, + allowed_types: Optional[list[str]] = None, + logger: Optional[logging.Logger] = None, + diagnostic_data_type: str = "OEM", +) -> tuple[Optional[bytes], Optional[dict[str, Any]], Optional[str]]: + """ + Initiate CollectDiagnosticData, poll until done, download log and metadata. + + Args: + conn: Redfish connection (session already established). + log_service_path: Path to LogService under Systems, e.g. + "redfish/v1/Systems/UBB/LogServices/DiagLogs" (no leading slash). + oem_diagnostic_type: OEM type when diagnostic_data_type is OEM (e.g. JournalControl, AllLogs). + task_timeout_s: Max seconds to wait for BMC task + output_dir: If set, save log archive and LogEntry JSON here. + validate_type: If True, require oem_diagnostic_type to be in allowed_types. + allowed_types: Allowable OEM diagnostic types for validation when validate_type is True. + logger: Logger + diagnostic_data_type: DMTF DiagnosticDataType (OEM, Manager, and similar). + + Returns: + (log_bytes, log_entry_metadata_dict, error_message). + On success: (bytes, dict, None). On failure: (None, None, error_str). + """ + SLEEP_S_DEFAULT = 1 + log = logger if logger is not None else _module_logger + diag_type = (diagnostic_data_type or "OEM").strip() or "OEM" + oem_type = (oem_diagnostic_type or "").strip() + if diag_type == "OEM" and not oem_type: + return None, None, "oem_diagnostic_type is required" + if validate_type and allowed_types and oem_type and oem_type not in allowed_types: + return ( + None, + None, + f"oem_diagnostic_type {oem_type!r} not in allowed types", + ) + path_prefix = log_service_path.rstrip("/") + action_path = f"{path_prefix}/Actions/LogService.CollectDiagnosticData" + payload: dict[str, Any] = {"DiagnosticDataType": diag_type} + if oem_type: + payload["OEMDiagnosticDataType"] = oem_type + + try: + resp: RedfishHttpResponse = conn.post(action_path, json=payload) + except RedfishConnectionError as e: + return None, None, str(e) + + if resp.status_code not in (codes.ok, codes.accepted): + return ( + None, + None, + f"Unexpected status {resp.status_code} for CollectDiagnosticData: {resp.text}", + ) + + # DSP0266 12.2: 202 shall include Location (task monitor URI), optionally Retry-After + location_header = resp.headers.get("Location") or resp.headers.get("Content-Location") + if location_header and not location_header.startswith("http"): + location_header = _resolve_path(conn, location_header) + try: + sleep_s = int(resp.headers.get("Retry-After", SLEEP_S_DEFAULT) or SLEEP_S_DEFAULT) + except ValueError: + sleep_s = SLEEP_S_DEFAULT + try: + oem_response = resp.json() + except Exception: + oem_response = {} + + # 200 OK with TaskState=Completed: synchronous completion, body is the Task + task_json: Optional[dict[str, Any]] = None + if resp.status_code == codes.ok and isinstance(oem_response, dict): + if oem_response.get("TaskState") == TaskState.completed.value: + headers_list = oem_response.get("Payload", {}).get("HttpHeaders", []) or [] + if any(isinstance(h, str) and "Location:" in h for h in headers_list): + task_json = oem_response + + task_monitor: Optional[str] = None + task_path: Optional[str] = None + if task_json is None: + if isinstance(oem_response, dict) and oem_response.get(RF_ODATA_ID): + task_path = _task_resource_path( + _get_path_from_connection(conn, oem_response[RF_ODATA_ID]) + ) + if location_header: + loc_path = _get_path_from_connection(conn, location_header) + loc_task = _task_resource_path(loc_path) + if loc_task: + task_path = loc_task + else: + task_monitor = location_header + if not task_monitor and not task_path and isinstance(oem_response, dict): + task_monitor = _get_task_monitor_uri(oem_response, conn) + if task_path: + task_json, poll_err = _poll_task_resource(conn, task_path, task_timeout_s, sleep_s) + if poll_err: + return None, None, poll_err + elif task_monitor: + start = time.time() + poll_resp = None + while True: + if time.time() - start > task_timeout_s: + return None, None, f"Task did not complete within {task_timeout_s}s" + monitor_path = _get_path_from_connection(conn, task_monitor) + try: + poll_resp = conn.get_response(monitor_path) + except RedfishConnectionError as exc: + return None, None, str(exc) + if poll_resp.status_code == codes.not_found: + return None, None, f"TaskMonitor GET failed: status {codes.not_found}" + if poll_resp.status_code != codes.accepted: + break + time.sleep(max(int(sleep_s), 1)) + try: + monitor_body = poll_resp.json() if poll_resp else {} + except Exception: + monitor_body = {} + task_uri_from_monitor = ( + monitor_body.get(RF_ODATA_ID) if isinstance(monitor_body, dict) else None + ) + if isinstance(task_uri_from_monitor, str) and task_uri_from_monitor.strip(): + follow_path = _get_path_from_connection(conn, task_uri_from_monitor.strip()) + else: + follow_path = _get_path_from_connection( + conn, task_monitor.rstrip("/").rsplit("/", 1)[0] + ) + follow_task = _task_resource_path(follow_path) + if follow_task: + task_json, poll_err = _poll_task_resource( + conn, follow_task, task_timeout_s, sleep_s + ) + if poll_err: + return None, None, poll_err + else: + try: + task_resp = conn.get_response(follow_path) + except RedfishConnectionError as exc: + return None, None, str(exc) + if task_resp.status_code != codes.ok: + return None, None, f"Task GET failed: {task_resp.status_code}" + task_json = task_resp.json() + if task_json.get("TaskState") != TaskState.completed.value: + return ( + None, + None, + f"Task did not complete: TaskState={task_json.get('TaskState')}", + ) + else: + _log_collect_diag_response( + log, resp.status_code, oem_response, getattr(resp, "text", "") or "" + ) + return None, None, "No TaskMonitor in response and no Location header" + + if not isinstance(task_json, dict): + return None, None, "Task did not complete: missing task body" + + # LogEntry location from Payload.HttpHeaders + headers_list = task_json.get("Payload", {}).get("HttpHeaders", []) or [] + location = None + for header in headers_list: + if isinstance(header, str) and "Location:" in header: + location = header.split("Location:", 1)[-1].strip() + break + if not location: + return None, None, "Location header missing in task Payload.HttpHeaders" + if location.startswith("http"): + log_entry_path = location + else: + log_entry_path = location.lstrip("/") + + log_entry_json, first_status, first_error = _fetch_log_entry_json(conn, log_entry_path, log) + if log_entry_json is None: + if first_error: + err = first_error + if first_status is not None: + err = f"{first_error} (status {first_status})" + elif first_status is not None: + err = f"status {first_status}" + else: + err = "unknown" + return None, None, f"LogEntry GET failed: {err} (GET {log_entry_path})" + + file_stem = oem_type or diag_type + try: + log_bytes = _download_log_and_save(conn, log_entry_json, file_stem, output_dir, log) + except RedfishConnectionError as exc: + return None, None, str(exc) + return log_bytes, log_entry_json, None diff --git a/nodescraper/connection/redfish/redfish_params.py b/nodescraper/connection/redfish/redfish_params.py index 9827f2e8..90783903 100644 --- a/nodescraper/connection/redfish/redfish_params.py +++ b/nodescraper/connection/redfish/redfish_params.py @@ -1,130 +1,130 @@ -############################################################################### -# -# 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 __future__ import annotations - -from typing import Optional, Union - -from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator - -# RedfishTargetParams is an alias kept for callers that import it by name. -# Internally, both the top-level config and per-target entries use the same model. -from pydantic.networks import IPvAnyAddress - -from nodescraper.connection.inband.sshparams import SSHConnectionParams - -from .redfish_connection import DEFAULT_REDFISH_API_ROOT - - -class RedfishConnectionParams(BaseModel): - """Connection parameters for a Redfish (BMC) API endpoint. - - Single-target mode: supply ``host`` (and optionally ``username``, ``password``, etc.). - Multi-target mode: supply ``targets`` — a list of ``RedfishConnectionParams`` entries, - each with ``host`` set and an optional ``target_key`` identifier. - """ - - model_config = ConfigDict(arbitrary_types_allowed=True) - - target_key: Optional[str] = Field( - default=None, - description="Identifier used when this entry appears inside a 'targets' list.", - ) - name: Optional[str] = Field(default=None) - targets: Optional[list[RedfishConnectionParams]] = Field( - default=None, description="List of OOB Redfish targets (multi-target mode)." - ) - max_workers: Optional[int] = Field( - default=None, - ge=1, - description=( - "Max concurrent threads for multi-target collection. " - "Defaults to min(len(targets), 32) when not set." - ), - ) - host: Optional[Union[IPvAnyAddress, str]] = None - username: Optional[str] = None - password: Optional[SecretStr] = None - port: Optional[int] = Field(default=None, ge=1, le=65535) - use_https: bool = True - verify_ssl: bool = Field( - default=True, - description="Verify HTTPS server certificate. Set False for BMCs with self-signed certs.", - ) - timeout_seconds: float = Field(default=10.0, gt=0, le=300) - use_session_auth: bool = True - api_root: str = Field( - default=DEFAULT_REDFISH_API_ROOT, - description="Redfish API path (e.g. 'redfish/v1'). Override for a different API version.", - ) - - @model_validator(mode="after") - def _validate_target_config(self) -> "RedfishConnectionParams": - if not self.targets and self.host is None: - raise ValueError( - "Either 'targets' (multi-target mode) or 'host' (single-target mode) must be provided." - ) - return self - - @property - def is_multi_target(self) -> bool: - """True when one or more targets are configured via the ``targets`` list.""" - return bool(self.targets) - - -RedfishConnectionParams.model_rebuild() - -# Backward-compatible alias so existing callers of RedfishTargetParams still work. -RedfishTargetParams = RedfishConnectionParams - - -def redfish_params_to_ssh( - params: Union[RedfishConnectionParams, dict], - *, - ssh_port: Optional[int] = None, - key_filename: Optional[str] = None, -) -> SSHConnectionParams: - """Map Redfish BMC credentials to SSH connection params for shell access.""" - if isinstance(params, dict): - data = dict(params) - ssh_port = data.pop("ssh_port", ssh_port if ssh_port is not None else 22) - key_filename = data.pop("key_filename", key_filename) - params = RedfishConnectionParams.model_validate(data) - else: - if ssh_port is None: - ssh_port = 22 - - if params.host is None: - raise ValueError( - "SSH mapping requires a Redfish host. Multi-target configs apply only to Redfish plugins." - ) - - return SSHConnectionParams( - hostname=str(params.host), - username=params.username, - password=params.password, - port=ssh_port, - key_filename=key_filename, - ) +############################################################################### +# +# 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 __future__ import annotations + +from typing import Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator + +# RedfishTargetParams is an alias kept for callers that import it by name. +# Internally, both the top-level config and per-target entries use the same model. +from pydantic.networks import IPvAnyAddress + +from nodescraper.connection.inband.sshparams import SSHConnectionParams + +from .redfish_connection import DEFAULT_REDFISH_API_ROOT + + +class RedfishConnectionParams(BaseModel): + """Connection parameters for a Redfish (BMC) API endpoint. + + Single-target mode: supply ``host`` (and optionally ``username``, ``password``, etc.). + Multi-target mode: supply ``targets`` — a list of ``RedfishConnectionParams`` entries, + each with ``host`` set and an optional ``target_key`` identifier. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + target_key: Optional[str] = Field( + default=None, + description="Identifier used when this entry appears inside a 'targets' list.", + ) + name: Optional[str] = Field(default=None) + targets: Optional[list[RedfishConnectionParams]] = Field( + default=None, description="List of OOB Redfish targets (multi-target mode)." + ) + max_workers: Optional[int] = Field( + default=None, + ge=1, + description=( + "Max concurrent threads for multi-target collection. " + "Defaults to min(len(targets), 32) when not set." + ), + ) + host: Optional[Union[IPvAnyAddress, str]] = None + username: Optional[str] = None + password: Optional[SecretStr] = None + port: Optional[int] = Field(default=None, ge=1, le=65535) + use_https: bool = True + verify_ssl: bool = Field( + default=True, + description="Verify HTTPS server certificate. Set False for BMCs with self-signed certs.", + ) + timeout_seconds: float = Field(default=10.0, gt=0, le=300) + use_session_auth: bool = True + api_root: str = Field( + default=DEFAULT_REDFISH_API_ROOT, + description="Redfish API path (e.g. 'redfish/v1'). Override for a different API version.", + ) + + @model_validator(mode="after") + def _validate_target_config(self) -> "RedfishConnectionParams": + if not self.targets and self.host is None: + raise ValueError( + "Either 'targets' (multi-target mode) or 'host' (single-target mode) must be provided." + ) + return self + + @property + def is_multi_target(self) -> bool: + """True when one or more targets are configured via the ``targets`` list.""" + return bool(self.targets) + + +RedfishConnectionParams.model_rebuild() + +# Backward-compatible alias so existing callers of RedfishTargetParams still work. +RedfishTargetParams = RedfishConnectionParams + + +def redfish_params_to_ssh( + params: Union[RedfishConnectionParams, dict], + *, + ssh_port: Optional[int] = None, + key_filename: Optional[str] = None, +) -> SSHConnectionParams: + """Map Redfish BMC credentials to SSH connection params for shell access.""" + if isinstance(params, dict): + data = dict(params) + ssh_port = data.pop("ssh_port", ssh_port if ssh_port is not None else 22) + key_filename = data.pop("key_filename", key_filename) + params = RedfishConnectionParams.model_validate(data) + else: + if ssh_port is None: + ssh_port = 22 + + if params.host is None: + raise ValueError( + "SSH mapping requires a Redfish host. Multi-target configs apply only to Redfish plugins." + ) + + return SSHConnectionParams( + hostname=str(params.host), + username=params.username, + password=params.password, + port=ssh_port, + key_filename=key_filename, + ) diff --git a/nodescraper/connection/redfish/redfish_path.py b/nodescraper/connection/redfish/redfish_path.py index 0cd14024..f5dec6f3 100644 --- a/nodescraper/connection/redfish/redfish_path.py +++ b/nodescraper/connection/redfish/redfish_path.py @@ -1,61 +1,61 @@ -############################################################################### -# -# 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. -# -############################################################################### -"""Fluent Redfish path builder with pathlib-like / syntax and optional parameter substitution.""" -from __future__ import annotations - -from typing import Dict - - -class RedfishPath: - """Fluent interface for building Redfish URI paths.""" - - def __init__(self, base: str = "") -> None: - self._path = (base or "").strip().strip("/") - self._params: Dict[str, str] = {} - - def __truediv__(self, segment: str) -> RedfishPath: - """Allow path / \"segment\" syntax. Leading/trailing slashes on segment are stripped.""" - seg = (segment or "").strip().strip("/") - if not seg: - return RedfishPath(self._path) - new_path = RedfishPath() - new_path._path = f"{self._path}/{seg}" if self._path else seg - new_path._params = dict(self._params) - return new_path - - def __call__(self, **params: str) -> str: - """Substitute placeholders in the path and return the final path string. - - Placeholders use {key}; e.g. path \"Systems/{id}/LogServices/DiagLogs\" with (id=\"UBB\") - returns \"Systems/UBB/LogServices/DiagLogs\". - """ - result = self._path - for key, value in params.items(): - result = result.replace(f"{{{key}}}", value) - return result - - def __str__(self) -> str: - return self._path +############################################################################### +# +# 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. +# +############################################################################### +"""Fluent Redfish path builder with pathlib-like / syntax and optional parameter substitution.""" +from __future__ import annotations + +from typing import Dict + + +class RedfishPath: + """Fluent interface for building Redfish URI paths.""" + + def __init__(self, base: str = "") -> None: + self._path = (base or "").strip().strip("/") + self._params: Dict[str, str] = {} + + def __truediv__(self, segment: str) -> RedfishPath: + """Allow path / \"segment\" syntax. Leading/trailing slashes on segment are stripped.""" + seg = (segment or "").strip().strip("/") + if not seg: + return RedfishPath(self._path) + new_path = RedfishPath() + new_path._path = f"{self._path}/{seg}" if self._path else seg + new_path._params = dict(self._params) + return new_path + + def __call__(self, **params: str) -> str: + """Substitute placeholders in the path and return the final path string. + + Placeholders use {key}; e.g. path \"Systems/{id}/LogServices/DiagLogs\" with (id=\"UBB\") + returns \"Systems/UBB/LogServices/DiagLogs\". + """ + result = self._path + for key, value in params.items(): + result = result.replace(f"{{{key}}}", value) + return result + + def __str__(self) -> str: + return self._path diff --git a/nodescraper/enums/taskstate.py b/nodescraper/enums/taskstate.py index 46d9d3fa..7735d049 100644 --- a/nodescraper/enums/taskstate.py +++ b/nodescraper/enums/taskstate.py @@ -1,44 +1,44 @@ -############################################################################### -# -# MIT License -# -# Copyright (c) 2026 Advanced Micro Devices, Inc. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -############################################################################### -import enum - - -class TaskState(enum.Enum): - """Redfish Task resource TaskState.""" - - new = "New" - starting = "Starting" - running = "Running" - suspended = "Suspended" - interrupted = "Interrupted" - pending = "Pending" - stopping = "Stopping" - completed = "Completed" - killed = "Killed" - exception = "Exception" - service = "Service" - cancelling = "Cancelling" - cancelled = "Cancelled" +############################################################################### +# +# 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 enum + + +class TaskState(enum.Enum): + """Redfish Task resource TaskState.""" + + new = "New" + starting = "Starting" + running = "Running" + suspended = "Suspended" + interrupted = "Interrupted" + pending = "Pending" + stopping = "Stopping" + completed = "Completed" + killed = "Killed" + exception = "Exception" + service = "Service" + cancelling = "Cancelling" + cancelled = "Cancelled" diff --git a/nodescraper/helpers/__init__.py b/nodescraper/helpers/__init__.py index 2cca9561..69d0ff0d 100644 --- a/nodescraper/helpers/__init__.py +++ b/nodescraper/helpers/__init__.py @@ -1,38 +1,38 @@ -############################################################################### -# -# MIT License -# -# Copyright (c) 2026 Advanced Micro Devices, Inc. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -############################################################################### -from nodescraper.plugins.inband.dmesg import ( - compare_dmesg_runs, - compute_extracted_errors, - find_dmesg_datamodel_path, - load_dmesg_data, -) - -__all__ = [ - "compare_dmesg_runs", - "compute_extracted_errors", - "find_dmesg_datamodel_path", - "load_dmesg_data", -] +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from nodescraper.plugins.inband.dmesg import ( + compare_dmesg_runs, + compute_extracted_errors, + find_dmesg_datamodel_path, + load_dmesg_data, +) + +__all__ = [ + "compare_dmesg_runs", + "compute_extracted_errors", + "find_dmesg_datamodel_path", + "load_dmesg_data", +] diff --git a/nodescraper/helpers/plugin_execution_target.py b/nodescraper/helpers/plugin_execution_target.py index 0b8daffb..4e4cd3e4 100644 --- a/nodescraper/helpers/plugin_execution_target.py +++ b/nodescraper/helpers/plugin_execution_target.py @@ -1,119 +1,119 @@ -############################################################################### -# -# 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 __future__ import annotations - -from typing import Any, Optional, Union - -from pydantic import BaseModel - -from nodescraper.connection.inband.inbandmanager import InBandConnectionManager -from nodescraper.connection.redfish.redfish_manager import RedfishConnectionManager -from nodescraper.enums import SystemLocation -from nodescraper.interfaces import ConnectionManager, PluginInterface -from nodescraper.models import SystemInfo - - -def _host_from_connection_args(raw: Optional[Union[dict[str, Any], BaseModel]]) -> Optional[str]: - if raw is None: - return None - if isinstance(raw, dict): - targets = raw.get("targets") - if isinstance(targets, list) and targets: - hosts = [] - for item in targets: - if isinstance(item, dict): - host = item.get("target_key") or item.get("host") - if host: - hosts.append(str(host)) - if hosts: - return ", ".join(hosts) - for key in ("host", "hostname", "ip"): - value = raw.get(key) - if value: - return str(value) - return None - for key in ("host", "hostname", "ip"): - value = getattr(raw, key, None) - if value is not None and str(value): - return str(value) - return None - - -def format_in_band_target_summary( - system_info: SystemInfo, - connection_configs: Optional[dict[str, Union[dict[str, Any], BaseModel]]] = None, -) -> str: - """Return a short summary of the default in-band execution target.""" - if system_info.location == SystemLocation.REMOTE: - host = _host_from_connection_args((connection_configs or {}).get("InBandConnectionManager")) - if host: - return f"In-band default: remote host via SSH ({host})" - return "In-band default: remote host via SSH" - host_name = system_info.name or "local host" - return f"In-band default: local host ({host_name})" - - -def format_plugin_execution_target( - plugin_class: type[PluginInterface], - *, - system_info: SystemInfo, - connection_manager: Optional[ConnectionManager] = None, - connection_configs: Optional[dict[str, Union[dict[str, Any], BaseModel]]] = None, -) -> Optional[str]: - """Return a one-line description of where a plugin collects data from.""" - connection_type = getattr(plugin_class, "CONNECTION_TYPE", None) - if connection_type is None: - return None - - configs = connection_configs or {} - manager_args = ( - getattr(connection_manager, "connection_args", None) if connection_manager else None - ) - - if connection_type is RedfishConnectionManager or issubclass( - connection_type, RedfishConnectionManager - ): - host = _host_from_connection_args(manager_args) or _host_from_connection_args( - configs.get("RedfishConnectionManager") - ) - if host: - return f"Execution target: BMC via Redfish OOB ({host})" - return "Execution target: BMC via Redfish OOB" - - if connection_type is InBandConnectionManager or issubclass( - connection_type, InBandConnectionManager - ): - if system_info.location == SystemLocation.REMOTE: - host = _host_from_connection_args(manager_args) or _host_from_connection_args( - configs.get("InBandConnectionManager") - ) - if host: - return f"Execution target: remote host via SSH ({host})" - return "Execution target: remote host via SSH" - host_name = system_info.name or "local host" - return f"Execution target: local host ({host_name})" - - return f"Execution target: {connection_type.__name__}" +############################################################################### +# +# 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 __future__ import annotations + +from typing import Any, Optional, Union + +from pydantic import BaseModel + +from nodescraper.connection.inband.inbandmanager import InBandConnectionManager +from nodescraper.connection.redfish.redfish_manager import RedfishConnectionManager +from nodescraper.enums import SystemLocation +from nodescraper.interfaces import ConnectionManager, PluginInterface +from nodescraper.models import SystemInfo + + +def _host_from_connection_args(raw: Optional[Union[dict[str, Any], BaseModel]]) -> Optional[str]: + if raw is None: + return None + if isinstance(raw, dict): + targets = raw.get("targets") + if isinstance(targets, list) and targets: + hosts = [] + for item in targets: + if isinstance(item, dict): + host = item.get("target_key") or item.get("host") + if host: + hosts.append(str(host)) + if hosts: + return ", ".join(hosts) + for key in ("host", "hostname", "ip"): + value = raw.get(key) + if value: + return str(value) + return None + for key in ("host", "hostname", "ip"): + value = getattr(raw, key, None) + if value is not None and str(value): + return str(value) + return None + + +def format_in_band_target_summary( + system_info: SystemInfo, + connection_configs: Optional[dict[str, Union[dict[str, Any], BaseModel]]] = None, +) -> str: + """Return a short summary of the default in-band execution target.""" + if system_info.location == SystemLocation.REMOTE: + host = _host_from_connection_args((connection_configs or {}).get("InBandConnectionManager")) + if host: + return f"In-band default: remote host via SSH ({host})" + return "In-band default: remote host via SSH" + host_name = system_info.name or "local host" + return f"In-band default: local host ({host_name})" + + +def format_plugin_execution_target( + plugin_class: type[PluginInterface], + *, + system_info: SystemInfo, + connection_manager: Optional[ConnectionManager] = None, + connection_configs: Optional[dict[str, Union[dict[str, Any], BaseModel]]] = None, +) -> Optional[str]: + """Return a one-line description of where a plugin collects data from.""" + connection_type = getattr(plugin_class, "CONNECTION_TYPE", None) + if connection_type is None: + return None + + configs = connection_configs or {} + manager_args = ( + getattr(connection_manager, "connection_args", None) if connection_manager else None + ) + + if connection_type is RedfishConnectionManager or issubclass( + connection_type, RedfishConnectionManager + ): + host = _host_from_connection_args(manager_args) or _host_from_connection_args( + configs.get("RedfishConnectionManager") + ) + if host: + return f"Execution target: BMC via Redfish OOB ({host})" + return "Execution target: BMC via Redfish OOB" + + if connection_type is InBandConnectionManager or issubclass( + connection_type, InBandConnectionManager + ): + if system_info.location == SystemLocation.REMOTE: + host = _host_from_connection_args(manager_args) or _host_from_connection_args( + configs.get("InBandConnectionManager") + ) + if host: + return f"Execution target: remote host via SSH ({host})" + return "Execution target: remote host via SSH" + host_name = system_info.name or "local host" + return f"Execution target: local host ({host_name})" + + return f"Execution target: {connection_type.__name__}" diff --git a/nodescraper/plugins/inband/dmesg/run_compare.py b/nodescraper/plugins/inband/dmesg/run_compare.py index 11c536b8..f054252d 100644 --- a/nodescraper/plugins/inband/dmesg/run_compare.py +++ b/nodescraper/plugins/inband/dmesg/run_compare.py @@ -1,82 +1,82 @@ -############################################################################### -# -# 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 logging -import os -from typing import Optional, Tuple - -from .dmesg_plugin import DmesgPlugin -from .dmesgdata import DmesgData - - -def find_dmesg_datamodel_path(run_path: str) -> Optional[str]: - """Find the DmesgPlugin collector datamodel under a scraper run directory.""" - return DmesgPlugin.find_datamodel_path_in_run(run_path) - - -def load_dmesg_data(path: str) -> Tuple[Optional[DmesgData], Optional[str]]: - """Load DmesgData from a scraper run directory or a datamodel file path.""" - path = os.path.abspath(path) - if not os.path.exists(path): - return None, None - dm_path = path if os.path.isfile(path) else DmesgPlugin.find_datamodel_path_in_run(path) - if not dm_path: - return None, None - dm = DmesgPlugin.load_datamodel_from_path(dm_path) - return (dm, dm_path) if dm is not None else (None, None) - - -def compute_extracted_errors(dm: DmesgData) -> list[str]: - """Apply DmesgPlugin analyzer regexes to dmesg content (in memory only).""" - out = DmesgPlugin.get_extracted_errors(dm) - return out if out is not None else [] - - -def compare_dmesg_runs( - path1: str, - path2: str, - logger: Optional[logging.Logger] = None, -) -> Tuple[list[str], list[str], str, str]: - """Load two DmesgPlugin runs, compute extracted errors in memory, and compare.""" - log = logger or logging.getLogger(__name__) - label1 = os.path.basename(path1.rstrip(os.sep)) - label2 = os.path.basename(path2.rstrip(os.sep)) - - d1 = DmesgPlugin.load_run_data(path1) - d2 = DmesgPlugin.load_run_data(path2) - - if d1 is None: - log.warning("No DmesgPlugin datamodel found at: %s", path1) - return [], [], label1, label2 - if d2 is None: - log.warning("No DmesgPlugin datamodel found at: %s", path2) - return [], [], label1, label2 - - err1 = set(d1.get("extracted_errors") or []) - err2 = set(d2.get("extracted_errors") or []) - only_in_1 = sorted(err1 - err2) - only_in_2 = sorted(err2 - err1) - - return only_in_1, only_in_2, label1, label2 +############################################################################### +# +# 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 logging +import os +from typing import Optional, Tuple + +from .dmesg_plugin import DmesgPlugin +from .dmesgdata import DmesgData + + +def find_dmesg_datamodel_path(run_path: str) -> Optional[str]: + """Find the DmesgPlugin collector datamodel under a scraper run directory.""" + return DmesgPlugin.find_datamodel_path_in_run(run_path) + + +def load_dmesg_data(path: str) -> Tuple[Optional[DmesgData], Optional[str]]: + """Load DmesgData from a scraper run directory or a datamodel file path.""" + path = os.path.abspath(path) + if not os.path.exists(path): + return None, None + dm_path = path if os.path.isfile(path) else DmesgPlugin.find_datamodel_path_in_run(path) + if not dm_path: + return None, None + dm = DmesgPlugin.load_datamodel_from_path(dm_path) + return (dm, dm_path) if dm is not None else (None, None) + + +def compute_extracted_errors(dm: DmesgData) -> list[str]: + """Apply DmesgPlugin analyzer regexes to dmesg content (in memory only).""" + out = DmesgPlugin.get_extracted_errors(dm) + return out if out is not None else [] + + +def compare_dmesg_runs( + path1: str, + path2: str, + logger: Optional[logging.Logger] = None, +) -> Tuple[list[str], list[str], str, str]: + """Load two DmesgPlugin runs, compute extracted errors in memory, and compare.""" + log = logger or logging.getLogger(__name__) + label1 = os.path.basename(path1.rstrip(os.sep)) + label2 = os.path.basename(path2.rstrip(os.sep)) + + d1 = DmesgPlugin.load_run_data(path1) + d2 = DmesgPlugin.load_run_data(path2) + + if d1 is None: + log.warning("No DmesgPlugin datamodel found at: %s", path1) + return [], [], label1, label2 + if d2 is None: + log.warning("No DmesgPlugin datamodel found at: %s", path2) + return [], [], label1, label2 + + err1 = set(d1.get("extracted_errors") or []) + err2 = set(d2.get("extracted_errors") or []) + only_in_1 = sorted(err1 - err2) + only_in_2 = sorted(err2 - err1) + + return only_in_1, only_in_2, label1, label2 diff --git a/nodescraper/plugins/inband/journal/analyzer_args.py b/nodescraper/plugins/inband/journal/analyzer_args.py index ee5390c1..6a30b3ae 100644 --- a/nodescraper/plugins/inband/journal/analyzer_args.py +++ b/nodescraper/plugins/inband/journal/analyzer_args.py @@ -1,43 +1,43 @@ -############################################################################### -# -# MIT License -# -# Copyright (c) 2026 Advanced Micro Devices, Inc. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -############################################################################### -from typing import Optional - -from pydantic import Field - -from nodescraper.models import TimeRangeAnalysisArgs - - -class JournalAnalyzerArgs(TimeRangeAnalysisArgs): - """Arguments for journal analyzer""" - - check_priority: Optional[int] = Field( - default=None, - description="Check against journal log priority (0=emergency..7=debug). If an entry has priority <= check_priority, an ERROR event is raised.", - ) - group: bool = Field( - default=True, - description="If True, group entries that have the same priority and message.", - ) +############################################################################### +# +# 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 Optional + +from pydantic import Field + +from nodescraper.models import TimeRangeAnalysisArgs + + +class JournalAnalyzerArgs(TimeRangeAnalysisArgs): + """Arguments for journal analyzer""" + + check_priority: Optional[int] = Field( + default=None, + description="Check against journal log priority (0=emergency..7=debug). If an entry has priority <= check_priority, an ERROR event is raised.", + ) + group: bool = Field( + default=True, + description="If True, group entries that have the same priority and message.", + ) diff --git a/nodescraper/plugins/inband/journal/collector_args.py b/nodescraper/plugins/inband/journal/collector_args.py index 6832ffc7..3ee9953f 100644 --- a/nodescraper/plugins/inband/journal/collector_args.py +++ b/nodescraper/plugins/inband/journal/collector_args.py @@ -1,38 +1,38 @@ -############################################################################### -# -# MIT License -# -# 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 typing import Optional - -from pydantic import Field - -from nodescraper.models import CollectorArgs - - -class JournalCollectorArgs(CollectorArgs): - boot: Optional[int] = Field( - default=None, - description="Optional boot ID to limit journal collection to a specific boot.", - ) +############################################################################### +# +# MIT License +# +# 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 typing import Optional + +from pydantic import Field + +from nodescraper.models import CollectorArgs + + +class JournalCollectorArgs(CollectorArgs): + boot: Optional[int] = Field( + default=None, + description="Optional boot ID to limit journal collection to a specific boot.", + ) diff --git a/nodescraper/plugins/inband/journal/journal_analyzer.py b/nodescraper/plugins/inband/journal/journal_analyzer.py index fbf14eaf..d3e8bfa6 100644 --- a/nodescraper/plugins/inband/journal/journal_analyzer.py +++ b/nodescraper/plugins/inband/journal/journal_analyzer.py @@ -1,238 +1,238 @@ -############################################################################### -# -# 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 datetime import datetime -from typing import Optional, TypedDict - -from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus -from nodescraper.interfaces import DataAnalyzer -from nodescraper.models import TaskResult - -from .analyzer_args import JournalAnalyzerArgs -from .journaldata import JournalData, JournalJsonEntry - - -class JournalEvent(TypedDict): - count: int - first_occurrence: datetime - last_occurrence: datetime - - -class JournalPriority: - EMERGENCY = 0 - ALERT = 1 - CRITICAL = 2 - ERROR = 3 - WARNING = 4 - NOTICE = 5 - INFO = 6 - DEBUG = 7 - - -class JournalAnalyzer(DataAnalyzer[JournalData, JournalAnalyzerArgs]): - """Check journalctl for errors""" - - DATA_MODEL = JournalData - - @classmethod - def filter_journal( - cls, - journal_content_json: list[JournalJsonEntry], - analysis_range_start: Optional[datetime] = None, - analysis_range_end: Optional[datetime] = None, - ) -> list[JournalJsonEntry]: - """Filter a journal log by date - - Args: - journal_content_json (list[JournalJsonEntry]): unfiltered journal log - analysis_range_start (Optional[datetime], optional): start of analysis range. Defaults to None. - analysis_range_end (Optional[datetime], optional): end of analysis range. Defaults to None. - - Returns: - list[JournalJsonEntry]: filtered journal log - """ - - filtered_journal = [] - - found_start = False if analysis_range_start else True - - # Parse through the journal log and filter by date - for entry in journal_content_json: - date = entry.REALTIME_TIMESTAMP - - # Skip entries without valid timestamp - if date is None: - continue - - if analysis_range_start and not found_start and date >= analysis_range_start: - found_start = True - elif analysis_range_end and date >= analysis_range_end: - break - - # only read entries after starting timestamp is found, ignore entries that do not have valid date - if found_start: - filtered_journal.append(entry) - - return filtered_journal - - def _priority_to_entry_priority(self, priority: int) -> EventPriority: - if priority <= JournalPriority.ERROR: - entry_priority = EventPriority.ERROR - elif priority == JournalPriority.WARNING: - entry_priority = EventPriority.WARNING - elif priority >= JournalPriority.NOTICE: - entry_priority = EventPriority.INFO - else: - # Unknown? - entry_priority = EventPriority.ERROR - return entry_priority - - def _analyze_journal_entries_by_priority( - self, journal_content_json: list[JournalJsonEntry], priority: int, group: bool - ) -> None: - """Analyze a list of Journal Entries for a priority. - if WARNING, CRITICAL or it is unknown then log an error/warning Journal Entry. - Parameters - ---------- - journal_content_json : list[JournalJsonEntry] - List of JournalJsonEntry to analyze - priority : int - Priority threshold to check against - group : bool - Whether to group similar entries - """ - # Use a tuple of (message, priority) as the key instead of the JournalJsonEntry object - journal_event_map: dict[tuple[str, int], JournalEvent] = {} - - # Check against journal log priority levels. emergency(0), alert(1), critical(2), error(3), warning(4), notice(5), info(6), debug(7) - for entry in journal_content_json: - if entry.PRIORITY <= priority: - self.result.status = ExecutionStatus.ERROR - if not group: - entry_dict = entry.model_dump() # Convert JournalJsonEntry to dictionary - entry_dict["task_name"] = self.__class__.__name__ - self._log_event( - category=EventCategory.OS, - description="Journal log entry with priority level %s" % entry.PRIORITY, - data=entry_dict, - priority=self._priority_to_entry_priority(entry.PRIORITY), - console_log=False, - ) - else: - # Handle MESSAGE as either string or list - message = entry.MESSAGE - if isinstance(message, list): - message = " ".join(message) - - # Create a tuple key from message and priority - entry_key = (message, entry.PRIORITY) - if journal_event_map.get(entry_key) is None: - journal_event_map[entry_key] = { - "count": 1, - "first_occurrence": ( - entry.REALTIME_TIMESTAMP - if entry.REALTIME_TIMESTAMP - else datetime.fromtimestamp(0) - ), - "last_occurrence": ( - entry.REALTIME_TIMESTAMP - if entry.REALTIME_TIMESTAMP - else datetime.fromtimestamp(0) - ), - } - else: - journal_event_map[entry_key]["count"] += 1 - if entry.REALTIME_TIMESTAMP: - journal_event_map[entry_key][ - "last_occurrence" - ] = entry.REALTIME_TIMESTAMP - - # log all events that were grouped - if group: - for (message, entry_priority), event_data in journal_event_map.items(): - self._log_event( - category=EventCategory.OS, - description="Journal entries found in OS journal log", - priority=self._priority_to_entry_priority(entry_priority), - data={ - "message": message, - "priority": entry_priority, - "count": event_data["count"], - "first_occurrence": event_data["first_occurrence"], - "last_occurrence": event_data["last_occurrence"], - }, - console_log=False, - ) - - def analyze_data( - self, data: JournalData, args: Optional[JournalAnalyzerArgs] = None - ) -> TaskResult: - """Analyze the OS journal log for errors - - Parameters - ---------- - data : JournalData - Journal data to analyze - args : Optional[JournalAnalyzerArgs], optional - Analysis arguments, by default None - - Returns - ------- - TaskResult - A TaskResult object containing the result of the analysis - If journal log entries are found ExecutionStatus.OK - If journal log entries are found with priority level less than or equal to check_priority ExecutionStatus.ERROR - """ - if args is None: - args = JournalAnalyzerArgs() - - journal_content_json = data.journal_content_json - - # Filter by time range if specified - if args.analysis_range_start or args.analysis_range_end: - self.logger.info( - "Filtering journal log using range %s - %s", - args.analysis_range_start, - args.analysis_range_end, - ) - journal_content_json = self.filter_journal( - journal_content_json=journal_content_json, - analysis_range_start=args.analysis_range_start, - analysis_range_end=args.analysis_range_end, - ) - - self.result.status = ExecutionStatus.OK - - if args.check_priority is not None: - self._analyze_journal_entries_by_priority( - journal_content_json, args.check_priority, args.group - ) - - if self.result.status == ExecutionStatus.OK: - self.result.message = "No journal errors found" - else: - self.result.message = f"Found journal entries with priority <= {args.check_priority}" - - return self.result +############################################################################### +# +# 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 datetime import datetime +from typing import Optional, TypedDict + +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.interfaces import DataAnalyzer +from nodescraper.models import TaskResult + +from .analyzer_args import JournalAnalyzerArgs +from .journaldata import JournalData, JournalJsonEntry + + +class JournalEvent(TypedDict): + count: int + first_occurrence: datetime + last_occurrence: datetime + + +class JournalPriority: + EMERGENCY = 0 + ALERT = 1 + CRITICAL = 2 + ERROR = 3 + WARNING = 4 + NOTICE = 5 + INFO = 6 + DEBUG = 7 + + +class JournalAnalyzer(DataAnalyzer[JournalData, JournalAnalyzerArgs]): + """Check journalctl for errors""" + + DATA_MODEL = JournalData + + @classmethod + def filter_journal( + cls, + journal_content_json: list[JournalJsonEntry], + analysis_range_start: Optional[datetime] = None, + analysis_range_end: Optional[datetime] = None, + ) -> list[JournalJsonEntry]: + """Filter a journal log by date + + Args: + journal_content_json (list[JournalJsonEntry]): unfiltered journal log + analysis_range_start (Optional[datetime], optional): start of analysis range. Defaults to None. + analysis_range_end (Optional[datetime], optional): end of analysis range. Defaults to None. + + Returns: + list[JournalJsonEntry]: filtered journal log + """ + + filtered_journal = [] + + found_start = False if analysis_range_start else True + + # Parse through the journal log and filter by date + for entry in journal_content_json: + date = entry.REALTIME_TIMESTAMP + + # Skip entries without valid timestamp + if date is None: + continue + + if analysis_range_start and not found_start and date >= analysis_range_start: + found_start = True + elif analysis_range_end and date >= analysis_range_end: + break + + # only read entries after starting timestamp is found, ignore entries that do not have valid date + if found_start: + filtered_journal.append(entry) + + return filtered_journal + + def _priority_to_entry_priority(self, priority: int) -> EventPriority: + if priority <= JournalPriority.ERROR: + entry_priority = EventPriority.ERROR + elif priority == JournalPriority.WARNING: + entry_priority = EventPriority.WARNING + elif priority >= JournalPriority.NOTICE: + entry_priority = EventPriority.INFO + else: + # Unknown? + entry_priority = EventPriority.ERROR + return entry_priority + + def _analyze_journal_entries_by_priority( + self, journal_content_json: list[JournalJsonEntry], priority: int, group: bool + ) -> None: + """Analyze a list of Journal Entries for a priority. + if WARNING, CRITICAL or it is unknown then log an error/warning Journal Entry. + Parameters + ---------- + journal_content_json : list[JournalJsonEntry] + List of JournalJsonEntry to analyze + priority : int + Priority threshold to check against + group : bool + Whether to group similar entries + """ + # Use a tuple of (message, priority) as the key instead of the JournalJsonEntry object + journal_event_map: dict[tuple[str, int], JournalEvent] = {} + + # Check against journal log priority levels. emergency(0), alert(1), critical(2), error(3), warning(4), notice(5), info(6), debug(7) + for entry in journal_content_json: + if entry.PRIORITY <= priority: + self.result.status = ExecutionStatus.ERROR + if not group: + entry_dict = entry.model_dump() # Convert JournalJsonEntry to dictionary + entry_dict["task_name"] = self.__class__.__name__ + self._log_event( + category=EventCategory.OS, + description="Journal log entry with priority level %s" % entry.PRIORITY, + data=entry_dict, + priority=self._priority_to_entry_priority(entry.PRIORITY), + console_log=False, + ) + else: + # Handle MESSAGE as either string or list + message = entry.MESSAGE + if isinstance(message, list): + message = " ".join(message) + + # Create a tuple key from message and priority + entry_key = (message, entry.PRIORITY) + if journal_event_map.get(entry_key) is None: + journal_event_map[entry_key] = { + "count": 1, + "first_occurrence": ( + entry.REALTIME_TIMESTAMP + if entry.REALTIME_TIMESTAMP + else datetime.fromtimestamp(0) + ), + "last_occurrence": ( + entry.REALTIME_TIMESTAMP + if entry.REALTIME_TIMESTAMP + else datetime.fromtimestamp(0) + ), + } + else: + journal_event_map[entry_key]["count"] += 1 + if entry.REALTIME_TIMESTAMP: + journal_event_map[entry_key][ + "last_occurrence" + ] = entry.REALTIME_TIMESTAMP + + # log all events that were grouped + if group: + for (message, entry_priority), event_data in journal_event_map.items(): + self._log_event( + category=EventCategory.OS, + description="Journal entries found in OS journal log", + priority=self._priority_to_entry_priority(entry_priority), + data={ + "message": message, + "priority": entry_priority, + "count": event_data["count"], + "first_occurrence": event_data["first_occurrence"], + "last_occurrence": event_data["last_occurrence"], + }, + console_log=False, + ) + + def analyze_data( + self, data: JournalData, args: Optional[JournalAnalyzerArgs] = None + ) -> TaskResult: + """Analyze the OS journal log for errors + + Parameters + ---------- + data : JournalData + Journal data to analyze + args : Optional[JournalAnalyzerArgs], optional + Analysis arguments, by default None + + Returns + ------- + TaskResult + A TaskResult object containing the result of the analysis + If journal log entries are found ExecutionStatus.OK + If journal log entries are found with priority level less than or equal to check_priority ExecutionStatus.ERROR + """ + if args is None: + args = JournalAnalyzerArgs() + + journal_content_json = data.journal_content_json + + # Filter by time range if specified + if args.analysis_range_start or args.analysis_range_end: + self.logger.info( + "Filtering journal log using range %s - %s", + args.analysis_range_start, + args.analysis_range_end, + ) + journal_content_json = self.filter_journal( + journal_content_json=journal_content_json, + analysis_range_start=args.analysis_range_start, + analysis_range_end=args.analysis_range_end, + ) + + self.result.status = ExecutionStatus.OK + + if args.check_priority is not None: + self._analyze_journal_entries_by_priority( + journal_content_json, args.check_priority, args.group + ) + + if self.result.status == ExecutionStatus.OK: + self.result.message = "No journal errors found" + else: + self.result.message = f"Found journal entries with priority <= {args.check_priority}" + + return self.result diff --git a/nodescraper/plugins/inband/nic/nic_analyzer.py b/nodescraper/plugins/inband/nic/nic_analyzer.py index d16a082a..5cbcc869 100644 --- a/nodescraper/plugins/inband/nic/nic_analyzer.py +++ b/nodescraper/plugins/inband/nic/nic_analyzer.py @@ -1,391 +1,391 @@ -############################################################################### -# -# 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, Dict, List, Optional - -from nodescraper.base.regexanalyzer import ErrorRegex -from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus -from nodescraper.interfaces import DataAnalyzer -from nodescraper.models import TaskResult -from nodescraper.utils import _validate_firmware_policy - -from .analyzer_args import NicAnalyzerArgs -from .nic_data import NicDataModel - -# Default regexes for nicctl show card logs (boot-fault, persistent, non-persistent) -DEFAULT_NICCTL_LOG_ERROR_REGEX: List[ErrorRegex] = [ - ErrorRegex( - regex=re.compile(r"\berror\b", re.IGNORECASE), - message="nicctl card log: error", - event_category=EventCategory.NETWORK, - event_priority=EventPriority.WARNING, - ), - ErrorRegex( - regex=re.compile(r"\bfail(?:ed|ure)?\b", re.IGNORECASE), - message="nicctl card log: fail/failed/failure", - event_category=EventCategory.NETWORK, - event_priority=EventPriority.WARNING, - ), - ErrorRegex( - regex=re.compile(r"\bfault\b", re.IGNORECASE), - message="nicctl card log: fault", - event_category=EventCategory.NETWORK, - event_priority=EventPriority.WARNING, - ), - ErrorRegex( - regex=re.compile(r"\bcritical\b", re.IGNORECASE), - message="nicctl card log: critical", - event_category=EventCategory.NETWORK, - event_priority=EventPriority.WARNING, - ), -] - - -def _nicctl_log_error_regex_list( - args: NicAnalyzerArgs, -) -> List[ErrorRegex]: - """Return list of ErrorRegex for nicctl card logs (from args or default).""" - if not args.nicctl_log_error_regex: - return list(DEFAULT_NICCTL_LOG_ERROR_REGEX) - out: List[ErrorRegex] = [] - for item in args.nicctl_log_error_regex: - if isinstance(item, ErrorRegex): - out.append(item) - elif isinstance(item, dict): - d = dict(item) - d["regex"] = re.compile(d["regex"]) if isinstance(d.get("regex"), str) else d["regex"] - if "event_category" in d and isinstance(d["event_category"], str): - d["event_category"] = EventCategory(d["event_category"]) - if "event_priority" in d: - p = d["event_priority"] - if isinstance(p, str): - d["event_priority"] = getattr(EventPriority, p.upper(), EventPriority.WARNING) - elif isinstance(p, int): - d["event_priority"] = EventPriority(p) - out.append(ErrorRegex(**d)) - return out - - -def _normalize_prio_map(d: Optional[Dict[Any, Any]]) -> Optional[Dict[int, int]]: - """Convert expected_qos_prio_map (config may have str keys) to Dict[int, int].""" - if d is None: - return None - return {int(k): int(v) for k, v in d.items()} - - -def _normalize_tsa_map(d: Optional[Dict[Any, Any]]) -> Optional[Dict[int, str]]: - """Convert expected_qos_tsa_map (config may have str keys) to Dict[int, str].""" - if d is None: - return None - return {int(k): str(v) for k, v in d.items()} - - -def _firmware_records(data: NicDataModel) -> list[Dict[str, Any]]: - """Build normalized records for Broadcom and Pensando NIC output.""" - records: list[Dict[str, Any]] = [] - devices = {device.device_num: device for device in data.broadcom_nic_devices} - for device_num, version in data.broadcom_nic_firmware.items(): - device = devices.get(device_num) - records.append( - { - "identity": str(device_num), - "version": version, - "source": data.broadcom_cli_type or "broadcom", - "vendor": "Broadcom", - "model": device.model if device else None, - "interface": device.interface_name if device else None, - "pci_bdf": device.pci_address if device else None, - } - ) - for device in data.broadcom_nic_devices: - if device.device_num not in data.broadcom_nic_firmware: - records.append( - { - "identity": str(device.device_num), - "version": None, - "source": data.broadcom_cli_type or "broadcom", - "vendor": "Broadcom", - "model": device.model, - "interface": device.interface_name, - "pci_bdf": device.pci_address, - } - ) - for firmware in data.pensando_nic_version_firmware: - records.append( - { - "identity": firmware.nic_id, - "version": firmware.firmware_a, - "source": "nicctl", - "vendor": "Pensando", - "pci_bdf": firmware.pcie_bdf, - } - ) - return records - - -class NicAnalyzer(DataAnalyzer[NicDataModel, NicAnalyzerArgs]): - """Analyze niccli/nicctl data; checks Broadcom support_rdma, performance_profile (RoCE), pcie_relaxed_ordering (enabled), and getqos (expected QoS across adapters).""" - - DATA_MODEL = NicDataModel - - def analyze_data( - self, data: NicDataModel, args: Optional[NicAnalyzerArgs] = None - ) -> TaskResult: - """Run checks on the collected data (Broadcom support_rdma, performance_profile, pcie_relaxed_ordering, getqos per device).""" - if args is None: - args = NicAnalyzerArgs() - - firmware_policy_issues = _validate_firmware_policy( - _firmware_records(data), - ( - {"expected_nic_firmware": args.expected_nic_firmware} - if args.expected_nic_firmware - else None - ), - ) - for issue in firmware_policy_issues: - self._log_event( - category=EventCategory.NETWORK, - description="Network adapter firmware policy mismatch", - data=issue, - priority=EventPriority.WARNING, - console_log=True, - ) - has_firmware_data = bool( - data.broadcom_nic_devices - or data.broadcom_nic_firmware - or data.pensando_nic_version_firmware - ) - - has_broadcom = bool(data.broadcom_nic_support_rdma) - has_nicctl_logs = bool( - data.nicctl_card_logs and any((c or "").strip() for c in data.nicctl_card_logs.values()) - ) - if not has_broadcom and not has_nicctl_logs and not has_firmware_data: - self.result.message = "No Broadcom support_rdma or nicctl card log data to check" - self.result.status = ExecutionStatus.OK - return self.result - - disabled_values = set(args.support_rdma_disabled_values) - any_disabled = False - for device_num, value in sorted(data.broadcom_nic_support_rdma.items()): - value_lower = (value or "").strip().lower() - if value_lower in disabled_values: - any_disabled = True - self._log_event( - category=EventCategory.NETWORK, - description=f"Broadcom device {device_num}: support_rdma is disabled or off", - data={"device_num": device_num, "support_rdma_output": value}, - priority=EventPriority.WARNING, - console_log=True, - ) - else: - self._log_event( - category=EventCategory.NETWORK, - description=f"Broadcom device {device_num}: support_rdma = {value!r}", - data={"device_num": device_num, "support_rdma_output": value}, - priority=EventPriority.INFO, - ) - - if any_disabled: - self.result.message = "One or more Broadcom devices have support_rdma disabled" - self.result.status = ExecutionStatus.WARNING - - # performance_profile expected value check (default RoCE) - expected_profile = args.performance_profile_expected.strip() - expected_profile_lower = expected_profile.lower() - any_non_roce = False - if data.broadcom_nic_performance_profile: - for device_num, value in sorted(data.broadcom_nic_performance_profile.items()): - value_normalized = (value or "").strip().lower() - if expected_profile_lower not in value_normalized: - any_non_roce = True - self._log_event( - category=EventCategory.NETWORK, - description=f"Broadcom device {device_num}: performance_profile is {value!r} (expected {expected_profile})", - data={"device_num": device_num, "performance_profile_output": value}, - priority=EventPriority.WARNING, - console_log=True, - ) - else: - self._log_event( - category=EventCategory.NETWORK, - description=f"Broadcom device {device_num}: performance_profile = {expected_profile}", - data={"device_num": device_num, "performance_profile_output": value}, - priority=EventPriority.INFO, - ) - - # pcie_relaxed_ordering check (default: output should indicate "enabled") - expected_ro = args.pcie_relaxed_ordering_expected.strip().lower() - any_relaxed_ordering_bad = False - if data.broadcom_nic_pcie_relaxed_ordering and expected_ro: - for device_num, value in sorted(data.broadcom_nic_pcie_relaxed_ordering.items()): - value_lower = (value or "").strip().lower() - if expected_ro not in value_lower: - any_relaxed_ordering_bad = True - self._log_event( - category=EventCategory.NETWORK, - description=f"Broadcom device {device_num}: pcie_relaxed_ordering does not show {args.pcie_relaxed_ordering_expected!r} (got {value!r})", - data={"device_num": device_num, "pcie_relaxed_ordering_output": value}, - priority=EventPriority.WARNING, - console_log=True, - ) - else: - self._log_event( - category=EventCategory.NETWORK, - description=f"Broadcom device {device_num}: pcie_relaxed_ordering = {args.pcie_relaxed_ordering_expected}", - data={"device_num": device_num, "pcie_relaxed_ordering_output": value}, - priority=EventPriority.INFO, - ) - - # getqos: expected QoS (priorities, PFC, ETS) across all adapters - any_qos_mismatch = False - expected_prio = _normalize_prio_map(args.expected_qos_prio_map) - expected_tsa = _normalize_tsa_map(args.expected_qos_tsa_map) - if ( - expected_prio is not None - or args.expected_qos_pfc_enabled is not None - or expected_tsa is not None - or args.expected_qos_tc_bandwidth is not None - ): - for device_num, qos in sorted(data.broadcom_nic_qos.items()): - mismatches = [] - if expected_prio is not None and qos.prio_map != expected_prio: - mismatches.append(f"prio_map {qos.prio_map!r} != expected {expected_prio!r}") - if ( - args.expected_qos_pfc_enabled is not None - and qos.pfc_enabled != args.expected_qos_pfc_enabled - ): - mismatches.append( - f"pfc_enabled {qos.pfc_enabled!r} != expected {args.expected_qos_pfc_enabled!r}" - ) - if expected_tsa is not None and qos.tsa_map != expected_tsa: - mismatches.append(f"tsa_map {qos.tsa_map!r} != expected {expected_tsa!r}") - if ( - args.expected_qos_tc_bandwidth is not None - and qos.tc_bandwidth != args.expected_qos_tc_bandwidth - ): - mismatches.append( - f"tc_bandwidth {qos.tc_bandwidth!r} != expected {args.expected_qos_tc_bandwidth!r}" - ) - if mismatches: - any_qos_mismatch = True - self._log_event( - category=EventCategory.NETWORK, - description=f"Broadcom device {device_num}: getqos does not match expected QoS: {'; '.join(mismatches)}", - data={ - "device_num": device_num, - "mismatches": mismatches, - }, - priority=EventPriority.WARNING, - console_log=True, - ) - else: - self._log_event( - category=EventCategory.NETWORK, - description=f"Broadcom device {device_num}: getqos matches expected (priorities, PFC, ETS)", - data={"device_num": device_num}, - priority=EventPriority.INFO, - ) - elif args.require_qos_consistent_across_adapters and len(data.broadcom_nic_qos) >= 2: - qos_list = list(data.broadcom_nic_qos.values()) - first = qos_list[0] - for device_num, qos in sorted(data.broadcom_nic_qos.items()): - if ( - qos.prio_map != first.prio_map - or qos.pfc_enabled != first.pfc_enabled - or qos.tsa_map != first.tsa_map - ): - any_qos_mismatch = True - self._log_event( - category=EventCategory.NETWORK, - description=f"Broadcom device {device_num}: getqos differs from other adapters (priorities, PFC, or ETS not consistent)", - data={"device_num": device_num, "qos": qos.model_dump()}, - priority=EventPriority.WARNING, - console_log=True, - ) - else: - self._log_event( - category=EventCategory.NETWORK, - description=f"Broadcom device {device_num}: getqos consistent with other adapters", - data={"device_num": device_num}, - priority=EventPriority.INFO, - ) - - # nicctl card logs (boot-fault, persistent, non-persistent): run error regexes and log matches to user. - any_nicctl_log_errors = False - if data.nicctl_card_logs: - regex_list = _nicctl_log_error_regex_list(args) - for log_type, content in data.nicctl_card_logs.items(): - if not (content or "").strip(): - continue - for err_regex in regex_list: - for match in err_regex.regex.finditer(content): - matched_text = match.group(0).strip() or match.group(0) - if len(matched_text) > 500: - matched_text = matched_text[:497] + "..." - any_nicctl_log_errors = True - self._log_event( - category=err_regex.event_category, - description=f"nicctl card log ({log_type}): {err_regex.message} — {matched_text!r}", - data={ - "log_type": log_type, - "message": err_regex.message, - "match_content": matched_text, - }, - priority=err_regex.event_priority, - console_log=True, - ) - - any_firmware_mismatch = bool(firmware_policy_issues) - if ( - any_disabled - or any_non_roce - or any_relaxed_ordering_bad - or any_qos_mismatch - or any_nicctl_log_errors - or any_firmware_mismatch - ): - self.result.status = ExecutionStatus.WARNING - parts = [] - if any_disabled: - parts.append("support_rdma") - if any_non_roce: - parts.append("performance_profile") - if any_relaxed_ordering_bad: - parts.append("pcie_relaxed_ordering") - if any_qos_mismatch: - parts.append("getqos") - if any_nicctl_log_errors: - parts.append("nicctl_card_logs") - if any_firmware_mismatch: - parts.append("firmware") - self.result.message = f"Broadcom/nic check(s) failed: {' and/or '.join(parts)}" - else: - self.result.status = ExecutionStatus.OK - self.result.message = "Broadcom support_rdma, performance_profile, pcie_relaxed_ordering, getqos, and nicctl card logs checks OK" - return self.result +############################################################################### +# +# 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, Dict, List, Optional + +from nodescraper.base.regexanalyzer import ErrorRegex +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.interfaces import DataAnalyzer +from nodescraper.models import TaskResult +from nodescraper.utils import _validate_firmware_policy + +from .analyzer_args import NicAnalyzerArgs +from .nic_data import NicDataModel + +# Default regexes for nicctl show card logs (boot-fault, persistent, non-persistent) +DEFAULT_NICCTL_LOG_ERROR_REGEX: List[ErrorRegex] = [ + ErrorRegex( + regex=re.compile(r"\berror\b", re.IGNORECASE), + message="nicctl card log: error", + event_category=EventCategory.NETWORK, + event_priority=EventPriority.WARNING, + ), + ErrorRegex( + regex=re.compile(r"\bfail(?:ed|ure)?\b", re.IGNORECASE), + message="nicctl card log: fail/failed/failure", + event_category=EventCategory.NETWORK, + event_priority=EventPriority.WARNING, + ), + ErrorRegex( + regex=re.compile(r"\bfault\b", re.IGNORECASE), + message="nicctl card log: fault", + event_category=EventCategory.NETWORK, + event_priority=EventPriority.WARNING, + ), + ErrorRegex( + regex=re.compile(r"\bcritical\b", re.IGNORECASE), + message="nicctl card log: critical", + event_category=EventCategory.NETWORK, + event_priority=EventPriority.WARNING, + ), +] + + +def _nicctl_log_error_regex_list( + args: NicAnalyzerArgs, +) -> List[ErrorRegex]: + """Return list of ErrorRegex for nicctl card logs (from args or default).""" + if not args.nicctl_log_error_regex: + return list(DEFAULT_NICCTL_LOG_ERROR_REGEX) + out: List[ErrorRegex] = [] + for item in args.nicctl_log_error_regex: + if isinstance(item, ErrorRegex): + out.append(item) + elif isinstance(item, dict): + d = dict(item) + d["regex"] = re.compile(d["regex"]) if isinstance(d.get("regex"), str) else d["regex"] + if "event_category" in d and isinstance(d["event_category"], str): + d["event_category"] = EventCategory(d["event_category"]) + if "event_priority" in d: + p = d["event_priority"] + if isinstance(p, str): + d["event_priority"] = getattr(EventPriority, p.upper(), EventPriority.WARNING) + elif isinstance(p, int): + d["event_priority"] = EventPriority(p) + out.append(ErrorRegex(**d)) + return out + + +def _normalize_prio_map(d: Optional[Dict[Any, Any]]) -> Optional[Dict[int, int]]: + """Convert expected_qos_prio_map (config may have str keys) to Dict[int, int].""" + if d is None: + return None + return {int(k): int(v) for k, v in d.items()} + + +def _normalize_tsa_map(d: Optional[Dict[Any, Any]]) -> Optional[Dict[int, str]]: + """Convert expected_qos_tsa_map (config may have str keys) to Dict[int, str].""" + if d is None: + return None + return {int(k): str(v) for k, v in d.items()} + + +def _firmware_records(data: NicDataModel) -> list[Dict[str, Any]]: + """Build normalized records for Broadcom and Pensando NIC output.""" + records: list[Dict[str, Any]] = [] + devices = {device.device_num: device for device in data.broadcom_nic_devices} + for device_num, version in data.broadcom_nic_firmware.items(): + device = devices.get(device_num) + records.append( + { + "identity": str(device_num), + "version": version, + "source": data.broadcom_cli_type or "broadcom", + "vendor": "Broadcom", + "model": device.model if device else None, + "interface": device.interface_name if device else None, + "pci_bdf": device.pci_address if device else None, + } + ) + for device in data.broadcom_nic_devices: + if device.device_num not in data.broadcom_nic_firmware: + records.append( + { + "identity": str(device.device_num), + "version": None, + "source": data.broadcom_cli_type or "broadcom", + "vendor": "Broadcom", + "model": device.model, + "interface": device.interface_name, + "pci_bdf": device.pci_address, + } + ) + for firmware in data.pensando_nic_version_firmware: + records.append( + { + "identity": firmware.nic_id, + "version": firmware.firmware_a, + "source": "nicctl", + "vendor": "Pensando", + "pci_bdf": firmware.pcie_bdf, + } + ) + return records + + +class NicAnalyzer(DataAnalyzer[NicDataModel, NicAnalyzerArgs]): + """Analyze niccli/nicctl data; checks Broadcom support_rdma, performance_profile (RoCE), pcie_relaxed_ordering (enabled), and getqos (expected QoS across adapters).""" + + DATA_MODEL = NicDataModel + + def analyze_data( + self, data: NicDataModel, args: Optional[NicAnalyzerArgs] = None + ) -> TaskResult: + """Run checks on the collected data (Broadcom support_rdma, performance_profile, pcie_relaxed_ordering, getqos per device).""" + if args is None: + args = NicAnalyzerArgs() + + firmware_policy_issues = _validate_firmware_policy( + _firmware_records(data), + ( + {"expected_nic_firmware": args.expected_nic_firmware} + if args.expected_nic_firmware + else None + ), + ) + for issue in firmware_policy_issues: + self._log_event( + category=EventCategory.NETWORK, + description="Network adapter firmware policy mismatch", + data=issue, + priority=EventPriority.WARNING, + console_log=True, + ) + has_firmware_data = bool( + data.broadcom_nic_devices + or data.broadcom_nic_firmware + or data.pensando_nic_version_firmware + ) + + has_broadcom = bool(data.broadcom_nic_support_rdma) + has_nicctl_logs = bool( + data.nicctl_card_logs and any((c or "").strip() for c in data.nicctl_card_logs.values()) + ) + if not has_broadcom and not has_nicctl_logs and not has_firmware_data: + self.result.message = "No Broadcom support_rdma or nicctl card log data to check" + self.result.status = ExecutionStatus.OK + return self.result + + disabled_values = set(args.support_rdma_disabled_values) + any_disabled = False + for device_num, value in sorted(data.broadcom_nic_support_rdma.items()): + value_lower = (value or "").strip().lower() + if value_lower in disabled_values: + any_disabled = True + self._log_event( + category=EventCategory.NETWORK, + description=f"Broadcom device {device_num}: support_rdma is disabled or off", + data={"device_num": device_num, "support_rdma_output": value}, + priority=EventPriority.WARNING, + console_log=True, + ) + else: + self._log_event( + category=EventCategory.NETWORK, + description=f"Broadcom device {device_num}: support_rdma = {value!r}", + data={"device_num": device_num, "support_rdma_output": value}, + priority=EventPriority.INFO, + ) + + if any_disabled: + self.result.message = "One or more Broadcom devices have support_rdma disabled" + self.result.status = ExecutionStatus.WARNING + + # performance_profile expected value check (default RoCE) + expected_profile = args.performance_profile_expected.strip() + expected_profile_lower = expected_profile.lower() + any_non_roce = False + if data.broadcom_nic_performance_profile: + for device_num, value in sorted(data.broadcom_nic_performance_profile.items()): + value_normalized = (value or "").strip().lower() + if expected_profile_lower not in value_normalized: + any_non_roce = True + self._log_event( + category=EventCategory.NETWORK, + description=f"Broadcom device {device_num}: performance_profile is {value!r} (expected {expected_profile})", + data={"device_num": device_num, "performance_profile_output": value}, + priority=EventPriority.WARNING, + console_log=True, + ) + else: + self._log_event( + category=EventCategory.NETWORK, + description=f"Broadcom device {device_num}: performance_profile = {expected_profile}", + data={"device_num": device_num, "performance_profile_output": value}, + priority=EventPriority.INFO, + ) + + # pcie_relaxed_ordering check (default: output should indicate "enabled") + expected_ro = args.pcie_relaxed_ordering_expected.strip().lower() + any_relaxed_ordering_bad = False + if data.broadcom_nic_pcie_relaxed_ordering and expected_ro: + for device_num, value in sorted(data.broadcom_nic_pcie_relaxed_ordering.items()): + value_lower = (value or "").strip().lower() + if expected_ro not in value_lower: + any_relaxed_ordering_bad = True + self._log_event( + category=EventCategory.NETWORK, + description=f"Broadcom device {device_num}: pcie_relaxed_ordering does not show {args.pcie_relaxed_ordering_expected!r} (got {value!r})", + data={"device_num": device_num, "pcie_relaxed_ordering_output": value}, + priority=EventPriority.WARNING, + console_log=True, + ) + else: + self._log_event( + category=EventCategory.NETWORK, + description=f"Broadcom device {device_num}: pcie_relaxed_ordering = {args.pcie_relaxed_ordering_expected}", + data={"device_num": device_num, "pcie_relaxed_ordering_output": value}, + priority=EventPriority.INFO, + ) + + # getqos: expected QoS (priorities, PFC, ETS) across all adapters + any_qos_mismatch = False + expected_prio = _normalize_prio_map(args.expected_qos_prio_map) + expected_tsa = _normalize_tsa_map(args.expected_qos_tsa_map) + if ( + expected_prio is not None + or args.expected_qos_pfc_enabled is not None + or expected_tsa is not None + or args.expected_qos_tc_bandwidth is not None + ): + for device_num, qos in sorted(data.broadcom_nic_qos.items()): + mismatches = [] + if expected_prio is not None and qos.prio_map != expected_prio: + mismatches.append(f"prio_map {qos.prio_map!r} != expected {expected_prio!r}") + if ( + args.expected_qos_pfc_enabled is not None + and qos.pfc_enabled != args.expected_qos_pfc_enabled + ): + mismatches.append( + f"pfc_enabled {qos.pfc_enabled!r} != expected {args.expected_qos_pfc_enabled!r}" + ) + if expected_tsa is not None and qos.tsa_map != expected_tsa: + mismatches.append(f"tsa_map {qos.tsa_map!r} != expected {expected_tsa!r}") + if ( + args.expected_qos_tc_bandwidth is not None + and qos.tc_bandwidth != args.expected_qos_tc_bandwidth + ): + mismatches.append( + f"tc_bandwidth {qos.tc_bandwidth!r} != expected {args.expected_qos_tc_bandwidth!r}" + ) + if mismatches: + any_qos_mismatch = True + self._log_event( + category=EventCategory.NETWORK, + description=f"Broadcom device {device_num}: getqos does not match expected QoS: {'; '.join(mismatches)}", + data={ + "device_num": device_num, + "mismatches": mismatches, + }, + priority=EventPriority.WARNING, + console_log=True, + ) + else: + self._log_event( + category=EventCategory.NETWORK, + description=f"Broadcom device {device_num}: getqos matches expected (priorities, PFC, ETS)", + data={"device_num": device_num}, + priority=EventPriority.INFO, + ) + elif args.require_qos_consistent_across_adapters and len(data.broadcom_nic_qos) >= 2: + qos_list = list(data.broadcom_nic_qos.values()) + first = qos_list[0] + for device_num, qos in sorted(data.broadcom_nic_qos.items()): + if ( + qos.prio_map != first.prio_map + or qos.pfc_enabled != first.pfc_enabled + or qos.tsa_map != first.tsa_map + ): + any_qos_mismatch = True + self._log_event( + category=EventCategory.NETWORK, + description=f"Broadcom device {device_num}: getqos differs from other adapters (priorities, PFC, or ETS not consistent)", + data={"device_num": device_num, "qos": qos.model_dump()}, + priority=EventPriority.WARNING, + console_log=True, + ) + else: + self._log_event( + category=EventCategory.NETWORK, + description=f"Broadcom device {device_num}: getqos consistent with other adapters", + data={"device_num": device_num}, + priority=EventPriority.INFO, + ) + + # nicctl card logs (boot-fault, persistent, non-persistent): run error regexes and log matches to user. + any_nicctl_log_errors = False + if data.nicctl_card_logs: + regex_list = _nicctl_log_error_regex_list(args) + for log_type, content in data.nicctl_card_logs.items(): + if not (content or "").strip(): + continue + for err_regex in regex_list: + for match in err_regex.regex.finditer(content): + matched_text = match.group(0).strip() or match.group(0) + if len(matched_text) > 500: + matched_text = matched_text[:497] + "..." + any_nicctl_log_errors = True + self._log_event( + category=err_regex.event_category, + description=f"nicctl card log ({log_type}): {err_regex.message} — {matched_text!r}", + data={ + "log_type": log_type, + "message": err_regex.message, + "match_content": matched_text, + }, + priority=err_regex.event_priority, + console_log=True, + ) + + any_firmware_mismatch = bool(firmware_policy_issues) + if ( + any_disabled + or any_non_roce + or any_relaxed_ordering_bad + or any_qos_mismatch + or any_nicctl_log_errors + or any_firmware_mismatch + ): + self.result.status = ExecutionStatus.WARNING + parts = [] + if any_disabled: + parts.append("support_rdma") + if any_non_roce: + parts.append("performance_profile") + if any_relaxed_ordering_bad: + parts.append("pcie_relaxed_ordering") + if any_qos_mismatch: + parts.append("getqos") + if any_nicctl_log_errors: + parts.append("nicctl_card_logs") + if any_firmware_mismatch: + parts.append("firmware") + self.result.message = f"Broadcom/nic check(s) failed: {' and/or '.join(parts)}" + else: + self.result.status = ExecutionStatus.OK + self.result.message = "Broadcom support_rdma, performance_profile, pcie_relaxed_ordering, getqos, and nicctl card logs checks OK" + return self.result diff --git a/nodescraper/plugins/inband/sys_settings/analyzer_args.py b/nodescraper/plugins/inband/sys_settings/analyzer_args.py index 594a4ca7..6778e639 100644 --- a/nodescraper/plugins/inband/sys_settings/analyzer_args.py +++ b/nodescraper/plugins/inband/sys_settings/analyzer_args.py @@ -1,92 +1,92 @@ -############################################################################### -# -# 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 Optional - -from pydantic import BaseModel, Field - -from nodescraper.models import AnalyzerArgs - - -class SysfsCheck(BaseModel): - """One sysfs check: path to read, acceptable values or pattern, and display name. - - For file paths: use expected (list of acceptable values); if empty, check passes. - For directory paths: use pattern (regex); at least one directory entry must match (e.g. ^hsn[0-9]+). - """ - - path: str = Field(description="Sysfs path to read (file) or list (directory).") - expected: list[str] = Field( - default_factory=list, - description="For file paths: list of acceptable values; if empty, check passes.", - ) - name: str = Field(description="Display name for this check in reports.") - pattern: Optional[str] = Field( - default=None, - description="For directory paths: regex; at least one entry must match (e.g. ^hsn[0-9]+).", - ) - - -class SysSettingsAnalyzerArgs(AnalyzerArgs): - """Sysfs settings for analysis via a list of checks (path, expected values, name). - - The path in each check is the sysfs path to read; the collector uses these paths - when collection_args is derived from analysis_args (e.g. by the plugin). - """ - - checks: Optional[list[SysfsCheck]] = Field( - default=None, - description="List of sysfs checks (path, expected values or pattern, display name).", - ) - - def paths_to_collect(self) -> list[str]: - """Return unique sysfs file paths from checks (those without pattern), for use by the collector.""" - if not self.checks: - return [] - seen = set() - out = [] - for c in self.checks: - if c.pattern: - continue - p = c.path.rstrip("/") - if p not in seen: - seen.add(p) - out.append(c.path) - return out - - def paths_to_list(self) -> list[str]: - """Return unique sysfs directory paths from checks (those with pattern), for listing (ls).""" - if not self.checks: - return [] - seen = set() - out = [] - for c in self.checks: - if not c.pattern: - continue - p = c.path.rstrip("/") - if p not in seen: - seen.add(p) - out.append(c.path) - return out +############################################################################### +# +# 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 Optional + +from pydantic import BaseModel, Field + +from nodescraper.models import AnalyzerArgs + + +class SysfsCheck(BaseModel): + """One sysfs check: path to read, acceptable values or pattern, and display name. + + For file paths: use expected (list of acceptable values); if empty, check passes. + For directory paths: use pattern (regex); at least one directory entry must match (e.g. ^hsn[0-9]+). + """ + + path: str = Field(description="Sysfs path to read (file) or list (directory).") + expected: list[str] = Field( + default_factory=list, + description="For file paths: list of acceptable values; if empty, check passes.", + ) + name: str = Field(description="Display name for this check in reports.") + pattern: Optional[str] = Field( + default=None, + description="For directory paths: regex; at least one entry must match (e.g. ^hsn[0-9]+).", + ) + + +class SysSettingsAnalyzerArgs(AnalyzerArgs): + """Sysfs settings for analysis via a list of checks (path, expected values, name). + + The path in each check is the sysfs path to read; the collector uses these paths + when collection_args is derived from analysis_args (e.g. by the plugin). + """ + + checks: Optional[list[SysfsCheck]] = Field( + default=None, + description="List of sysfs checks (path, expected values or pattern, display name).", + ) + + def paths_to_collect(self) -> list[str]: + """Return unique sysfs file paths from checks (those without pattern), for use by the collector.""" + if not self.checks: + return [] + seen = set() + out = [] + for c in self.checks: + if c.pattern: + continue + p = c.path.rstrip("/") + if p not in seen: + seen.add(p) + out.append(c.path) + return out + + def paths_to_list(self) -> list[str]: + """Return unique sysfs directory paths from checks (those with pattern), for listing (ls).""" + if not self.checks: + return [] + seen = set() + out = [] + for c in self.checks: + if not c.pattern: + continue + p = c.path.rstrip("/") + if p not in seen: + seen.add(p) + out.append(c.path) + return out diff --git a/nodescraper/plugins/inband/sys_settings/collector_args.py b/nodescraper/plugins/inband/sys_settings/collector_args.py index 7fe0bdad..40ef94ab 100644 --- a/nodescraper/plugins/inband/sys_settings/collector_args.py +++ b/nodescraper/plugins/inband/sys_settings/collector_args.py @@ -1,50 +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 pydantic import Field - -from nodescraper.models import CollectorArgs - - -class SysSettingsCollectorArgs(CollectorArgs): - """Collection args for SysSettingsCollector. - - paths: sysfs paths to read (cat). If a path contains '*', collect with - ``cat`` first (e.g. class/drm/card*/device/vbios_version). If cat fails, - fall back to ls -l (e.g. class/net/*/device). - directory_paths: sysfs paths to list (ls -1); use for checks that match entry names by regex. - """ - - paths: list[str] = Field( - default_factory=list, - description=( - "Sysfs paths to read (cat). Paths with '*' are cat'd via bash " - "(e.g. class/drm/card*/device/vbios_version); if cat fails, ls -l is used." - ), - ) - directory_paths: list[str] = Field( - default_factory=list, - description="Sysfs paths to list (ls -1); used for checks that match entry names by regex.", - ) +############################################################################### +# +# 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 SysSettingsCollectorArgs(CollectorArgs): + """Collection args for SysSettingsCollector. + + paths: sysfs paths to read (cat). If a path contains '*', collect with + ``cat`` first (e.g. class/drm/card*/device/vbios_version). If cat fails, + fall back to ls -l (e.g. class/net/*/device). + directory_paths: sysfs paths to list (ls -1); use for checks that match entry names by regex. + """ + + paths: list[str] = Field( + default_factory=list, + description=( + "Sysfs paths to read (cat). Paths with '*' are cat'd via bash " + "(e.g. class/drm/card*/device/vbios_version); if cat fails, ls -l is used." + ), + ) + directory_paths: list[str] = Field( + default_factory=list, + description="Sysfs paths to list (ls -1); used for checks that match entry names by regex.", + ) diff --git a/nodescraper/plugins/inband/sys_settings/sys_settings_analyzer.py b/nodescraper/plugins/inband/sys_settings/sys_settings_analyzer.py index 276b73ce..f31f7432 100644 --- a/nodescraper/plugins/inband/sys_settings/sys_settings_analyzer.py +++ b/nodescraper/plugins/inband/sys_settings/sys_settings_analyzer.py @@ -1,161 +1,161 @@ -############################################################################### -# -# 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 List, Optional, Union, cast - -from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus -from nodescraper.interfaces import DataAnalyzer -from nodescraper.models import TaskResult - -from .analyzer_args import SysSettingsAnalyzerArgs -from .sys_settings_data import SysSettingsDataModel - - -def _get_actual_for_path(data: SysSettingsDataModel, path: str) -> Optional[str]: - """Return the actual value from the data model for the given sysfs path. - - Args: - data: Collected sysfs readings (path -> value). - path: Sysfs path (with or without trailing slash). - - Returns: - Normalized value for that path, or None if not present. - """ - value = data.readings.get(path) or data.readings.get(path.rstrip("/")) - return (value or "").strip().lower() if value is not None else None - - -class SysSettingsAnalyzer(DataAnalyzer[SysSettingsDataModel, SysSettingsAnalyzerArgs]): - """Check sysfs settings against expected values from the checks list.""" - - DATA_MODEL = SysSettingsDataModel - - def analyze_data( - self, data: SysSettingsDataModel, args: Optional[SysSettingsAnalyzerArgs] = None - ) -> TaskResult: - """Compare sysfs data to expected settings from args.checks. - - Args: - data: Collected sysfs readings to check. - args: Analyzer args with checks (path, expected, name). If None or no checks, returns OK. - - Returns: - TaskResult with status OK if all checks pass, ERROR if any mismatch or missing path. - """ - mismatches: dict[str, dict[str, Union[Optional[str], List[str]]]] = {} - - if not args or not args.checks: - self.result.status = ExecutionStatus.OK - self.result.message = "No checks configured." - return self.result - - for check in args.checks: - raw_reading = data.readings.get(check.path) or data.readings.get(check.path.rstrip("/")) - - if check.pattern: - # Directory-listing check: at least one line must match the regex (e.g. ^hsn[0-9]+) - if raw_reading is None: - mismatches[check.name] = { - "path": check.path, - "pattern": check.pattern, - "actual": None, - "reason": "path not collected by this plugin", - } - continue - try: - pat = re.compile(check.pattern) - except re.error: - mismatches[check.name] = { - "path": check.path, - "pattern": check.pattern, - "reason": "invalid regex", - } - continue - lines = [ln.strip() for ln in raw_reading.splitlines() if ln.strip()] - if not any(pat.search(ln) for ln in lines): - mismatches[check.name] = { - "path": check.path, - "pattern": check.pattern, - "actual": lines, - } - continue - - actual = _get_actual_for_path(data, check.path) - if actual is None: - mismatches[check.name] = { - "path": check.path, - "expected": check.expected, - "actual": None, - "reason": "path not collected by this plugin", - } - continue - - if not check.expected: - continue - expected_normalized = [e.strip().lower() for e in check.expected] - if actual not in expected_normalized: - raw = data.readings.get(check.path) or data.readings.get(check.path.rstrip("/")) - mismatches[check.name] = { - "path": check.path, - "expected": check.expected, - "actual": raw, - } - - if mismatches: - self.result.status = ExecutionStatus.ERROR - parts = [] - for name, info in mismatches.items(): - path = info.get("path", "") - reason = info.get("reason") - pattern = info.get("pattern") - if reason: - part = f"{name} ({path}): {reason}" - elif pattern is not None: - part = f"{name} ({path}): no entry matching pattern {pattern!r}" - else: - expected = info.get("expected") - actual = cast(Optional[str], info.get("actual")) - part = f"{name} ({path}): expected one of {expected}, actual {repr(actual)}" - parts.append(part) - self.result.message = "Sysfs mismatch: " + "; ".join(parts) - self._log_event( - category=EventCategory.OS, - description="Sysfs mismatch detected", - data=mismatches, - priority=EventPriority.ERROR, - console_log=True, - ) - else: - self._log_event( - category=EventCategory.OS, - description="Sysfs settings match expected", - priority=EventPriority.INFO, - console_log=True, - ) - self.result.status = ExecutionStatus.OK - self.result.message = "Sysfs settings as expected." - - return self.result +############################################################################### +# +# 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 List, Optional, Union, cast + +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.interfaces import DataAnalyzer +from nodescraper.models import TaskResult + +from .analyzer_args import SysSettingsAnalyzerArgs +from .sys_settings_data import SysSettingsDataModel + + +def _get_actual_for_path(data: SysSettingsDataModel, path: str) -> Optional[str]: + """Return the actual value from the data model for the given sysfs path. + + Args: + data: Collected sysfs readings (path -> value). + path: Sysfs path (with or without trailing slash). + + Returns: + Normalized value for that path, or None if not present. + """ + value = data.readings.get(path) or data.readings.get(path.rstrip("/")) + return (value or "").strip().lower() if value is not None else None + + +class SysSettingsAnalyzer(DataAnalyzer[SysSettingsDataModel, SysSettingsAnalyzerArgs]): + """Check sysfs settings against expected values from the checks list.""" + + DATA_MODEL = SysSettingsDataModel + + def analyze_data( + self, data: SysSettingsDataModel, args: Optional[SysSettingsAnalyzerArgs] = None + ) -> TaskResult: + """Compare sysfs data to expected settings from args.checks. + + Args: + data: Collected sysfs readings to check. + args: Analyzer args with checks (path, expected, name). If None or no checks, returns OK. + + Returns: + TaskResult with status OK if all checks pass, ERROR if any mismatch or missing path. + """ + mismatches: dict[str, dict[str, Union[Optional[str], List[str]]]] = {} + + if not args or not args.checks: + self.result.status = ExecutionStatus.OK + self.result.message = "No checks configured." + return self.result + + for check in args.checks: + raw_reading = data.readings.get(check.path) or data.readings.get(check.path.rstrip("/")) + + if check.pattern: + # Directory-listing check: at least one line must match the regex (e.g. ^hsn[0-9]+) + if raw_reading is None: + mismatches[check.name] = { + "path": check.path, + "pattern": check.pattern, + "actual": None, + "reason": "path not collected by this plugin", + } + continue + try: + pat = re.compile(check.pattern) + except re.error: + mismatches[check.name] = { + "path": check.path, + "pattern": check.pattern, + "reason": "invalid regex", + } + continue + lines = [ln.strip() for ln in raw_reading.splitlines() if ln.strip()] + if not any(pat.search(ln) for ln in lines): + mismatches[check.name] = { + "path": check.path, + "pattern": check.pattern, + "actual": lines, + } + continue + + actual = _get_actual_for_path(data, check.path) + if actual is None: + mismatches[check.name] = { + "path": check.path, + "expected": check.expected, + "actual": None, + "reason": "path not collected by this plugin", + } + continue + + if not check.expected: + continue + expected_normalized = [e.strip().lower() for e in check.expected] + if actual not in expected_normalized: + raw = data.readings.get(check.path) or data.readings.get(check.path.rstrip("/")) + mismatches[check.name] = { + "path": check.path, + "expected": check.expected, + "actual": raw, + } + + if mismatches: + self.result.status = ExecutionStatus.ERROR + parts = [] + for name, info in mismatches.items(): + path = info.get("path", "") + reason = info.get("reason") + pattern = info.get("pattern") + if reason: + part = f"{name} ({path}): {reason}" + elif pattern is not None: + part = f"{name} ({path}): no entry matching pattern {pattern!r}" + else: + expected = info.get("expected") + actual = cast(Optional[str], info.get("actual")) + part = f"{name} ({path}): expected one of {expected}, actual {repr(actual)}" + parts.append(part) + self.result.message = "Sysfs mismatch: " + "; ".join(parts) + self._log_event( + category=EventCategory.OS, + description="Sysfs mismatch detected", + data=mismatches, + priority=EventPriority.ERROR, + console_log=True, + ) + else: + self._log_event( + category=EventCategory.OS, + description="Sysfs settings match expected", + priority=EventPriority.INFO, + console_log=True, + ) + self.result.status = ExecutionStatus.OK + self.result.message = "Sysfs settings as expected." + + return self.result diff --git a/nodescraper/plugins/inband/sys_settings/sys_settings_collector.py b/nodescraper/plugins/inband/sys_settings/sys_settings_collector.py index 612082a0..6739a6fc 100644 --- a/nodescraper/plugins/inband/sys_settings/sys_settings_collector.py +++ b/nodescraper/plugins/inband/sys_settings/sys_settings_collector.py @@ -1,242 +1,242 @@ -############################################################################### -# -# 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 Optional - -from nodescraper.base import InBandDataCollector -from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus, OSFamily -from nodescraper.models import TaskResult - -from .collector_args import SysSettingsCollectorArgs -from .sys_settings_data import SysSettingsDataModel - -BRACKETED_RE = re.compile(r"\[(\w+)\]") - - -def _parse_bracketed_setting(content: str) -> Optional[str]: - """Extract the active setting from sysfs content. - - Args: - content: Raw sysfs file content (e.g. "[always] madvise never"). - - Returns: - The bracketed value if present, else None. - """ - if not content: - return None - match = BRACKETED_RE.search(content.strip()) - return match.group(1).strip() if match else None - - -def _paths_from_args(args: Optional[SysSettingsCollectorArgs]) -> list[str]: - """Extract list of sysfs paths from collection args. - - Args: - args: Collector args containing paths to read, or None. - - Returns: - List of sysfs paths; empty if args is None or args.paths is empty. - """ - if args is None: - return [] - return list(args.paths) if args.paths else [] - - -def _directory_paths_from_args(args: Optional[SysSettingsCollectorArgs]) -> list[str]: - """Extract list of sysfs directory paths to list from collection args.""" - if args is None: - return [] - return list(args.directory_paths) if args.directory_paths else [] - - -def _path_under_sys(path: str) -> Optional[str]: - """Normalize path to the suffix under /sys/ for use in 'cat /sys/{}'. - - Accepts paths like '/sys/kernel/...' or 'kernel/...'. Returns the relative - part (e.g. 'kernel/mm/transparent_hugepage/enabled'). Returns None if path - contains '..' (e.g. /sys/../etc/passwd, /sys/something/../../etc) or is not under /sys. - """ - if ".." in path: - return None - p = path.strip().lstrip("/") - if p.startswith("sys/"): - p = p[4:] - if p.startswith("/"): - return None - return p if p else None - - -def _sysfs_full_path(suffix: str) -> str: - """Return full path /sys/{suffix} for use as readings key.""" - return f"/sys/{suffix}" - - -def _collapse_glob_cat(stdout: str) -> str: - """Keep unique non-empty lines; store a single value when every match is identical.""" - lines = [line.strip() for line in (stdout or "").splitlines() if line.strip()] - unique = list(dict.fromkeys(lines)) - if not unique: - return "" - if len(unique) == 1: - return unique[0] - return "\n".join(unique) - - -class SysSettingsCollector(InBandDataCollector[SysSettingsDataModel, SysSettingsCollectorArgs]): - """Collect sysfs settings from user-specified paths.""" - - DATA_MODEL = SysSettingsDataModel - SUPPORTED_OS_FAMILY: set[OSFamily] = {OSFamily.LINUX} - - CMD = "cat /sys/{}" - CMD_LS = "ls -1 /sys/{}" - CMD_LS_LONG = "ls -l /sys/{}" - - def collect_data( - self, args: Optional[SysSettingsCollectorArgs] = None - ) -> tuple[TaskResult, Optional[SysSettingsDataModel]]: - """Collect sysfs values for each path in args.paths. - - Glob paths (containing '*') are read with ``bash -c 'cat /sys/...'`` first. - Identical cat lines are collapsed to one value. If cat fails, ``ls -l`` is used. - - Args: - args: Collector args with paths to read; if None or empty paths, returns NOT_RAN. - - Returns: - Tuple of (TaskResult, SysSettingsDataModel or None). Data is None on NOT_RAN or ERROR. - """ - if self.system_info.os_family != OSFamily.LINUX: - self._log_event( - category=EventCategory.OS, - description="Sysfs collection is only supported on Linux.", - priority=EventPriority.WARNING, - console_log=True, - ) - return self.result, None - - paths = _paths_from_args(args) - directory_paths = _directory_paths_from_args(args) - if directory_paths: - self._log_event( - category=EventCategory.OS, - description=f"Sysfs directory_paths to list: {directory_paths}", - data={"directory_paths": directory_paths}, - priority=EventPriority.INFO, - console_log=True, - ) - if not paths and not directory_paths: - self.result.message = "No paths configured for sysfs collection" - self.result.status = ExecutionStatus.NOT_RAN - return self.result, None - - readings: dict[str, str] = {} - for path in paths: - suffix = _path_under_sys(path) - if suffix is None: - self._log_event( - category=EventCategory.OS, - description=f"Skipping path not under /sys or invalid: {path!r}", - data={"path": path}, - priority=EventPriority.WARNING, - console_log=True, - ) - continue - full_path = _sysfs_full_path(suffix) - if "*" in suffix: - cat_cmd = self.CMD.format(suffix) - res = self._run_sut_cmd(f"bash -c {cat_cmd!r}", sudo=False) - if res.exit_code == 0 and res.stdout and res.stdout.strip(): - value = _collapse_glob_cat(res.stdout) - if "\n" not in value: - value = _parse_bracketed_setting(value) or value - readings[full_path] = value - continue - cmd = self.CMD_LS_LONG.format(suffix) - res = self._run_sut_cmd(f"bash -c {cmd!r}", sudo=False) - if res.exit_code == 0: - readings[full_path] = res.stdout.strip() if res.stdout else "" - else: - self._log_event( - category=EventCategory.OS, - description=f"Failed to read sysfs path: {full_path}", - data={"exit_code": res.exit_code}, - priority=EventPriority.WARNING, - console_log=True, - ) - else: - res = self._run_sut_cmd(self.CMD.format(suffix), sudo=False) - if res.exit_code == 0 and res.stdout: - value = _parse_bracketed_setting(res.stdout) or res.stdout.strip() - readings[full_path] = value - else: - self._log_event( - category=EventCategory.OS, - description=f"Failed to read sysfs path: {full_path}", - data={"exit_code": res.exit_code}, - priority=EventPriority.WARNING, - console_log=True, - ) - - for path in directory_paths: - suffix = _path_under_sys(path) - if suffix is None: - self._log_event( - category=EventCategory.OS, - description=f"Skipping directory path not under /sys or invalid: {path!r}", - data={"path": path}, - priority=EventPriority.WARNING, - console_log=True, - ) - continue - full_path = _sysfs_full_path(suffix) - res = self._run_sut_cmd(self.CMD_LS.format(suffix), sudo=False) - if res.exit_code == 0: - readings[full_path] = res.stdout.strip() if res.stdout else "" - else: - self._log_event( - category=EventCategory.OS, - description=f"Failed to list sysfs path: {full_path}", - data={"exit_code": res.exit_code}, - priority=EventPriority.WARNING, - console_log=True, - ) - - if not readings: - self.result.message = "Sysfs settings not read" - self.result.status = ExecutionStatus.ERROR - return self.result, None - - sys_settings_data = SysSettingsDataModel(readings=readings) - self._log_event( - category=EventCategory.OS, - description="Sysfs settings collected", - data=sys_settings_data.model_dump(), - priority=EventPriority.INFO, - ) - self.result.message = f"Sysfs collected {len(readings)} path(s)" - self.result.status = ExecutionStatus.OK - return self.result, sys_settings_data +############################################################################### +# +# 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 Optional + +from nodescraper.base import InBandDataCollector +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus, OSFamily +from nodescraper.models import TaskResult + +from .collector_args import SysSettingsCollectorArgs +from .sys_settings_data import SysSettingsDataModel + +BRACKETED_RE = re.compile(r"\[(\w+)\]") + + +def _parse_bracketed_setting(content: str) -> Optional[str]: + """Extract the active setting from sysfs content. + + Args: + content: Raw sysfs file content (e.g. "[always] madvise never"). + + Returns: + The bracketed value if present, else None. + """ + if not content: + return None + match = BRACKETED_RE.search(content.strip()) + return match.group(1).strip() if match else None + + +def _paths_from_args(args: Optional[SysSettingsCollectorArgs]) -> list[str]: + """Extract list of sysfs paths from collection args. + + Args: + args: Collector args containing paths to read, or None. + + Returns: + List of sysfs paths; empty if args is None or args.paths is empty. + """ + if args is None: + return [] + return list(args.paths) if args.paths else [] + + +def _directory_paths_from_args(args: Optional[SysSettingsCollectorArgs]) -> list[str]: + """Extract list of sysfs directory paths to list from collection args.""" + if args is None: + return [] + return list(args.directory_paths) if args.directory_paths else [] + + +def _path_under_sys(path: str) -> Optional[str]: + """Normalize path to the suffix under /sys/ for use in 'cat /sys/{}'. + + Accepts paths like '/sys/kernel/...' or 'kernel/...'. Returns the relative + part (e.g. 'kernel/mm/transparent_hugepage/enabled'). Returns None if path + contains '..' (e.g. /sys/../etc/passwd, /sys/something/../../etc) or is not under /sys. + """ + if ".." in path: + return None + p = path.strip().lstrip("/") + if p.startswith("sys/"): + p = p[4:] + if p.startswith("/"): + return None + return p if p else None + + +def _sysfs_full_path(suffix: str) -> str: + """Return full path /sys/{suffix} for use as readings key.""" + return f"/sys/{suffix}" + + +def _collapse_glob_cat(stdout: str) -> str: + """Keep unique non-empty lines; store a single value when every match is identical.""" + lines = [line.strip() for line in (stdout or "").splitlines() if line.strip()] + unique = list(dict.fromkeys(lines)) + if not unique: + return "" + if len(unique) == 1: + return unique[0] + return "\n".join(unique) + + +class SysSettingsCollector(InBandDataCollector[SysSettingsDataModel, SysSettingsCollectorArgs]): + """Collect sysfs settings from user-specified paths.""" + + DATA_MODEL = SysSettingsDataModel + SUPPORTED_OS_FAMILY: set[OSFamily] = {OSFamily.LINUX} + + CMD = "cat /sys/{}" + CMD_LS = "ls -1 /sys/{}" + CMD_LS_LONG = "ls -l /sys/{}" + + def collect_data( + self, args: Optional[SysSettingsCollectorArgs] = None + ) -> tuple[TaskResult, Optional[SysSettingsDataModel]]: + """Collect sysfs values for each path in args.paths. + + Glob paths (containing '*') are read with ``bash -c 'cat /sys/...'`` first. + Identical cat lines are collapsed to one value. If cat fails, ``ls -l`` is used. + + Args: + args: Collector args with paths to read; if None or empty paths, returns NOT_RAN. + + Returns: + Tuple of (TaskResult, SysSettingsDataModel or None). Data is None on NOT_RAN or ERROR. + """ + if self.system_info.os_family != OSFamily.LINUX: + self._log_event( + category=EventCategory.OS, + description="Sysfs collection is only supported on Linux.", + priority=EventPriority.WARNING, + console_log=True, + ) + return self.result, None + + paths = _paths_from_args(args) + directory_paths = _directory_paths_from_args(args) + if directory_paths: + self._log_event( + category=EventCategory.OS, + description=f"Sysfs directory_paths to list: {directory_paths}", + data={"directory_paths": directory_paths}, + priority=EventPriority.INFO, + console_log=True, + ) + if not paths and not directory_paths: + self.result.message = "No paths configured for sysfs collection" + self.result.status = ExecutionStatus.NOT_RAN + return self.result, None + + readings: dict[str, str] = {} + for path in paths: + suffix = _path_under_sys(path) + if suffix is None: + self._log_event( + category=EventCategory.OS, + description=f"Skipping path not under /sys or invalid: {path!r}", + data={"path": path}, + priority=EventPriority.WARNING, + console_log=True, + ) + continue + full_path = _sysfs_full_path(suffix) + if "*" in suffix: + cat_cmd = self.CMD.format(suffix) + res = self._run_sut_cmd(f"bash -c {cat_cmd!r}", sudo=False) + if res.exit_code == 0 and res.stdout and res.stdout.strip(): + value = _collapse_glob_cat(res.stdout) + if "\n" not in value: + value = _parse_bracketed_setting(value) or value + readings[full_path] = value + continue + cmd = self.CMD_LS_LONG.format(suffix) + res = self._run_sut_cmd(f"bash -c {cmd!r}", sudo=False) + if res.exit_code == 0: + readings[full_path] = res.stdout.strip() if res.stdout else "" + else: + self._log_event( + category=EventCategory.OS, + description=f"Failed to read sysfs path: {full_path}", + data={"exit_code": res.exit_code}, + priority=EventPriority.WARNING, + console_log=True, + ) + else: + res = self._run_sut_cmd(self.CMD.format(suffix), sudo=False) + if res.exit_code == 0 and res.stdout: + value = _parse_bracketed_setting(res.stdout) or res.stdout.strip() + readings[full_path] = value + else: + self._log_event( + category=EventCategory.OS, + description=f"Failed to read sysfs path: {full_path}", + data={"exit_code": res.exit_code}, + priority=EventPriority.WARNING, + console_log=True, + ) + + for path in directory_paths: + suffix = _path_under_sys(path) + if suffix is None: + self._log_event( + category=EventCategory.OS, + description=f"Skipping directory path not under /sys or invalid: {path!r}", + data={"path": path}, + priority=EventPriority.WARNING, + console_log=True, + ) + continue + full_path = _sysfs_full_path(suffix) + res = self._run_sut_cmd(self.CMD_LS.format(suffix), sudo=False) + if res.exit_code == 0: + readings[full_path] = res.stdout.strip() if res.stdout else "" + else: + self._log_event( + category=EventCategory.OS, + description=f"Failed to list sysfs path: {full_path}", + data={"exit_code": res.exit_code}, + priority=EventPriority.WARNING, + console_log=True, + ) + + if not readings: + self.result.message = "Sysfs settings not read" + self.result.status = ExecutionStatus.ERROR + return self.result, None + + sys_settings_data = SysSettingsDataModel(readings=readings) + self._log_event( + category=EventCategory.OS, + description="Sysfs settings collected", + data=sys_settings_data.model_dump(), + priority=EventPriority.INFO, + ) + self.result.message = f"Sysfs collected {len(readings)} path(s)" + self.result.status = ExecutionStatus.OK + return self.result, sys_settings_data diff --git a/nodescraper/plugins/inband/sys_settings/sys_settings_data.py b/nodescraper/plugins/inband/sys_settings/sys_settings_data.py index acec4b4f..eadc1216 100644 --- a/nodescraper/plugins/inband/sys_settings/sys_settings_data.py +++ b/nodescraper/plugins/inband/sys_settings/sys_settings_data.py @@ -1,38 +1,38 @@ -############################################################################### -# -# 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 DataModel - - -class SysSettingsDataModel(DataModel): - """Data model for sysfs settings: path -> parsed value. - - Values are parsed from user-specified sysfs paths (bracketed value extracted - when present, e.g. '[always] madvise never' -> 'always'). - """ - - readings: dict[str, str] = Field(default_factory=dict) # sysfs path (as given) -> parsed value +############################################################################### +# +# 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 DataModel + + +class SysSettingsDataModel(DataModel): + """Data model for sysfs settings: path -> parsed value. + + Values are parsed from user-specified sysfs paths (bracketed value extracted + when present, e.g. '[always] madvise never' -> 'always'). + """ + + readings: dict[str, str] = Field(default_factory=dict) # sysfs path (as given) -> parsed value diff --git a/nodescraper/plugins/inband/sys_settings/sys_settings_plugin.py b/nodescraper/plugins/inband/sys_settings/sys_settings_plugin.py index 158ac6ff..be81b10a 100644 --- a/nodescraper/plugins/inband/sys_settings/sys_settings_plugin.py +++ b/nodescraper/plugins/inband/sys_settings/sys_settings_plugin.py @@ -1,44 +1,44 @@ -############################################################################### -# -# MIT License -# -# Copyright (c) 2026 Advanced Micro Devices, Inc. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -############################################################################### -from nodescraper.base import InBandDataPlugin - -from .analyzer_args import SysSettingsAnalyzerArgs -from .collector_args import SysSettingsCollectorArgs -from .sys_settings_analyzer import SysSettingsAnalyzer -from .sys_settings_collector import SysSettingsCollector -from .sys_settings_data import SysSettingsDataModel - - -class SysSettingsPlugin( - InBandDataPlugin[SysSettingsDataModel, SysSettingsCollectorArgs, SysSettingsAnalyzerArgs] -): - """Plugin to collect and analyze sysfs settings from user-specified paths.""" - - DATA_MODEL = SysSettingsDataModel - COLLECTOR = SysSettingsCollector - ANALYZER = SysSettingsAnalyzer - COLLECTOR_ARGS = SysSettingsCollectorArgs - ANALYZER_ARGS = SysSettingsAnalyzerArgs +############################################################################### +# +# 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 SysSettingsAnalyzerArgs +from .collector_args import SysSettingsCollectorArgs +from .sys_settings_analyzer import SysSettingsAnalyzer +from .sys_settings_collector import SysSettingsCollector +from .sys_settings_data import SysSettingsDataModel + + +class SysSettingsPlugin( + InBandDataPlugin[SysSettingsDataModel, SysSettingsCollectorArgs, SysSettingsAnalyzerArgs] +): + """Plugin to collect and analyze sysfs settings from user-specified paths.""" + + DATA_MODEL = SysSettingsDataModel + COLLECTOR = SysSettingsCollector + ANALYZER = SysSettingsAnalyzer + COLLECTOR_ARGS = SysSettingsCollectorArgs + ANALYZER_ARGS = SysSettingsAnalyzerArgs diff --git a/nodescraper/plugins/ooband/__init__.py b/nodescraper/plugins/ooband/__init__.py index b75b2eb0..e83d846e 100644 --- a/nodescraper/plugins/ooband/__init__.py +++ b/nodescraper/plugins/ooband/__init__.py @@ -1,26 +1,26 @@ -############################################################################### -# -# 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. -# -############################################################################### -"""Out-of-band (OOB) plugins: Redfish and other BMC/remote management plugins.""" +############################################################################### +# +# 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. +# +############################################################################### +"""Out-of-band (OOB) plugins: Redfish and other BMC/remote management plugins.""" diff --git a/nodescraper/plugins/ooband/redfish_endpoint/__init__.py b/nodescraper/plugins/ooband/redfish_endpoint/__init__.py index 84293e72..1407be8c 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/__init__.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/__init__.py @@ -1,40 +1,40 @@ -############################################################################### -# -# 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 .analyzer_args import RedfishEndpointAnalyzerArgs -from .collector_args import RedfishEndpointCollectorArgs -from .endpoint_analyzer import RedfishEndpointAnalyzer -from .endpoint_collector import RedfishEndpointCollector -from .endpoint_data import RedfishEndpointDataModel -from .endpoint_plugin import RedfishEndpointPlugin - -__all__ = [ - "RedfishEndpointAnalyzer", - "RedfishEndpointAnalyzerArgs", - "RedfishEndpointCollector", - "RedfishEndpointCollectorArgs", - "RedfishEndpointDataModel", - "RedfishEndpointPlugin", -] +############################################################################### +# +# 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 .analyzer_args import RedfishEndpointAnalyzerArgs +from .collector_args import RedfishEndpointCollectorArgs +from .endpoint_analyzer import RedfishEndpointAnalyzer +from .endpoint_collector import RedfishEndpointCollector +from .endpoint_data import RedfishEndpointDataModel +from .endpoint_plugin import RedfishEndpointPlugin + +__all__ = [ + "RedfishEndpointAnalyzer", + "RedfishEndpointAnalyzerArgs", + "RedfishEndpointCollector", + "RedfishEndpointCollectorArgs", + "RedfishEndpointDataModel", + "RedfishEndpointPlugin", +] diff --git a/nodescraper/plugins/ooband/redfish_endpoint/analyzer_args.py b/nodescraper/plugins/ooband/redfish_endpoint/analyzer_args.py index 5bb5acf9..28f115e5 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/analyzer_args.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/analyzer_args.py @@ -1,67 +1,67 @@ -############################################################################### -# -# 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 enum import Enum -from typing import Any, Union - -from pydantic import Field - -from nodescraper.models import AnalyzerArgs - - -class ConstraintKey(str, Enum): - """Keys used in Redfish constraint dicts (e.g. in analyzer checks config). - - Naming aligns with JSON Schema combining: anyOf = value must match any of the list (OR). - oneOf in JSON Schema means exactly one (XOR); we use anyOf for \"value in allowed list\". - """ - - EQ = "eq" - MIN = "min" - MAX = "max" - ANY_OF = "anyOf" - - -RedfishConstraint = Union[int, float, str, bool, dict[str, Any]] - - -class RedfishEndpointAnalyzerArgs(AnalyzerArgs): - """Analyzer args for config-driven Redfish checks.""" - - checks: dict[str, dict[str, RedfishConstraint]] = Field( - default_factory=dict, - description=( - "Map: URI or `*` -> { property_path: constraint }. " - "URI keys must match a key in the collected responses (exact match). " - "Use `*` as the key to apply the inner constraints to every collected response body. " - "Property paths use '/' for nesting and indices, e.g. 'Status/Health', 'PowerControl/0/PowerConsumedWatts'. " - "Constraints: " - "'eq' — value must equal the given literal (int, float, str, bool). " - "'min' — value must be numeric and >= the given number. " - "'max' — value must be numeric and <= the given number. " - "'anyOf' — value must be in the given list (OR; any match passes). " - 'Example: { "/redfish/v1/Systems/1": { "Status/Health": { "anyOf": ["OK", "Warning"] }, "PowerState": "On" }, "*": { "Status/Health": { "anyOf": ["OK"] } } }.' - ), - ) +############################################################################### +# +# 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 enum import Enum +from typing import Any, Union + +from pydantic import Field + +from nodescraper.models import AnalyzerArgs + + +class ConstraintKey(str, Enum): + """Keys used in Redfish constraint dicts (e.g. in analyzer checks config). + + Naming aligns with JSON Schema combining: anyOf = value must match any of the list (OR). + oneOf in JSON Schema means exactly one (XOR); we use anyOf for \"value in allowed list\". + """ + + EQ = "eq" + MIN = "min" + MAX = "max" + ANY_OF = "anyOf" + + +RedfishConstraint = Union[int, float, str, bool, dict[str, Any]] + + +class RedfishEndpointAnalyzerArgs(AnalyzerArgs): + """Analyzer args for config-driven Redfish checks.""" + + checks: dict[str, dict[str, RedfishConstraint]] = Field( + default_factory=dict, + description=( + "Map: URI or `*` -> { property_path: constraint }. " + "URI keys must match a key in the collected responses (exact match). " + "Use `*` as the key to apply the inner constraints to every collected response body. " + "Property paths use '/' for nesting and indices, e.g. 'Status/Health', 'PowerControl/0/PowerConsumedWatts'. " + "Constraints: " + "'eq' — value must equal the given literal (int, float, str, bool). " + "'min' — value must be numeric and >= the given number. " + "'max' — value must be numeric and <= the given number. " + "'anyOf' — value must be in the given list (OR; any match passes). " + 'Example: { "/redfish/v1/Systems/1": { "Status/Health": { "anyOf": ["OK", "Warning"] }, "PowerState": "On" }, "*": { "Status/Health": { "anyOf": ["OK"] } } }.' + ), + ) diff --git a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py index 26412f94..3b783fe3 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py @@ -1,151 +1,151 @@ -############################################################################### -# -# 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 Any, Optional - -from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus -from nodescraper.interfaces import DataAnalyzer -from nodescraper.models import TaskResult - -from .analyzer_args import ConstraintKey, RedfishConstraint, RedfishEndpointAnalyzerArgs -from .endpoint_data import RedfishEndpointDataModel - - -def _get_by_path(obj: Any, path: str) -> Any: - """Get a value from a nested dict/list.""" - if not path.strip(): - return obj - current: Any = obj - for segment in path.strip().split("/"): - if not segment: - continue - if current is None: - return None - if isinstance(current, list): - try: - idx = int(segment) - current = current[idx] if 0 <= idx < len(current) else None - except ValueError: - return None - elif isinstance(current, dict): - current = current.get(segment) - else: - return None - return current - - -def _check_constraint(actual: Any, constraint: RedfishConstraint) -> tuple[bool, str]: - """Compare actual value to constraint.""" - if isinstance(constraint, dict): - if ConstraintKey.EQ in constraint: - ok = actual == constraint[ConstraintKey.EQ] - return ok, f"expected eq {constraint[ConstraintKey.EQ]}, got {actual!r}" - if ConstraintKey.MIN in constraint or ConstraintKey.MAX in constraint: - try: - val = float(actual) if actual is not None else None - if val is None: - return False, f"expected numeric, got {actual!r}" - if ConstraintKey.MIN in constraint and val < constraint[ConstraintKey.MIN]: - return False, f"value {val} below min {constraint[ConstraintKey.MIN]}" - if ConstraintKey.MAX in constraint and val > constraint[ConstraintKey.MAX]: - return False, f"value {val} above max {constraint[ConstraintKey.MAX]}" - return True, "" - except (TypeError, ValueError): - return False, f"expected numeric, got {actual!r}" - if ConstraintKey.ANY_OF in constraint: - allowed = constraint[ConstraintKey.ANY_OF] - if not isinstance(allowed, list): - return False, "anyOf must be a list" - ok = actual in allowed - return ok, f"expected any of {allowed}, got {actual!r}" - ok = actual == constraint - return ok, f"expected {constraint!r}, got {actual!r}" - - -class RedfishEndpointAnalyzer(DataAnalyzer[RedfishEndpointDataModel, RedfishEndpointAnalyzerArgs]): - """Checks Redfish endpoint responses against configured thresholds and key/value rules.""" - - DATA_MODEL = RedfishEndpointDataModel - - DOCUMENTATION_ANALYSIS_ITEMS: tuple[str, ...] = ( - "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.", - ) - - def analyze_data( - self, - data: RedfishEndpointDataModel, - args: Optional[RedfishEndpointAnalyzerArgs] = None, - ) -> TaskResult: - """Evaluate each configured check against the collected Redfish responses.""" - if not args or not args.checks: - self.result.status = ExecutionStatus.OK - self.result.message = "No checks configured" - return self.result - - failed: list[dict[str, Any]] = [] - for uri, path_constraints in args.checks.items(): - if uri == "*": - bodies = list(data.responses.values()) - else: - body = data.responses.get(uri) - bodies = [body] if body is not None else [] - if not bodies: - if uri != "*": - failed.append( - {"uri": uri, "path": None, "reason": "URI not in collected responses"} - ) - continue - for resp in bodies: - for path, constraint in path_constraints.items(): - actual = _get_by_path(resp, path) - ok, msg = _check_constraint(actual, constraint) - if not ok: - failed.append( - { - "uri": uri, - "path": path, - "expected": constraint, - "actual": actual, - "reason": msg, - } - ) - - if failed: - description = f"Redfish endpoint checks failed: {len(failed)} failure(s)" - self._log_event( - category=EventCategory.TELEMETRY, - description=description, - data={"failures": failed}, - priority=EventPriority.WARNING, - console_log=True, - ) - self.result.status = ExecutionStatus.ERROR - self.result.message = f"{len(failed)} check(s) failed" - else: - self.result.status = ExecutionStatus.OK - self.result.message = "All Redfish endpoint checks passed" - return self.result +############################################################################### +# +# 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 Any, Optional + +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.interfaces import DataAnalyzer +from nodescraper.models import TaskResult + +from .analyzer_args import ConstraintKey, RedfishConstraint, RedfishEndpointAnalyzerArgs +from .endpoint_data import RedfishEndpointDataModel + + +def _get_by_path(obj: Any, path: str) -> Any: + """Get a value from a nested dict/list.""" + if not path.strip(): + return obj + current: Any = obj + for segment in path.strip().split("/"): + if not segment: + continue + if current is None: + return None + if isinstance(current, list): + try: + idx = int(segment) + current = current[idx] if 0 <= idx < len(current) else None + except ValueError: + return None + elif isinstance(current, dict): + current = current.get(segment) + else: + return None + return current + + +def _check_constraint(actual: Any, constraint: RedfishConstraint) -> tuple[bool, str]: + """Compare actual value to constraint.""" + if isinstance(constraint, dict): + if ConstraintKey.EQ in constraint: + ok = actual == constraint[ConstraintKey.EQ] + return ok, f"expected eq {constraint[ConstraintKey.EQ]}, got {actual!r}" + if ConstraintKey.MIN in constraint or ConstraintKey.MAX in constraint: + try: + val = float(actual) if actual is not None else None + if val is None: + return False, f"expected numeric, got {actual!r}" + if ConstraintKey.MIN in constraint and val < constraint[ConstraintKey.MIN]: + return False, f"value {val} below min {constraint[ConstraintKey.MIN]}" + if ConstraintKey.MAX in constraint and val > constraint[ConstraintKey.MAX]: + return False, f"value {val} above max {constraint[ConstraintKey.MAX]}" + return True, "" + except (TypeError, ValueError): + return False, f"expected numeric, got {actual!r}" + if ConstraintKey.ANY_OF in constraint: + allowed = constraint[ConstraintKey.ANY_OF] + if not isinstance(allowed, list): + return False, "anyOf must be a list" + ok = actual in allowed + return ok, f"expected any of {allowed}, got {actual!r}" + ok = actual == constraint + return ok, f"expected {constraint!r}, got {actual!r}" + + +class RedfishEndpointAnalyzer(DataAnalyzer[RedfishEndpointDataModel, RedfishEndpointAnalyzerArgs]): + """Checks Redfish endpoint responses against configured thresholds and key/value rules.""" + + DATA_MODEL = RedfishEndpointDataModel + + DOCUMENTATION_ANALYSIS_ITEMS: tuple[str, ...] = ( + "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.", + ) + + def analyze_data( + self, + data: RedfishEndpointDataModel, + args: Optional[RedfishEndpointAnalyzerArgs] = None, + ) -> TaskResult: + """Evaluate each configured check against the collected Redfish responses.""" + if not args or not args.checks: + self.result.status = ExecutionStatus.OK + self.result.message = "No checks configured" + return self.result + + failed: list[dict[str, Any]] = [] + for uri, path_constraints in args.checks.items(): + if uri == "*": + bodies = list(data.responses.values()) + else: + body = data.responses.get(uri) + bodies = [body] if body is not None else [] + if not bodies: + if uri != "*": + failed.append( + {"uri": uri, "path": None, "reason": "URI not in collected responses"} + ) + continue + for resp in bodies: + for path, constraint in path_constraints.items(): + actual = _get_by_path(resp, path) + ok, msg = _check_constraint(actual, constraint) + if not ok: + failed.append( + { + "uri": uri, + "path": path, + "expected": constraint, + "actual": actual, + "reason": msg, + } + ) + + if failed: + description = f"Redfish endpoint checks failed: {len(failed)} failure(s)" + self._log_event( + category=EventCategory.TELEMETRY, + description=description, + data={"failures": failed}, + priority=EventPriority.WARNING, + console_log=True, + ) + self.result.status = ExecutionStatus.ERROR + self.result.message = f"{len(failed)} check(s) failed" + else: + self.result.status = ExecutionStatus.OK + self.result.message = "All Redfish endpoint checks passed" + return self.result diff --git a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py index 1c0f021f..b139396a 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py @@ -1,335 +1,335 @@ -############################################################################### -# -# 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 collections import deque -from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Any, Optional -from urllib.parse import urlparse - -from nodescraper.base import RedfishDataCollector -from nodescraper.connection.redfish import ( - RF_MEMBERS, - RF_ODATA_ID, - RedfishConnection, - RedfishGetResult, -) -from nodescraper.connection.redfish.redfish_clear_log import ( - ClearLogEndpoint, - ClearLogResult, - clear_redfish_logs, -) -from nodescraper.enums import ( - EventCategory, - EventPriority, - ExecutionStatus, - SystemInteractionLevel, -) -from nodescraper.models import TaskResult - -from .collector_args import RedfishEndpointCollectorArgs -from .endpoint_data import RedfishEndpointDataModel - - -def _normalize_path(odata_id: str, api_root: str) -> str: - """Convert @odata.id value (URL or path) to a normalized path under api_root.""" - if not odata_id or not isinstance(odata_id, str): - return "" - s = odata_id.strip() - if s.startswith(("http://", "https://")): - parsed = urlparse(s) - s = parsed.path or "/" - if not s.startswith("/"): - s = "/" + s - s = s.rstrip("/") or "/" - api_root_norm = api_root.strip("/") - if api_root_norm and not s.startswith("/" + api_root_norm): - return "" - return s - - -def _extract_odata_ids(obj: Any) -> list[str]: - """Recursively extract all @odata.id values from a Redfish JSON body.""" - out: list[str] = [] - if isinstance(obj, dict): - if RF_ODATA_ID in obj and isinstance(obj[RF_ODATA_ID], str): - out.append(obj[RF_ODATA_ID]) - for k, v in obj.items(): - if k == RF_MEMBERS and isinstance(v, list): - for item in v: - if ( - isinstance(item, dict) - and RF_ODATA_ID in item - and isinstance(item[RF_ODATA_ID], str) - ): - out.append(item[RF_ODATA_ID]) - elif isinstance(v, dict): - out.extend(_extract_odata_ids(v)) - elif isinstance(v, list): - for item in v: - if isinstance(item, dict): - out.extend(_extract_odata_ids(item)) - return out - - -def _discover_tree( - connection: RedfishConnection, - api_root: str, - max_depth: int, - max_endpoints: int, -) -> tuple[list[str], dict[str, dict], list[RedfishGetResult]]: - """ - Traverse the Redfish resource tree from the service root. - - max_depth matches collection_args.tree_max_depth: 1 = service root only; 2 = root + one link - level; child links are only enqueued when depth + 1 < max_depth (root is depth 0). - """ - root_path = _normalize_path(api_root, api_root) or ("/" + api_root.strip("/")) - seen: set[str] = set() - to_visit: deque[tuple[str, int]] = deque([(root_path, 0)]) - responses: dict[str, dict] = {} - results: list[RedfishGetResult] = [] - while to_visit: - if max_endpoints and len(seen) >= max_endpoints: - break - path, depth = to_visit.popleft() - if path in seen or depth > max_depth: - continue - seen.add(path) - res = connection.run_get(path) - results.append(res) - if res.success and res.data is not None: - responses[path] = res.data - for odata_id in _extract_odata_ids(res.data): - link_path = _normalize_path(odata_id, api_root) - # Follow only if the child depth stays strictly below max_depth (1 = root only). - if link_path and link_path not in seen and depth + 1 < max_depth: - to_visit.append((link_path, depth + 1)) - return sorted(seen), responses, results - - -def _uris_from_args(args: Optional[RedfishEndpointCollectorArgs]) -> list[str]: - """Return list of URIs from collector args.uris.""" - if args is None: - return [] - return list(args.uris) if args.uris else [] - - -def _discover_tree_enabled(args: Optional[RedfishEndpointCollectorArgs]) -> bool: - """True only when tree discovery is explicitly enabled (avoids string/other truthy junk).""" - if args is None: - return False - return getattr(args, "discover_tree", False) is True - - -def _fetch_one(connection_copy: RedfishConnection, path: str) -> RedfishGetResult: - """Run a single GET on a connection copy (used from worker threads).""" - return connection_copy.run_get(path) - - -def _fetch_one_paged( - connection_copy: RedfishConnection, path: str, max_pages: int -) -> RedfishGetResult: - """Run a paged GET on a connection copy, following Members@odata.nextLink (used from worker threads).""" - return connection_copy.run_get_paged(path, max_pages=max_pages) - - -class RedfishEndpointCollector( - RedfishDataCollector[RedfishEndpointDataModel, RedfishEndpointCollectorArgs] -): - """Collects Redfish endpoint responses for URIs specified in config.""" - - DATA_MODEL = RedfishEndpointDataModel - - DOCUMENTATION_COLLECTION_ITEMS: tuple[str, ...] = ( - "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).", - ) - - def collect_data( - self, args: Optional[RedfishEndpointCollectorArgs] = None - ) -> tuple[TaskResult, Optional[RedfishEndpointDataModel]]: - """Collect via tree discovery, or via explicit URIs, or skip if neither is configured.""" - uris = _uris_from_args(args) - use_tree = _discover_tree_enabled(args) - - # 1) Tree discovery: only when discover_tree is explicitly true - if use_tree: - api_root = getattr(self.connection, "api_root", "redfish/v1") - max_depth = getattr(args, "tree_max_depth", 2) if args else 2 - max_endpoints = (getattr(args, "tree_max_endpoints", 0) or 0) if args else 0 - _paths, responses, results = _discover_tree( - self.connection, - api_root, - max_depth=max_depth, - max_endpoints=max_endpoints, - ) - for res in results: - self._append_redfish_artifact(res) - if not res.success and res.error: - self._log_event( - category=EventCategory.RUNTIME, - description=f"Redfish GET failed during tree discovery for {res.path}: {res.error}", - priority=EventPriority.WARNING, - console_log=True, - ) - if not responses: - self.result.message = "No Redfish endpoints discovered from tree" - self.result.status = ExecutionStatus.ERROR - return self.result, None - data = RedfishEndpointDataModel(responses=responses) - self.result.message = f"Collected {len(responses)} Redfish endpoint(s) from tree" - self.result.status = ExecutionStatus.OK - self._apply_clear_log_uris(args) - return self.result, data - - # 2) URI list: when discover_tree is false/absent and uris are provided - if not uris: - self.result.message = ( - "No collection mode configured: set collection_args.discover_tree to true " - "or provide collection_args.uris" - ) - self.result.status = ExecutionStatus.NOT_RAN - return self.result, None - - paths = [] - for uri in uris: - path = uri.strip() if uri else "" - if not path: - continue - if not path.startswith("/"): - path = "/" + path - paths.append(path) - - max_workers = getattr(args, "max_workers", 1) if args else 1 - max_workers = min(max_workers, len(paths)) - follow_next_link = getattr(args, "follow_next_link", False) is True - max_pages = getattr(args, "max_pages", 200) if args else 200 - - if max_workers <= 1: - # Sequential - responses = {} - for path in paths: - if follow_next_link: - res = self._run_redfish_get_paged(path, max_pages=max_pages, log_artifact=True) - else: - res = self._run_redfish_get(path, log_artifact=True) - if res.success and res.data is not None: - responses[res.path] = res.data - else: - self._log_event( - category=EventCategory.RUNTIME, - description=f"Redfish GET failed for {path}: {res.error or 'unknown'}", - priority=EventPriority.WARNING, - console_log=True, - ) - else: - # Concurrent: one connection copy per worker, collect results in main thread - responses = {} - results = [] - with ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = {} - for path in paths: - conn = self.connection.copy() - if follow_next_link: - futures[executor.submit(_fetch_one_paged, conn, path, max_pages)] = path - else: - futures[executor.submit(_fetch_one, conn, path)] = path - for future in as_completed(futures): - path = futures[future] - try: - res = future.result() - results.append(res) - if res.success and res.data is not None: - responses[res.path] = res.data - else: - self._log_event( - category=EventCategory.RUNTIME, - description=f"Redfish GET failed for {path}: {res.error or 'unknown'}", - priority=EventPriority.WARNING, - console_log=True, - ) - except Exception as e: - self._log_event( - category=EventCategory.RUNTIME, - description=f"Redfish GET failed for {path}: {e!s}", - priority=EventPriority.WARNING, - console_log=True, - ) - for res in results: - self._append_redfish_artifact(res) - - if not responses: - self.result.message = "No Redfish endpoints could be read" - self.result.status = ExecutionStatus.ERROR - return self.result, None - - data = RedfishEndpointDataModel(responses=responses) - self.result.message = f"Collected {len(responses)} Redfish endpoint(s)" - self.result.status = ExecutionStatus.OK - self._apply_clear_log_uris(args) - return self.result, data - - def _apply_clear_log_uris(self, args: Optional[RedfishEndpointCollectorArgs]) -> None: - """POST {} to each URI in args.clear_log_uris (LogService.ClearLog endpoints). - - Skips when system_interaction_level < INTERACTIVE. Failures downgrade - the overall result to WARNING. - - Args: - args: Collector args carrying the clear_log_uris list. - """ - if args is None: - return - uris = [u.strip() for u in (args.clear_log_uris or []) if u and u.strip()] - if not uris: - return - if self.system_interaction_level < SystemInteractionLevel.INTERACTIVE: - self.logger.debug( - "Skipping log clear: system_interaction_level is %s (requires INTERACTIVE)", - self.system_interaction_level.name, - ) - return - - endpoints = [ClearLogEndpoint(path=uri) for uri in uris] - clear_results: list[ClearLogResult] = clear_redfish_logs( - self.connection, endpoints, self.logger - ) - cleared = [r for r in clear_results if r.success] - failed = [r for r in clear_results if not r.success] - if cleared: - self.result.message += f"; cleared {len(cleared)} log store(s)" - if failed: - descriptions = "; ".join(f"{r.path}: {r.error}" for r in failed) - self._log_event( - category=EventCategory.RUNTIME, - description=f"Failed to clear {len(failed)} log store(s): {descriptions}", - priority=EventPriority.WARNING, - console_log=True, - ) - if self.result.status == ExecutionStatus.OK: - self.result.status = ExecutionStatus.WARNING - self.result.message += f"; {len(failed)} log store(s) failed to clear" +############################################################################### +# +# 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 collections import deque +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any, Optional +from urllib.parse import urlparse + +from nodescraper.base import RedfishDataCollector +from nodescraper.connection.redfish import ( + RF_MEMBERS, + RF_ODATA_ID, + RedfishConnection, + RedfishGetResult, +) +from nodescraper.connection.redfish.redfish_clear_log import ( + ClearLogEndpoint, + ClearLogResult, + clear_redfish_logs, +) +from nodescraper.enums import ( + EventCategory, + EventPriority, + ExecutionStatus, + SystemInteractionLevel, +) +from nodescraper.models import TaskResult + +from .collector_args import RedfishEndpointCollectorArgs +from .endpoint_data import RedfishEndpointDataModel + + +def _normalize_path(odata_id: str, api_root: str) -> str: + """Convert @odata.id value (URL or path) to a normalized path under api_root.""" + if not odata_id or not isinstance(odata_id, str): + return "" + s = odata_id.strip() + if s.startswith(("http://", "https://")): + parsed = urlparse(s) + s = parsed.path or "/" + if not s.startswith("/"): + s = "/" + s + s = s.rstrip("/") or "/" + api_root_norm = api_root.strip("/") + if api_root_norm and not s.startswith("/" + api_root_norm): + return "" + return s + + +def _extract_odata_ids(obj: Any) -> list[str]: + """Recursively extract all @odata.id values from a Redfish JSON body.""" + out: list[str] = [] + if isinstance(obj, dict): + if RF_ODATA_ID in obj and isinstance(obj[RF_ODATA_ID], str): + out.append(obj[RF_ODATA_ID]) + for k, v in obj.items(): + if k == RF_MEMBERS and isinstance(v, list): + for item in v: + if ( + isinstance(item, dict) + and RF_ODATA_ID in item + and isinstance(item[RF_ODATA_ID], str) + ): + out.append(item[RF_ODATA_ID]) + elif isinstance(v, dict): + out.extend(_extract_odata_ids(v)) + elif isinstance(v, list): + for item in v: + if isinstance(item, dict): + out.extend(_extract_odata_ids(item)) + return out + + +def _discover_tree( + connection: RedfishConnection, + api_root: str, + max_depth: int, + max_endpoints: int, +) -> tuple[list[str], dict[str, dict], list[RedfishGetResult]]: + """ + Traverse the Redfish resource tree from the service root. + + max_depth matches collection_args.tree_max_depth: 1 = service root only; 2 = root + one link + level; child links are only enqueued when depth + 1 < max_depth (root is depth 0). + """ + root_path = _normalize_path(api_root, api_root) or ("/" + api_root.strip("/")) + seen: set[str] = set() + to_visit: deque[tuple[str, int]] = deque([(root_path, 0)]) + responses: dict[str, dict] = {} + results: list[RedfishGetResult] = [] + while to_visit: + if max_endpoints and len(seen) >= max_endpoints: + break + path, depth = to_visit.popleft() + if path in seen or depth > max_depth: + continue + seen.add(path) + res = connection.run_get(path) + results.append(res) + if res.success and res.data is not None: + responses[path] = res.data + for odata_id in _extract_odata_ids(res.data): + link_path = _normalize_path(odata_id, api_root) + # Follow only if the child depth stays strictly below max_depth (1 = root only). + if link_path and link_path not in seen and depth + 1 < max_depth: + to_visit.append((link_path, depth + 1)) + return sorted(seen), responses, results + + +def _uris_from_args(args: Optional[RedfishEndpointCollectorArgs]) -> list[str]: + """Return list of URIs from collector args.uris.""" + if args is None: + return [] + return list(args.uris) if args.uris else [] + + +def _discover_tree_enabled(args: Optional[RedfishEndpointCollectorArgs]) -> bool: + """True only when tree discovery is explicitly enabled (avoids string/other truthy junk).""" + if args is None: + return False + return getattr(args, "discover_tree", False) is True + + +def _fetch_one(connection_copy: RedfishConnection, path: str) -> RedfishGetResult: + """Run a single GET on a connection copy (used from worker threads).""" + return connection_copy.run_get(path) + + +def _fetch_one_paged( + connection_copy: RedfishConnection, path: str, max_pages: int +) -> RedfishGetResult: + """Run a paged GET on a connection copy, following Members@odata.nextLink (used from worker threads).""" + return connection_copy.run_get_paged(path, max_pages=max_pages) + + +class RedfishEndpointCollector( + RedfishDataCollector[RedfishEndpointDataModel, RedfishEndpointCollectorArgs] +): + """Collects Redfish endpoint responses for URIs specified in config.""" + + DATA_MODEL = RedfishEndpointDataModel + + DOCUMENTATION_COLLECTION_ITEMS: tuple[str, ...] = ( + "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).", + ) + + def collect_data( + self, args: Optional[RedfishEndpointCollectorArgs] = None + ) -> tuple[TaskResult, Optional[RedfishEndpointDataModel]]: + """Collect via tree discovery, or via explicit URIs, or skip if neither is configured.""" + uris = _uris_from_args(args) + use_tree = _discover_tree_enabled(args) + + # 1) Tree discovery: only when discover_tree is explicitly true + if use_tree: + api_root = getattr(self.connection, "api_root", "redfish/v1") + max_depth = getattr(args, "tree_max_depth", 2) if args else 2 + max_endpoints = (getattr(args, "tree_max_endpoints", 0) or 0) if args else 0 + _paths, responses, results = _discover_tree( + self.connection, + api_root, + max_depth=max_depth, + max_endpoints=max_endpoints, + ) + for res in results: + self._append_redfish_artifact(res) + if not res.success and res.error: + self._log_event( + category=EventCategory.RUNTIME, + description=f"Redfish GET failed during tree discovery for {res.path}: {res.error}", + priority=EventPriority.WARNING, + console_log=True, + ) + if not responses: + self.result.message = "No Redfish endpoints discovered from tree" + self.result.status = ExecutionStatus.ERROR + return self.result, None + data = RedfishEndpointDataModel(responses=responses) + self.result.message = f"Collected {len(responses)} Redfish endpoint(s) from tree" + self.result.status = ExecutionStatus.OK + self._apply_clear_log_uris(args) + return self.result, data + + # 2) URI list: when discover_tree is false/absent and uris are provided + if not uris: + self.result.message = ( + "No collection mode configured: set collection_args.discover_tree to true " + "or provide collection_args.uris" + ) + self.result.status = ExecutionStatus.NOT_RAN + return self.result, None + + paths = [] + for uri in uris: + path = uri.strip() if uri else "" + if not path: + continue + if not path.startswith("/"): + path = "/" + path + paths.append(path) + + max_workers = getattr(args, "max_workers", 1) if args else 1 + max_workers = min(max_workers, len(paths)) + follow_next_link = getattr(args, "follow_next_link", False) is True + max_pages = getattr(args, "max_pages", 200) if args else 200 + + if max_workers <= 1: + # Sequential + responses = {} + for path in paths: + if follow_next_link: + res = self._run_redfish_get_paged(path, max_pages=max_pages, log_artifact=True) + else: + res = self._run_redfish_get(path, log_artifact=True) + if res.success and res.data is not None: + responses[res.path] = res.data + else: + self._log_event( + category=EventCategory.RUNTIME, + description=f"Redfish GET failed for {path}: {res.error or 'unknown'}", + priority=EventPriority.WARNING, + console_log=True, + ) + else: + # Concurrent: one connection copy per worker, collect results in main thread + responses = {} + results = [] + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {} + for path in paths: + conn = self.connection.copy() + if follow_next_link: + futures[executor.submit(_fetch_one_paged, conn, path, max_pages)] = path + else: + futures[executor.submit(_fetch_one, conn, path)] = path + for future in as_completed(futures): + path = futures[future] + try: + res = future.result() + results.append(res) + if res.success and res.data is not None: + responses[res.path] = res.data + else: + self._log_event( + category=EventCategory.RUNTIME, + description=f"Redfish GET failed for {path}: {res.error or 'unknown'}", + priority=EventPriority.WARNING, + console_log=True, + ) + except Exception as e: + self._log_event( + category=EventCategory.RUNTIME, + description=f"Redfish GET failed for {path}: {e!s}", + priority=EventPriority.WARNING, + console_log=True, + ) + for res in results: + self._append_redfish_artifact(res) + + if not responses: + self.result.message = "No Redfish endpoints could be read" + self.result.status = ExecutionStatus.ERROR + return self.result, None + + data = RedfishEndpointDataModel(responses=responses) + self.result.message = f"Collected {len(responses)} Redfish endpoint(s)" + self.result.status = ExecutionStatus.OK + self._apply_clear_log_uris(args) + return self.result, data + + def _apply_clear_log_uris(self, args: Optional[RedfishEndpointCollectorArgs]) -> None: + """POST {} to each URI in args.clear_log_uris (LogService.ClearLog endpoints). + + Skips when system_interaction_level < INTERACTIVE. Failures downgrade + the overall result to WARNING. + + Args: + args: Collector args carrying the clear_log_uris list. + """ + if args is None: + return + uris = [u.strip() for u in (args.clear_log_uris or []) if u and u.strip()] + if not uris: + return + if self.system_interaction_level < SystemInteractionLevel.INTERACTIVE: + self.logger.debug( + "Skipping log clear: system_interaction_level is %s (requires INTERACTIVE)", + self.system_interaction_level.name, + ) + return + + endpoints = [ClearLogEndpoint(path=uri) for uri in uris] + clear_results: list[ClearLogResult] = clear_redfish_logs( + self.connection, endpoints, self.logger + ) + cleared = [r for r in clear_results if r.success] + failed = [r for r in clear_results if not r.success] + if cleared: + self.result.message += f"; cleared {len(cleared)} log store(s)" + if failed: + descriptions = "; ".join(f"{r.path}: {r.error}" for r in failed) + self._log_event( + category=EventCategory.RUNTIME, + description=f"Failed to clear {len(failed)} log store(s): {descriptions}", + priority=EventPriority.WARNING, + console_log=True, + ) + if self.result.status == ExecutionStatus.OK: + self.result.status = ExecutionStatus.WARNING + self.result.message += f"; {len(failed)} log store(s) failed to clear" diff --git a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_data.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_data.py index 19145485..3a2112a9 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_data.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_data.py @@ -1,34 +1,34 @@ -############################################################################### -# -# 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 DataModel - - -class RedfishEndpointDataModel(DataModel): - """Collected Redfish endpoint responses: URI -> JSON body.""" - - responses: dict[str, dict] = Field(default_factory=dict) +############################################################################### +# +# 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 DataModel + + +class RedfishEndpointDataModel(DataModel): + """Collected Redfish endpoint responses: URI -> JSON body.""" + + responses: dict[str, dict] = Field(default_factory=dict) diff --git a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_plugin.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_plugin.py index 029bcc88..8732888f 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_plugin.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_plugin.py @@ -1,48 +1,48 @@ -############################################################################### -# -# MIT License -# -# Copyright (c) 2026 Advanced Micro Devices, Inc. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -############################################################################### -from nodescraper.base import OOBandDataPlugin - -from .analyzer_args import RedfishEndpointAnalyzerArgs -from .collector_args import RedfishEndpointCollectorArgs -from .endpoint_analyzer import RedfishEndpointAnalyzer -from .endpoint_collector import RedfishEndpointCollector -from .endpoint_data import RedfishEndpointDataModel - - -class RedfishEndpointPlugin( - OOBandDataPlugin[ - RedfishEndpointDataModel, - RedfishEndpointCollectorArgs, - RedfishEndpointAnalyzerArgs, - ] -): - """Config-driven plugin: collect from Redfish URIs and check against thresholds/key-values.""" - - DATA_MODEL = RedfishEndpointDataModel - COLLECTOR = RedfishEndpointCollector - ANALYZER = RedfishEndpointAnalyzer - COLLECTOR_ARGS = RedfishEndpointCollectorArgs - ANALYZER_ARGS = RedfishEndpointAnalyzerArgs +############################################################################### +# +# 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 OOBandDataPlugin + +from .analyzer_args import RedfishEndpointAnalyzerArgs +from .collector_args import RedfishEndpointCollectorArgs +from .endpoint_analyzer import RedfishEndpointAnalyzer +from .endpoint_collector import RedfishEndpointCollector +from .endpoint_data import RedfishEndpointDataModel + + +class RedfishEndpointPlugin( + OOBandDataPlugin[ + RedfishEndpointDataModel, + RedfishEndpointCollectorArgs, + RedfishEndpointAnalyzerArgs, + ] +): + """Config-driven plugin: collect from Redfish URIs and check against thresholds/key-values.""" + + DATA_MODEL = RedfishEndpointDataModel + COLLECTOR = RedfishEndpointCollector + ANALYZER = RedfishEndpointAnalyzer + COLLECTOR_ARGS = RedfishEndpointCollectorArgs + ANALYZER_ARGS = RedfishEndpointAnalyzerArgs diff --git a/nodescraper/plugins/ooband/redfish_oem_diag/__init__.py b/nodescraper/plugins/ooband/redfish_oem_diag/__init__.py index 1a761c81..6ff220a9 100644 --- a/nodescraper/plugins/ooband/redfish_oem_diag/__init__.py +++ b/nodescraper/plugins/ooband/redfish_oem_diag/__init__.py @@ -1,22 +1,22 @@ -############################################################################### -# -# MIT License -# -# Copyright (c) 2026 Advanced Micro Devices, Inc. -# -############################################################################### -from .analyzer_args import RedfishOemDiagAnalyzerArgs -from .collector_args import RedfishOemDiagCollectorArgs -from .oem_diag_analyzer import RedfishOemDiagAnalyzer -from .oem_diag_collector import RedfishOemDiagCollector -from .oem_diag_data import RedfishOemDiagDataModel -from .oem_diag_plugin import RedfishOemDiagPlugin - -__all__ = [ - "RedfishOemDiagAnalyzer", - "RedfishOemDiagAnalyzerArgs", - "RedfishOemDiagCollector", - "RedfishOemDiagCollectorArgs", - "RedfishOemDiagDataModel", - "RedfishOemDiagPlugin", -] +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +############################################################################### +from .analyzer_args import RedfishOemDiagAnalyzerArgs +from .collector_args import RedfishOemDiagCollectorArgs +from .oem_diag_analyzer import RedfishOemDiagAnalyzer +from .oem_diag_collector import RedfishOemDiagCollector +from .oem_diag_data import RedfishOemDiagDataModel +from .oem_diag_plugin import RedfishOemDiagPlugin + +__all__ = [ + "RedfishOemDiagAnalyzer", + "RedfishOemDiagAnalyzerArgs", + "RedfishOemDiagCollector", + "RedfishOemDiagCollectorArgs", + "RedfishOemDiagDataModel", + "RedfishOemDiagPlugin", +] diff --git a/nodescraper/plugins/ooband/redfish_oem_diag/analyzer_args.py b/nodescraper/plugins/ooband/redfish_oem_diag/analyzer_args.py index 60759a94..9f965a33 100644 --- a/nodescraper/plugins/ooband/redfish_oem_diag/analyzer_args.py +++ b/nodescraper/plugins/ooband/redfish_oem_diag/analyzer_args.py @@ -1,37 +1,37 @@ -############################################################################### -# -# 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 AnalyzerArgs - - -class RedfishOemDiagAnalyzerArgs(AnalyzerArgs): - """Analyzer args for Redfish OEM diagnostic log results.""" - - require_all_success: bool = Field( - default=False, - description="If True, analysis fails when any OEM type collection failed.", - ) +############################################################################### +# +# 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 AnalyzerArgs + + +class RedfishOemDiagAnalyzerArgs(AnalyzerArgs): + """Analyzer args for Redfish OEM diagnostic log results.""" + + require_all_success: bool = Field( + default=False, + description="If True, analysis fails when any OEM type collection failed.", + ) diff --git a/nodescraper/plugins/ooband/redfish_oem_diag/collector_args.py b/nodescraper/plugins/ooband/redfish_oem_diag/collector_args.py index 627d5bff..13737e5e 100644 --- a/nodescraper/plugins/ooband/redfish_oem_diag/collector_args.py +++ b/nodescraper/plugins/ooband/redfish_oem_diag/collector_args.py @@ -1,72 +1,72 @@ -############################################################################### -# -# 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 __future__ import annotations - -from typing import Optional - -from pydantic import Field, model_validator - -from nodescraper.models import CollectorArgs - -DEFAULT_TASK_TIMEOUT_S = 1800 - - -class RedfishOemDiagCollectorArgs(CollectorArgs): - """Collector/analyzer args for Redfish OEM diagnostic log collection.""" - - log_service_path: str = Field( - default="redfish/v1/Systems/UBB/LogServices/DiagLogs", - description="Redfish path to the LogService (e.g. DiagLogs).", - ) - oem_diagnostic_types_allowable: Optional[list[str]] = Field( - default=None, - description="Allowable OEM diagnostic types for this architecture/BMC. When set, used for validation and as default for oem_diagnostic_types when empty.", - ) - oem_diagnostic_types: list[str] = Field( - default_factory=list, - description="OEM diagnostic types to collect. When empty and oem_diagnostic_types_allowable is set, defaults to that list.", - ) - task_timeout_s: int = Field( - default=DEFAULT_TASK_TIMEOUT_S, - ge=1, - le=3600, - description="Max seconds to wait for each BMC task.", - ) - clear_logs_after_collection: bool = Field( - default=False, - description=( - "If True, POST LogService.ClearLog to the configured log_service_path after a " - "successful collection. Requires system_interaction_level >= INTERACTIVE." - ), - ) - - @model_validator(mode="after") - def _default_oem_diagnostic_types(self) -> RedfishOemDiagCollectorArgs: - if not self.oem_diagnostic_types and self.oem_diagnostic_types_allowable: - return self.model_copy( - update={"oem_diagnostic_types": list(self.oem_diagnostic_types_allowable)} - ) - return self +############################################################################### +# +# 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 __future__ import annotations + +from typing import Optional + +from pydantic import Field, model_validator + +from nodescraper.models import CollectorArgs + +DEFAULT_TASK_TIMEOUT_S = 1800 + + +class RedfishOemDiagCollectorArgs(CollectorArgs): + """Collector/analyzer args for Redfish OEM diagnostic log collection.""" + + log_service_path: str = Field( + default="redfish/v1/Systems/UBB/LogServices/DiagLogs", + description="Redfish path to the LogService (e.g. DiagLogs).", + ) + oem_diagnostic_types_allowable: Optional[list[str]] = Field( + default=None, + description="Allowable OEM diagnostic types for this architecture/BMC. When set, used for validation and as default for oem_diagnostic_types when empty.", + ) + oem_diagnostic_types: list[str] = Field( + default_factory=list, + description="OEM diagnostic types to collect. When empty and oem_diagnostic_types_allowable is set, defaults to that list.", + ) + task_timeout_s: int = Field( + default=DEFAULT_TASK_TIMEOUT_S, + ge=1, + le=3600, + description="Max seconds to wait for each BMC task.", + ) + clear_logs_after_collection: bool = Field( + default=False, + description=( + "If True, POST LogService.ClearLog to the configured log_service_path after a " + "successful collection. Requires system_interaction_level >= INTERACTIVE." + ), + ) + + @model_validator(mode="after") + def _default_oem_diagnostic_types(self) -> RedfishOemDiagCollectorArgs: + if not self.oem_diagnostic_types and self.oem_diagnostic_types_allowable: + return self.model_copy( + update={"oem_diagnostic_types": list(self.oem_diagnostic_types_allowable)} + ) + return self diff --git a/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_analyzer.py b/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_analyzer.py index 11aaa1e8..62b8d33d 100644 --- a/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_analyzer.py +++ b/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_analyzer.py @@ -1,74 +1,74 @@ -############################################################################### -# -# 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 Optional - -from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus -from nodescraper.interfaces import DataAnalyzer -from nodescraper.models import TaskResult - -from .analyzer_args import RedfishOemDiagAnalyzerArgs -from .oem_diag_data import RedfishOemDiagDataModel - - -class RedfishOemDiagAnalyzer(DataAnalyzer[RedfishOemDiagDataModel, RedfishOemDiagAnalyzerArgs]): - """Analyzes Redfish OEM diagnostic log collection results.""" - - DATA_MODEL = RedfishOemDiagDataModel - - DOCUMENTATION_ANALYSIS_ITEMS: tuple[str, ...] = ( - "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.", - ) - - def analyze_data( - self, - data: RedfishOemDiagDataModel, - args: Optional[RedfishOemDiagAnalyzerArgs] = None, - ) -> TaskResult: - """Check collection results; optionally fail if any type failed.""" - if not data.results: - self.result.message = "No OEM diagnostic results to analyze" - self.result.status = ExecutionStatus.NOT_RAN - return self.result - - failed = [t for t, r in data.results.items() if not r.success] - success_count = len(data.results) - len(failed) - - if args and args.require_all_success and failed: - self._log_event( - category=EventCategory.RUNTIME, - description=f"OEM diag types failed: {failed}", - data={t: data.results[t].error for t in failed}, - priority=EventPriority.ERROR, - console_log=True, - ) - self.result.message = f"OEM diag: {len(failed)} type(s) failed: {failed}" - self.result.status = ExecutionStatus.ERROR - return self.result - - self.result.message = f"OEM diag: {success_count}/{len(data.results)} types collected" - self.result.status = ExecutionStatus.OK - return self.result +############################################################################### +# +# 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 Optional + +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.interfaces import DataAnalyzer +from nodescraper.models import TaskResult + +from .analyzer_args import RedfishOemDiagAnalyzerArgs +from .oem_diag_data import RedfishOemDiagDataModel + + +class RedfishOemDiagAnalyzer(DataAnalyzer[RedfishOemDiagDataModel, RedfishOemDiagAnalyzerArgs]): + """Analyzes Redfish OEM diagnostic log collection results.""" + + DATA_MODEL = RedfishOemDiagDataModel + + DOCUMENTATION_ANALYSIS_ITEMS: tuple[str, ...] = ( + "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.", + ) + + def analyze_data( + self, + data: RedfishOemDiagDataModel, + args: Optional[RedfishOemDiagAnalyzerArgs] = None, + ) -> TaskResult: + """Check collection results; optionally fail if any type failed.""" + if not data.results: + self.result.message = "No OEM diagnostic results to analyze" + self.result.status = ExecutionStatus.NOT_RAN + return self.result + + failed = [t for t, r in data.results.items() if not r.success] + success_count = len(data.results) - len(failed) + + if args and args.require_all_success and failed: + self._log_event( + category=EventCategory.RUNTIME, + description=f"OEM diag types failed: {failed}", + data={t: data.results[t].error for t in failed}, + priority=EventPriority.ERROR, + console_log=True, + ) + self.result.message = f"OEM diag: {len(failed)} type(s) failed: {failed}" + self.result.status = ExecutionStatus.ERROR + return self.result + + self.result.message = f"OEM diag: {success_count}/{len(data.results)} types collected" + self.result.status = ExecutionStatus.OK + return self.result diff --git a/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_collector.py b/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_collector.py index 7483cadf..3f9d5228 100644 --- a/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_collector.py +++ b/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_collector.py @@ -1,155 +1,155 @@ -############################################################################### -# -# MIT License -# -# Copyright (c) 2026 Advanced Micro Devices, Inc. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -############################################################################### -from pathlib import Path -from typing import Optional - -from nodescraper.base import RedfishDataCollector -from nodescraper.connection.redfish import collect_oem_diagnostic_data -from nodescraper.connection.redfish.redfish_clear_log import ( - _parse_clear_endpoint, - clear_redfish_logs, -) -from nodescraper.enums import ( - EventCategory, - EventPriority, - ExecutionStatus, - SystemInteractionLevel, -) -from nodescraper.models import TaskResult - -from .collector_args import RedfishOemDiagCollectorArgs -from .oem_diag_data import OemDiagTypeResult, RedfishOemDiagDataModel - - -class RedfishOemDiagCollector( - RedfishDataCollector[RedfishOemDiagDataModel, RedfishOemDiagCollectorArgs] -): - """Collects Redfish OEM diagnostic logs (e.g. JournalControl, AllLogs) via LogService.CollectDiagnosticData.""" - - DATA_MODEL = RedfishOemDiagDataModel - - DOCUMENTATION_COLLECTION_ITEMS: tuple[str, ...] = ( - "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.", - ) - - def collect_data( - self, args: Optional[RedfishOemDiagCollectorArgs] = None - ) -> tuple[TaskResult, Optional[RedfishOemDiagDataModel]]: - """Run OEM diagnostic collection for each type in args.oem_diagnostic_types.""" - if args is None: - args = RedfishOemDiagCollectorArgs() - types_to_collect = list(args.oem_diagnostic_types) if args.oem_diagnostic_types else [] - if not types_to_collect: - self.result.message = "No OEM diagnostic types configured" - self.result.status = ExecutionStatus.NOT_RAN - return self.result, None - - if self.log_path: - output_dir = (Path(self.log_path) / "diag_logs").resolve() - self.logger.info( - "(RedfishOemDiagPlugin) Diagnostic archives (e.g. *.tar.xz) will be written to: %s", - output_dir, - ) - else: - output_dir = None - - results: dict[str, OemDiagTypeResult] = {} - validate = bool(args.oem_diagnostic_types_allowable) - gathered = [] - for oem_type in types_to_collect: - log_bytes, metadata, err = collect_oem_diagnostic_data( - self.connection, - log_service_path=args.log_service_path, - oem_diagnostic_type=oem_type, - task_timeout_s=args.task_timeout_s, - output_dir=output_dir, - validate_type=validate, - allowed_types=args.oem_diagnostic_types_allowable, - logger=self.logger, - ) - gathered.append((oem_type, log_bytes, metadata, err)) - for oem_type, _log_bytes, metadata, err in gathered: - if err: - self._log_event( - category=EventCategory.RUNTIME, - description=f"OEM diag {oem_type!r}: error", - priority=EventPriority.WARNING, - data={"response_body": err}, - console_log=True, - ) - results[oem_type] = OemDiagTypeResult(success=False, error=err, metadata=None) - else: - results[oem_type] = OemDiagTypeResult(success=True, error=None, metadata=metadata) - - success_count = sum(1 for r in results.values() if r.success) - self.result.message = f"OEM diag: {success_count}/{len(results)} types collected" - self.result.status = ExecutionStatus.OK if success_count else ExecutionStatus.ERROR - - if ( - args.clear_logs_after_collection - and success_count > 0 - and self.system_interaction_level >= SystemInteractionLevel.INTERACTIVE - ): - self._clear_log_service(args.log_service_path) - elif args.clear_logs_after_collection and success_count == 0: - self.logger.debug("Skipping log clear: no OEM diag types collected successfully") - elif args.clear_logs_after_collection: - self.logger.debug( - "Skipping log clear: system_interaction_level is %s (requires INTERACTIVE)", - self.system_interaction_level.name, - ) - - return self.result, RedfishOemDiagDataModel(results=results) - - def _clear_log_service(self, log_service_path: str) -> None: - """POST LogService.ClearLog to the configured log service if it supports the action. - - Args: - log_service_path: Redfish path to the LogService. - """ - svc_res = self._run_redfish_get(log_service_path, log_artifact=False) - if not svc_res.success or not svc_res.data: - self.logger.debug("Skipping log clear: could not GET log service %s", log_service_path) - return - endpoint = _parse_clear_endpoint(svc_res.data) - if endpoint is None: - self.logger.debug("LogService %s does not advertise ClearLog action", log_service_path) - return - clear_results = clear_redfish_logs(self.connection, [endpoint], self.logger) - if clear_results and clear_results[0].success: - self.result.message += "; cleared log store" - elif clear_results and not clear_results[0].success: - self._log_event( - category=EventCategory.RUNTIME, - description=f"Failed to clear log store {endpoint.path}: {clear_results[0].error}", - priority=EventPriority.WARNING, - console_log=True, - ) - if self.result.status == ExecutionStatus.OK: - self.result.status = ExecutionStatus.WARNING - self.result.message += "; log clear failed" +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from pathlib import Path +from typing import Optional + +from nodescraper.base import RedfishDataCollector +from nodescraper.connection.redfish import collect_oem_diagnostic_data +from nodescraper.connection.redfish.redfish_clear_log import ( + _parse_clear_endpoint, + clear_redfish_logs, +) +from nodescraper.enums import ( + EventCategory, + EventPriority, + ExecutionStatus, + SystemInteractionLevel, +) +from nodescraper.models import TaskResult + +from .collector_args import RedfishOemDiagCollectorArgs +from .oem_diag_data import OemDiagTypeResult, RedfishOemDiagDataModel + + +class RedfishOemDiagCollector( + RedfishDataCollector[RedfishOemDiagDataModel, RedfishOemDiagCollectorArgs] +): + """Collects Redfish OEM diagnostic logs (e.g. JournalControl, AllLogs) via LogService.CollectDiagnosticData.""" + + DATA_MODEL = RedfishOemDiagDataModel + + DOCUMENTATION_COLLECTION_ITEMS: tuple[str, ...] = ( + "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.", + ) + + def collect_data( + self, args: Optional[RedfishOemDiagCollectorArgs] = None + ) -> tuple[TaskResult, Optional[RedfishOemDiagDataModel]]: + """Run OEM diagnostic collection for each type in args.oem_diagnostic_types.""" + if args is None: + args = RedfishOemDiagCollectorArgs() + types_to_collect = list(args.oem_diagnostic_types) if args.oem_diagnostic_types else [] + if not types_to_collect: + self.result.message = "No OEM diagnostic types configured" + self.result.status = ExecutionStatus.NOT_RAN + return self.result, None + + if self.log_path: + output_dir = (Path(self.log_path) / "diag_logs").resolve() + self.logger.info( + "(RedfishOemDiagPlugin) Diagnostic archives (e.g. *.tar.xz) will be written to: %s", + output_dir, + ) + else: + output_dir = None + + results: dict[str, OemDiagTypeResult] = {} + validate = bool(args.oem_diagnostic_types_allowable) + gathered = [] + for oem_type in types_to_collect: + log_bytes, metadata, err = collect_oem_diagnostic_data( + self.connection, + log_service_path=args.log_service_path, + oem_diagnostic_type=oem_type, + task_timeout_s=args.task_timeout_s, + output_dir=output_dir, + validate_type=validate, + allowed_types=args.oem_diagnostic_types_allowable, + logger=self.logger, + ) + gathered.append((oem_type, log_bytes, metadata, err)) + for oem_type, _log_bytes, metadata, err in gathered: + if err: + self._log_event( + category=EventCategory.RUNTIME, + description=f"OEM diag {oem_type!r}: error", + priority=EventPriority.WARNING, + data={"response_body": err}, + console_log=True, + ) + results[oem_type] = OemDiagTypeResult(success=False, error=err, metadata=None) + else: + results[oem_type] = OemDiagTypeResult(success=True, error=None, metadata=metadata) + + success_count = sum(1 for r in results.values() if r.success) + self.result.message = f"OEM diag: {success_count}/{len(results)} types collected" + self.result.status = ExecutionStatus.OK if success_count else ExecutionStatus.ERROR + + if ( + args.clear_logs_after_collection + and success_count > 0 + and self.system_interaction_level >= SystemInteractionLevel.INTERACTIVE + ): + self._clear_log_service(args.log_service_path) + elif args.clear_logs_after_collection and success_count == 0: + self.logger.debug("Skipping log clear: no OEM diag types collected successfully") + elif args.clear_logs_after_collection: + self.logger.debug( + "Skipping log clear: system_interaction_level is %s (requires INTERACTIVE)", + self.system_interaction_level.name, + ) + + return self.result, RedfishOemDiagDataModel(results=results) + + def _clear_log_service(self, log_service_path: str) -> None: + """POST LogService.ClearLog to the configured log service if it supports the action. + + Args: + log_service_path: Redfish path to the LogService. + """ + svc_res = self._run_redfish_get(log_service_path, log_artifact=False) + if not svc_res.success or not svc_res.data: + self.logger.debug("Skipping log clear: could not GET log service %s", log_service_path) + return + endpoint = _parse_clear_endpoint(svc_res.data) + if endpoint is None: + self.logger.debug("LogService %s does not advertise ClearLog action", log_service_path) + return + clear_results = clear_redfish_logs(self.connection, [endpoint], self.logger) + if clear_results and clear_results[0].success: + self.result.message += "; cleared log store" + elif clear_results and not clear_results[0].success: + self._log_event( + category=EventCategory.RUNTIME, + description=f"Failed to clear log store {endpoint.path}: {clear_results[0].error}", + priority=EventPriority.WARNING, + console_log=True, + ) + if self.result.status == ExecutionStatus.OK: + self.result.status = ExecutionStatus.WARNING + self.result.message += "; log clear failed" diff --git a/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_data.py b/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_data.py index db3f5b59..8e3affe1 100644 --- a/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_data.py +++ b/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_data.py @@ -1,44 +1,44 @@ -############################################################################### -# -# MIT License -# -# Copyright (c) 2026 Advanced Micro Devices, Inc. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -############################################################################### -from typing import Any, Optional - -from pydantic import Field - -from nodescraper.models import DataModel - - -class OemDiagTypeResult(DataModel): - """Result of collecting one OEM diagnostic log type (no raw bytes; JSON-serializable).""" - - success: bool = False - error: Optional[str] = None - metadata: Optional[dict[str, Any]] = None - - -class RedfishOemDiagDataModel(DataModel): - """Collected Redfish OEM diagnostic log results: OEM type -> result (success, error, metadata).""" - - results: dict[str, OemDiagTypeResult] = Field(default_factory=dict) +############################################################################### +# +# 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 Any, Optional + +from pydantic import Field + +from nodescraper.models import DataModel + + +class OemDiagTypeResult(DataModel): + """Result of collecting one OEM diagnostic log type (no raw bytes; JSON-serializable).""" + + success: bool = False + error: Optional[str] = None + metadata: Optional[dict[str, Any]] = None + + +class RedfishOemDiagDataModel(DataModel): + """Collected Redfish OEM diagnostic log results: OEM type -> result (success, error, metadata).""" + + results: dict[str, OemDiagTypeResult] = Field(default_factory=dict) diff --git a/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_plugin.py b/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_plugin.py index 6fd2ee5a..2bdf8797 100644 --- a/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_plugin.py +++ b/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_plugin.py @@ -1,52 +1,52 @@ -############################################################################### -# -# 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 OOBandDataPlugin - -from .analyzer_args import RedfishOemDiagAnalyzerArgs -from .collector_args import RedfishOemDiagCollectorArgs -from .oem_diag_analyzer import RedfishOemDiagAnalyzer -from .oem_diag_collector import RedfishOemDiagCollector -from .oem_diag_data import RedfishOemDiagDataModel - - -class RedfishOemDiagPlugin( - OOBandDataPlugin[ - RedfishOemDiagDataModel, - RedfishOemDiagCollectorArgs, - RedfishOemDiagAnalyzerArgs, - ] -): - """Collect Redfish OEM diagnostic logs (e.g. JournalControl, AllLogs, Dmesg) via LogService.CollectDiagnosticData. - - Uses RedfishConnectionManager. Configure log_service_path, oem_diagnostic_types (and optional output_dir) - in collection_args; use analysis_args.require_all_success to fail if any type fails. - """ - - DATA_MODEL = RedfishOemDiagDataModel - COLLECTOR = RedfishOemDiagCollector - ANALYZER = RedfishOemDiagAnalyzer - COLLECTOR_ARGS = RedfishOemDiagCollectorArgs - ANALYZER_ARGS = RedfishOemDiagAnalyzerArgs +############################################################################### +# +# 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 OOBandDataPlugin + +from .analyzer_args import RedfishOemDiagAnalyzerArgs +from .collector_args import RedfishOemDiagCollectorArgs +from .oem_diag_analyzer import RedfishOemDiagAnalyzer +from .oem_diag_collector import RedfishOemDiagCollector +from .oem_diag_data import RedfishOemDiagDataModel + + +class RedfishOemDiagPlugin( + OOBandDataPlugin[ + RedfishOemDiagDataModel, + RedfishOemDiagCollectorArgs, + RedfishOemDiagAnalyzerArgs, + ] +): + """Collect Redfish OEM diagnostic logs (e.g. JournalControl, AllLogs, Dmesg) via LogService.CollectDiagnosticData. + + Uses RedfishConnectionManager. Configure log_service_path, oem_diagnostic_types (and optional output_dir) + in collection_args; use analysis_args.require_all_success to fail if any type fails. + """ + + DATA_MODEL = RedfishOemDiagDataModel + COLLECTOR = RedfishOemDiagCollector + ANALYZER = RedfishOemDiagAnalyzer + COLLECTOR_ARGS = RedfishOemDiagCollectorArgs + ANALYZER_ARGS = RedfishOemDiagAnalyzerArgs diff --git a/nodescraper/plugins/serviceability/serviceability_api.py b/nodescraper/plugins/serviceability/serviceability_api.py index 2cbfbc29..30f1ea2d 100644 --- a/nodescraper/plugins/serviceability/serviceability_api.py +++ b/nodescraper/plugins/serviceability/serviceability_api.py @@ -1,165 +1,165 @@ -############################################################################### -# -# 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 __future__ import annotations - -from typing import Any - -from .se_models import ( - HubTriageResult, - PrioritizedServiceAction, - ServiceabilityBlock, - ServiceabilitySolution, -) - - -def service_action_identity(action: PrioritizedServiceAction) -> tuple[int, str, int]: - """Return the Hub row identity used to match top vs results entries.""" - return (action.afid, action.location, action.service_action_num) - - -def _hub_triage_identity(row: HubTriageResult) -> tuple[int, str, int]: - return (row.afid, row.location, row.service_action_num) - - -def _triage_row_to_action(rank: int, row: HubTriageResult) -> PrioritizedServiceAction: - return PrioritizedServiceAction( - rank=rank, - afid=row.afid, - location=row.location, - count=row.count, - service_action_num=row.service_action_num, - service_action_title=row.service_action_title, - service_action_category=row.service_action_category, - priority=row.priority, - sa_severity=row.sa_severity, - tier=row.tier, - tier_label=row.tier_label, - fru=row.fru, - fru_rank=row.fru_rank, - hub_sort_priority=row.hub_sort_priority, - multi_mask=row.multi_mask, - afid_summary=row.afid_summary, - service_action_steps=list(row.service_action_steps), - ) - - -def _solution_to_action(rank: int, solution: ServiceabilitySolution) -> PrioritizedServiceAction: - return PrioritizedServiceAction( - rank=rank, - afid=solution.afid, - location=solution.serviceable_unit[0] if solution.serviceable_unit else "", - count=1, - service_action_num=solution.service_action_num, - service_action_title=solution.service_action_title, - service_action_category=None, - priority=None, - sa_severity=None, - tier=None, - tier_label=solution.service_action_tier, - fru=None, - fru_rank=None, - hub_sort_priority=None, - multi_mask=None, - afid_summary=solution.afid_summary, - service_action_steps=[], - serviceable_units=list(solution.serviceable_unit), - ) - - -def build_top_service_actions(block: ServiceabilityBlock) -> list[PrioritizedServiceAction]: - """Return Hub triage.top service actions with ranks from triage.results order.""" - if block.hub_top_results: - rank_by_identity = { - _hub_triage_identity(row): rank - for rank, row in enumerate(block.hub_triage_results, start=1) - } - top_actions: list[PrioritizedServiceAction] = [] - for index, row in enumerate(block.hub_top_results, start=1): - rank = rank_by_identity.get(_hub_triage_identity(row), index) - top_actions.append(_triage_row_to_action(rank, row)) - return top_actions - if block.hub_triage_results: - return [_triage_row_to_action(1, block.hub_triage_results[0])] - if block.solution: - return [_solution_to_action(1, block.solution[0])] - return [] - - -def build_prioritized_service_actions( - block: ServiceabilityBlock, -) -> list[PrioritizedServiceAction]: - """Return hub-ranked service actions from triage.results order.""" - if block.hub_triage_results: - return [ - _triage_row_to_action(rank, row) - for rank, row in enumerate(block.hub_triage_results, start=1) - ] - return [ - _solution_to_action(rank, solution) for rank, solution in enumerate(block.solution, start=1) - ] - - -def split_recommendation_actions( - block: ServiceabilityBlock, -) -> tuple[list[PrioritizedServiceAction], list[PrioritizedServiceAction]]: - """Split Hub triage.top service actions from lower-priority triage.results rows.""" - prioritized = build_prioritized_service_actions(block) - top = build_top_service_actions(block) - if not prioritized: - return top, [] - if not top: - return [prioritized[0]], prioritized[1:] - - top_sort_priorities = { - action.hub_sort_priority for action in top if action.hub_sort_priority is not None - } - if top_sort_priorities: - additional = [ - action for action in prioritized if action.hub_sort_priority not in top_sort_priorities - ] - return top, additional - - top_identities = {service_action_identity(action) for action in top} - additional = [ - action for action in prioritized if service_action_identity(action) not in top_identities - ] - return top, additional - - -def prepare_serviceability_block_for_export( - block: ServiceabilityBlock, -) -> ServiceabilityBlock: - """Return a JSON-safe serviceability block without duplicate hub triage rows.""" - return block.model_copy(update={"hub_triage_results": [], "hub_top_results": []}) - - -def export_serviceability_json(block: ServiceabilityBlock) -> dict[str, Any]: - """Serialize serviceability.json with raw Hub output and no duplicate triage rows.""" - exported = prepare_serviceability_block_for_export(block) - return exported.model_dump( - mode="json", - exclude={"hub_triage_results", "hub_top_results"}, - ) +############################################################################### +# +# 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 __future__ import annotations + +from typing import Any + +from .se_models import ( + HubTriageResult, + PrioritizedServiceAction, + ServiceabilityBlock, + ServiceabilitySolution, +) + + +def service_action_identity(action: PrioritizedServiceAction) -> tuple[int, str, int]: + """Return the Hub row identity used to match top vs results entries.""" + return (action.afid, action.location, action.service_action_num) + + +def _hub_triage_identity(row: HubTriageResult) -> tuple[int, str, int]: + return (row.afid, row.location, row.service_action_num) + + +def _triage_row_to_action(rank: int, row: HubTriageResult) -> PrioritizedServiceAction: + return PrioritizedServiceAction( + rank=rank, + afid=row.afid, + location=row.location, + count=row.count, + service_action_num=row.service_action_num, + service_action_title=row.service_action_title, + service_action_category=row.service_action_category, + priority=row.priority, + sa_severity=row.sa_severity, + tier=row.tier, + tier_label=row.tier_label, + fru=row.fru, + fru_rank=row.fru_rank, + hub_sort_priority=row.hub_sort_priority, + multi_mask=row.multi_mask, + afid_summary=row.afid_summary, + service_action_steps=list(row.service_action_steps), + ) + + +def _solution_to_action(rank: int, solution: ServiceabilitySolution) -> PrioritizedServiceAction: + return PrioritizedServiceAction( + rank=rank, + afid=solution.afid, + location=solution.serviceable_unit[0] if solution.serviceable_unit else "", + count=1, + service_action_num=solution.service_action_num, + service_action_title=solution.service_action_title, + service_action_category=None, + priority=None, + sa_severity=None, + tier=None, + tier_label=solution.service_action_tier, + fru=None, + fru_rank=None, + hub_sort_priority=None, + multi_mask=None, + afid_summary=solution.afid_summary, + service_action_steps=[], + serviceable_units=list(solution.serviceable_unit), + ) + + +def build_top_service_actions(block: ServiceabilityBlock) -> list[PrioritizedServiceAction]: + """Return Hub triage.top service actions with ranks from triage.results order.""" + if block.hub_top_results: + rank_by_identity = { + _hub_triage_identity(row): rank + for rank, row in enumerate(block.hub_triage_results, start=1) + } + top_actions: list[PrioritizedServiceAction] = [] + for index, row in enumerate(block.hub_top_results, start=1): + rank = rank_by_identity.get(_hub_triage_identity(row), index) + top_actions.append(_triage_row_to_action(rank, row)) + return top_actions + if block.hub_triage_results: + return [_triage_row_to_action(1, block.hub_triage_results[0])] + if block.solution: + return [_solution_to_action(1, block.solution[0])] + return [] + + +def build_prioritized_service_actions( + block: ServiceabilityBlock, +) -> list[PrioritizedServiceAction]: + """Return hub-ranked service actions from triage.results order.""" + if block.hub_triage_results: + return [ + _triage_row_to_action(rank, row) + for rank, row in enumerate(block.hub_triage_results, start=1) + ] + return [ + _solution_to_action(rank, solution) for rank, solution in enumerate(block.solution, start=1) + ] + + +def split_recommendation_actions( + block: ServiceabilityBlock, +) -> tuple[list[PrioritizedServiceAction], list[PrioritizedServiceAction]]: + """Split Hub triage.top service actions from lower-priority triage.results rows.""" + prioritized = build_prioritized_service_actions(block) + top = build_top_service_actions(block) + if not prioritized: + return top, [] + if not top: + return [prioritized[0]], prioritized[1:] + + top_sort_priorities = { + action.hub_sort_priority for action in top if action.hub_sort_priority is not None + } + if top_sort_priorities: + additional = [ + action for action in prioritized if action.hub_sort_priority not in top_sort_priorities + ] + return top, additional + + top_identities = {service_action_identity(action) for action in top} + additional = [ + action for action in prioritized if service_action_identity(action) not in top_identities + ] + return top, additional + + +def prepare_serviceability_block_for_export( + block: ServiceabilityBlock, +) -> ServiceabilityBlock: + """Return a JSON-safe serviceability block without duplicate hub triage rows.""" + return block.model_copy(update={"hub_triage_results": [], "hub_top_results": []}) + + +def export_serviceability_json(block: ServiceabilityBlock) -> dict[str, Any]: + """Serialize serviceability.json with raw Hub output and no duplicate triage rows.""" + exported = prepare_serviceability_block_for_export(block) + return exported.model_dump( + mode="json", + exclude={"hub_triage_results", "hub_top_results"}, + ) diff --git a/nodescraper/plugins/serviceability/serviceability_recommendations_table.py b/nodescraper/plugins/serviceability/serviceability_recommendations_table.py index 528d95cc..2721e764 100644 --- a/nodescraper/plugins/serviceability/serviceability_recommendations_table.py +++ b/nodescraper/plugins/serviceability/serviceability_recommendations_table.py @@ -1,287 +1,287 @@ -############################################################################### -# -# 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 __future__ import annotations - -import sys -from textwrap import wrap -from typing import Any, Optional - -from nodescraper.models import PluginResult - -from .se_models import PrioritizedServiceAction, ServiceabilityBlock -from .serviceability_api import split_recommendation_actions - -RECOMMENDATION_TABLE_HEADERS: tuple[str, ...] = ( - "Rank", - "Priority", - "SA Sev", - "Tier", - "Units", - "Service Action", - "Steps", -) - -PRIMARY_RECOMMENDATION_TITLE = "Top Hub service action" -ADDITIONAL_RECOMMENDATIONS_TITLE = "Additional Hub service actions" -ADDITIONAL_RECOMMENDATIONS_NOTE = ( - "Lower-priority follow-on actions if the top service action does not resolve the issue." -) - -RECOMMENDATION_TABLE_MAX_WIDTHS: dict[str, int] = { - "Rank": 4, - "Priority": 8, - "SA Sev": 6, - "Tier": 10, - "Units": 28, - "Service Action": 40, - "Steps": 96, -} - - -def _recommendation_section_title( - title: str, - actions: list[PrioritizedServiceAction], -) -> str: - if not actions: - return title - ranks = [action.rank for action in actions if action.rank] - if len(actions) == 1 and ranks: - return f"{title} (rank {ranks[0]})" - if ranks: - return ( - f"{title} (ranks {min(ranks)}-{max(ranks)}, " - f"{len(actions)} item{'s' if len(actions) != 1 else ''})" - ) - return f"{title} ({len(actions)} item{'s' if len(actions) != 1 else ''})" - - -def _short_serviceable_unit(location: str) -> str: - text = str(location).strip().rstrip("/") - if not text: - return "" - return text.rsplit("/", 1)[-1] - - -def _recommendation_units_cell(action: PrioritizedServiceAction) -> str: - units = list(action.serviceable_units or []) - if not units and action.location: - units = [action.location] - return ", ".join(_short_serviceable_unit(unit) for unit in units if unit) - - -def _recommendation_service_action_cell(action: PrioritizedServiceAction) -> str: - title = (action.service_action_title or "").strip() - if title: - return f"{action.afid}: {title}" - return f"{action.afid}: SA {action.service_action_num}" - - -def _recommendation_steps_cell(action: PrioritizedServiceAction) -> str: - steps = [str(step).strip() for step in action.service_action_steps if str(step).strip()] - if not steps: - return "" - return "; ".join(f"{index}: {step}" for index, step in enumerate(steps)) - - -def _recommendation_table_row(action: PrioritizedServiceAction) -> list[str]: - priority = "" if action.priority is None else str(action.priority) - sa_severity = "" if action.sa_severity is None else str(action.sa_severity) - tier = (action.tier_label or "").strip() - return [ - str(action.rank), - priority, - sa_severity, - tier, - _recommendation_units_cell(action), - _recommendation_service_action_cell(action), - _recommendation_steps_cell(action), - ] - - -def render_recommendations_table( - actions: list[PrioritizedServiceAction], - *, - headers: tuple[str, ...] = RECOMMENDATION_TABLE_HEADERS, - max_widths: Optional[dict[str, int]] = None, -) -> str: - """Render prioritized Hub recommendations as a bordered ASCII table.""" - if max_widths is None: - max_widths = dict(RECOMMENDATION_TABLE_MAX_WIDTHS) - rows = [_recommendation_table_row(action) for action in actions] - return _gen_str_table(list(headers), rows, max_widths=max_widths) - - -def render_recommendations_section( - title: str, - actions: list[PrioritizedServiceAction], - *, - note: Optional[str] = None, -) -> str: - """Render a titled recommendations block with an optional explanatory note.""" - if not actions: - return "" - lines = [title] - if note: - lines.append(note) - lines.append(render_recommendations_table(actions)) - return "\n".join(lines) - - -def _serviceability_block_from_plugin_result( - plugin_result: PluginResult, -) -> Optional[ServiceabilityBlock]: - """Return a ServiceabilityBlock when a plugin result includes hub recommendation data.""" - result_data = plugin_result.result_data - if result_data is None: - return None - - system_data: Any - if isinstance(result_data, dict): - system_data = result_data.get("system_data") - else: - system_data = getattr(result_data, "system_data", None) - if system_data is None: - return None - - serviceability: Any - if isinstance(system_data, dict): - serviceability = system_data.get("serviceability") - else: - serviceability = getattr(system_data, "serviceability", None) - if serviceability is None: - return None - - if isinstance(serviceability, dict): - serviceability = ServiceabilityBlock.model_validate(serviceability) - if not (serviceability.hub_triage_results or serviceability.hub_top_results): - return None - return serviceability - - -def render_serviceability_recommendation_tables_for_plugin_results( - plugin_results: list[PluginResult], -) -> str: - """Render Hub recommendation tables for all plugins that produced serviceability output.""" - sections: list[str] = [] - for plugin_result in plugin_results: - block = _serviceability_block_from_plugin_result(plugin_result) - if block is None: - continue - rendered = render_serviceability_recommendation_tables(block) - if rendered: - sections.append(rendered) - return "\n\n".join(sections) - - -def render_serviceability_recommendation_tables(block: ServiceabilityBlock) -> str: - """Render primary and additional Hub recommendation tables.""" - primary, additional = split_recommendation_actions(block) - sections: list[str] = [] - primary_section = render_recommendations_section( - _recommendation_section_title(PRIMARY_RECOMMENDATION_TITLE, primary), - primary, - ) - if primary_section: - sections.append(primary_section) - additional_section = render_recommendations_section( - _recommendation_section_title(ADDITIONAL_RECOMMENDATIONS_TITLE, additional), - additional, - note=ADDITIONAL_RECOMMENDATIONS_NOTE, - ) - if additional_section: - sections.append(additional_section) - if not sections: - return "" - return "\n\n".join(sections) - - -def emit_serviceability_recommendation_tables(block: ServiceabilityBlock) -> None: - """Print Hub recommendation tables to stdout.""" - output = render_serviceability_recommendation_tables(block) - if not output: - return - sys.stdout.write(f"\n{output}\n") - - -def _gen_str_table( - headers: list[str], - rows: list[list[str]], - max_widths: Optional[dict[str, int]] = None, -) -> str: - max_widths = max_widths or {} - norm_rows: list[list[str]] = [[str(cell) for cell in row] for row in rows] - ncols = len(headers) - - raw_widths: list[int] = [len(header) for header in headers] - for norm_row in norm_rows: - for index, cell in enumerate(norm_row): - for part in cell.splitlines() or [""]: - if len(part) > raw_widths[index]: - raw_widths[index] = len(part) - - target_widths: list[int] = [] - for index, header in enumerate(headers): - cap = max_widths.get(header) - if cap is None: - target_widths.append(raw_widths[index]) - else: - target_widths.append(max(len(header), min(raw_widths[index], cap))) - - wrapped_rows: list[list[list[str]]] = [] - for norm_row in norm_rows: - wrapped_cells: list[list[str]] = [] - for index, cell in enumerate(norm_row): - cell_lines: list[str] = [] - for paragraph in cell.splitlines() or [""]: - cell_lines.extend(wrap(paragraph, width=target_widths[index]) or [""]) - wrapped_cells.append(cell_lines) - wrapped_rows.append(wrapped_cells) - - col_widths: list[int] = [] - for index in range(ncols): - widest_line = len(headers[index]) - for wrapped_row in wrapped_rows: - for line in wrapped_row[index]: - if len(line) > widest_line: - widest_line = len(line) - col_widths.append(widest_line) - - border = "+" + "+".join("-" * (width + 2) for width in col_widths) + "+" - - def render_physical_row(parts: list[str]) -> str: - return "| " + " | ".join(part.ljust(width) for part, width in zip(parts, col_widths)) + " |" - - table_lines = [border, render_physical_row(headers), border] - for wrapped_row in wrapped_rows: - height = max(len(cell_lines) for cell_lines in wrapped_row) - for line_index in range(height): - parts = [ - wrapped_row[column][line_index] if line_index < len(wrapped_row[column]) else "" - for column in range(ncols) - ] - table_lines.append(render_physical_row(parts)) - table_lines.append(border) - return "\n".join(table_lines) +############################################################################### +# +# 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 __future__ import annotations + +import sys +from textwrap import wrap +from typing import Any, Optional + +from nodescraper.models import PluginResult + +from .se_models import PrioritizedServiceAction, ServiceabilityBlock +from .serviceability_api import split_recommendation_actions + +RECOMMENDATION_TABLE_HEADERS: tuple[str, ...] = ( + "Rank", + "Priority", + "SA Sev", + "Tier", + "Units", + "Service Action", + "Steps", +) + +PRIMARY_RECOMMENDATION_TITLE = "Top Hub service action" +ADDITIONAL_RECOMMENDATIONS_TITLE = "Additional Hub service actions" +ADDITIONAL_RECOMMENDATIONS_NOTE = ( + "Lower-priority follow-on actions if the top service action does not resolve the issue." +) + +RECOMMENDATION_TABLE_MAX_WIDTHS: dict[str, int] = { + "Rank": 4, + "Priority": 8, + "SA Sev": 6, + "Tier": 10, + "Units": 28, + "Service Action": 40, + "Steps": 96, +} + + +def _recommendation_section_title( + title: str, + actions: list[PrioritizedServiceAction], +) -> str: + if not actions: + return title + ranks = [action.rank for action in actions if action.rank] + if len(actions) == 1 and ranks: + return f"{title} (rank {ranks[0]})" + if ranks: + return ( + f"{title} (ranks {min(ranks)}-{max(ranks)}, " + f"{len(actions)} item{'s' if len(actions) != 1 else ''})" + ) + return f"{title} ({len(actions)} item{'s' if len(actions) != 1 else ''})" + + +def _short_serviceable_unit(location: str) -> str: + text = str(location).strip().rstrip("/") + if not text: + return "" + return text.rsplit("/", 1)[-1] + + +def _recommendation_units_cell(action: PrioritizedServiceAction) -> str: + units = list(action.serviceable_units or []) + if not units and action.location: + units = [action.location] + return ", ".join(_short_serviceable_unit(unit) for unit in units if unit) + + +def _recommendation_service_action_cell(action: PrioritizedServiceAction) -> str: + title = (action.service_action_title or "").strip() + if title: + return f"{action.afid}: {title}" + return f"{action.afid}: SA {action.service_action_num}" + + +def _recommendation_steps_cell(action: PrioritizedServiceAction) -> str: + steps = [str(step).strip() for step in action.service_action_steps if str(step).strip()] + if not steps: + return "" + return "; ".join(f"{index}: {step}" for index, step in enumerate(steps)) + + +def _recommendation_table_row(action: PrioritizedServiceAction) -> list[str]: + priority = "" if action.priority is None else str(action.priority) + sa_severity = "" if action.sa_severity is None else str(action.sa_severity) + tier = (action.tier_label or "").strip() + return [ + str(action.rank), + priority, + sa_severity, + tier, + _recommendation_units_cell(action), + _recommendation_service_action_cell(action), + _recommendation_steps_cell(action), + ] + + +def render_recommendations_table( + actions: list[PrioritizedServiceAction], + *, + headers: tuple[str, ...] = RECOMMENDATION_TABLE_HEADERS, + max_widths: Optional[dict[str, int]] = None, +) -> str: + """Render prioritized Hub recommendations as a bordered ASCII table.""" + if max_widths is None: + max_widths = dict(RECOMMENDATION_TABLE_MAX_WIDTHS) + rows = [_recommendation_table_row(action) for action in actions] + return _gen_str_table(list(headers), rows, max_widths=max_widths) + + +def render_recommendations_section( + title: str, + actions: list[PrioritizedServiceAction], + *, + note: Optional[str] = None, +) -> str: + """Render a titled recommendations block with an optional explanatory note.""" + if not actions: + return "" + lines = [title] + if note: + lines.append(note) + lines.append(render_recommendations_table(actions)) + return "\n".join(lines) + + +def _serviceability_block_from_plugin_result( + plugin_result: PluginResult, +) -> Optional[ServiceabilityBlock]: + """Return a ServiceabilityBlock when a plugin result includes hub recommendation data.""" + result_data = plugin_result.result_data + if result_data is None: + return None + + system_data: Any + if isinstance(result_data, dict): + system_data = result_data.get("system_data") + else: + system_data = getattr(result_data, "system_data", None) + if system_data is None: + return None + + serviceability: Any + if isinstance(system_data, dict): + serviceability = system_data.get("serviceability") + else: + serviceability = getattr(system_data, "serviceability", None) + if serviceability is None: + return None + + if isinstance(serviceability, dict): + serviceability = ServiceabilityBlock.model_validate(serviceability) + if not (serviceability.hub_triage_results or serviceability.hub_top_results): + return None + return serviceability + + +def render_serviceability_recommendation_tables_for_plugin_results( + plugin_results: list[PluginResult], +) -> str: + """Render Hub recommendation tables for all plugins that produced serviceability output.""" + sections: list[str] = [] + for plugin_result in plugin_results: + block = _serviceability_block_from_plugin_result(plugin_result) + if block is None: + continue + rendered = render_serviceability_recommendation_tables(block) + if rendered: + sections.append(rendered) + return "\n\n".join(sections) + + +def render_serviceability_recommendation_tables(block: ServiceabilityBlock) -> str: + """Render primary and additional Hub recommendation tables.""" + primary, additional = split_recommendation_actions(block) + sections: list[str] = [] + primary_section = render_recommendations_section( + _recommendation_section_title(PRIMARY_RECOMMENDATION_TITLE, primary), + primary, + ) + if primary_section: + sections.append(primary_section) + additional_section = render_recommendations_section( + _recommendation_section_title(ADDITIONAL_RECOMMENDATIONS_TITLE, additional), + additional, + note=ADDITIONAL_RECOMMENDATIONS_NOTE, + ) + if additional_section: + sections.append(additional_section) + if not sections: + return "" + return "\n\n".join(sections) + + +def emit_serviceability_recommendation_tables(block: ServiceabilityBlock) -> None: + """Print Hub recommendation tables to stdout.""" + output = render_serviceability_recommendation_tables(block) + if not output: + return + sys.stdout.write(f"\n{output}\n") + + +def _gen_str_table( + headers: list[str], + rows: list[list[str]], + max_widths: Optional[dict[str, int]] = None, +) -> str: + max_widths = max_widths or {} + norm_rows: list[list[str]] = [[str(cell) for cell in row] for row in rows] + ncols = len(headers) + + raw_widths: list[int] = [len(header) for header in headers] + for norm_row in norm_rows: + for index, cell in enumerate(norm_row): + for part in cell.splitlines() or [""]: + if len(part) > raw_widths[index]: + raw_widths[index] = len(part) + + target_widths: list[int] = [] + for index, header in enumerate(headers): + cap = max_widths.get(header) + if cap is None: + target_widths.append(raw_widths[index]) + else: + target_widths.append(max(len(header), min(raw_widths[index], cap))) + + wrapped_rows: list[list[list[str]]] = [] + for norm_row in norm_rows: + wrapped_cells: list[list[str]] = [] + for index, cell in enumerate(norm_row): + cell_lines: list[str] = [] + for paragraph in cell.splitlines() or [""]: + cell_lines.extend(wrap(paragraph, width=target_widths[index]) or [""]) + wrapped_cells.append(cell_lines) + wrapped_rows.append(wrapped_cells) + + col_widths: list[int] = [] + for index in range(ncols): + widest_line = len(headers[index]) + for wrapped_row in wrapped_rows: + for line in wrapped_row[index]: + if len(line) > widest_line: + widest_line = len(line) + col_widths.append(widest_line) + + border = "+" + "+".join("-" * (width + 2) for width in col_widths) + "+" + + def render_physical_row(parts: list[str]) -> str: + return "| " + " | ".join(part.ljust(width) for part, width in zip(parts, col_widths)) + " |" + + table_lines = [border, render_physical_row(headers), border] + for wrapped_row in wrapped_rows: + height = max(len(cell_lines) for cell_lines in wrapped_row) + for line_index in range(height): + parts = [ + wrapped_row[column][line_index] if line_index < len(wrapped_row[column]) else "" + for column in range(ncols) + ] + table_lines.append(render_physical_row(parts)) + table_lines.append(border) + return "\n".join(table_lines) diff --git a/test/functional/fixtures/dmesg_sample.log b/test/functional/fixtures/dmesg_sample.log index e0844e42..98518d45 100644 --- a/test/functional/fixtures/dmesg_sample.log +++ b/test/functional/fixtures/dmesg_sample.log @@ -1,71 +1,71 @@ -kern :info : 2026-01-07T10:00:00,123456-06:00 Linux version 5.15.0-91-generic (buildd@amd64-builder) (gcc version 11.4.0) #101-Ubuntu SMP -kern :info : 2026-01-07T10:00:00,234567-06:00 Command line: BOOT_IMAGE=/boot/vmlinuz-5.15.0-91-generic root=UUID=a1b2c3d4 ro quiet splash vt.handoff=7 -kern :info : 2026-01-07T10:00:01,345678-06:00 KERNEL supported cpus: -kern :info : 2026-01-07T10:00:01,456789-06:00 Intel GenuineIntel -kern :info : 2026-01-07T10:00:01,567890-06:00 AMD AuthenticAMD -kern :info : 2026-01-07T10:00:02,678901-06:00 x86/fpu: Supporting XSAVE feature 0x001: 'x87 floating point registers' -kern :info : 2026-01-07T10:00:02,789012-06:00 x86/fpu: Supporting XSAVE feature 0x002: 'SSE registers' -kern :info : 2026-01-07T10:00:03,890123-06:00 Memory: 32823616K/33554432K available -kern :warn : 2026-01-07T10:00:05,123456-06:00 pci 0000:00:01.0: BAR 0: failed to assign [mem size 0x01000000] -kern :info : 2026-01-07T10:00:06,234567-06:00 PCI: Using ACPI for IRQ routing -kern :info : 2026-01-07T10:00:07,345678-06:00 NetLabel: Initializing -kern :info : 2026-01-07T10:00:08,456789-06:00 DMA: preallocated 4096 KiB GFP_KERNEL pool for atomic allocations -kern :err : 2026-01-07T10:00:10,567890-06:00 WARNING: CPU: 0 PID: 1 at drivers/gpu/drm/amd/amdgpu/amdgpu_device.c:123 amdgpu_device_init+0x456/0x789 -kern :info : 2026-01-07T10:00:11,678901-06:00 Modules linked in: amdgpu drm_ttm_helper ttm drm_kms_helper -kern :info : 2026-01-07T10:00:12,789012-06:00 CPU: 0 PID: 1 Comm: swapper/0 Not tainted 5.15.0-91-generic #101-Ubuntu -kern :err : 2026-01-07T10:00:15,890123-06:00 AMD-Vi: Event logged [IO_PAGE_FAULT device=00:14.0 domain=0x0000 address=0xfffffffffffffef0 flags=0x0010] -kern :info : 2026-01-07T10:00:16,123456-06:00 SCSI subsystem initialized -kern :info : 2026-01-07T10:00:17,234567-06:00 libata version 3.00 loaded -kern :info : 2026-01-07T10:00:18,345678-06:00 ACPI: Added _OSI(Module Device) -kern :info : 2026-01-07T10:00:19,456789-06:00 ACPI: Added _OSI(Processor Device) -kern :err : 2026-01-07T10:00:20,567890-06:00 ACPI Error: Method parse/execution failed \_SB.PCI0.GPP0.SWUS.SWDS.VGA.LCD._BCM, AE_NOT_FOUND -kern :info : 2026-01-07T10:00:22,678901-06:00 [drm] amdgpu kernel modesetting enabled -kern :info : 2026-01-07T10:00:23,789012-06:00 [drm] initializing kernel modesetting (NAVI21 0x1002:0x73BF) -kern :info : 2026-01-07T10:00:25,890123-06:00 amdgpu 0000:03:00.0: amdgpu: Fetched VBIOS from VFCT -kern :info : 2026-01-07T10:00:26,123456-06:00 amdgpu 0000:03:00.0: amdgpu: ATOM BIOS: 113-D4120100-O04 -kern :info : 2026-01-07T10:00:28,234567-06:00 [drm] GPU posting now... -kern :warn : 2026-01-07T10:00:30,345678-06:00 [drm] *ERROR* Timeout waiting for DMCUB auto-load -kern :info : 2026-01-07T10:00:32,456789-06:00 [drm] Display Core initialized with v3.2.149! -kern :info : 2026-01-07T10:00:35,567890-06:00 [drm] VCN decode and encode initialized successfully -kern :info : 2026-01-07T10:00:38,678901-06:00 [drm] fb0: amdgpudrmfb frame buffer device -kern :info : 2026-01-07T10:00:40,789012-06:00 amdgpu 0000:03:00.0: amdgpu: ring gfx_0.0.0 uses VM inv eng 0 on hub 0 -kern :info : 2026-01-07T10:00:42,890123-06:00 [drm] Initialized amdgpu 3.42.0 20150101 for 0000:03:00.0 on minor 0 -kern :info : 2026-01-07T10:00:45,123456-06:00 EXT4-fs (nvme0n1p2): mounted filesystem with ordered data mode -kern :info : 2026-01-07T10:00:48,234567-06:00 systemd[1]: systemd 249.11-0ubuntu3.6 running in system mode -kern :info : 2026-01-07T10:00:50,345678-06:00 systemd[1]: Detected architecture x86-64 -kern :info : 2026-01-07T10:00:55,456789-06:00 audit: type=1400 audit(1704636055.456:2): apparmor="STATUS" operation="profile_load" -kern :info : 2026-01-07T10:01:00,567890-06:00 Adding 33554428k swap on /swapfile -kern :info : 2026-01-07T10:01:05,678901-06:00 IPv6: ADDRCONF(NETDEV_CHANGE): enp5s0: link becomes ready -kern :info : 2026-01-07T10:01:10,789012-06:00 NFSD: Using UMH upcall client tracking operations -kern :info : 2026-01-07T10:01:15,890123-06:00 NFSD: starting 90-second grace period (net f0000098) -kern :info : 2026-01-07T10:01:20,123456-06:00 Bluetooth: BNEP (Ethernet Emulation) ver 1.3 -kern :info : 2026-01-07T10:01:25,234567-06:00 Bluetooth: BNEP filters: protocol multicast -kern :info : 2026-01-07T10:01:30,345678-06:00 System operational - all services started successfully -kern :err : 2026-01-07T10:02:00,123456-06:00 amdgpu 0000:01:00.0: {14}poison is consumed by client 12, kick off gpu reset flow -kern :err : 2026-01-07T10:02:00,234567-06:00 amdgpu 0000:01:00.0: {15}Poison is created -kern :err : 2026-01-07T10:02:01,345678-06:00 amdgpu 0000:02:00.0: amdgpu: Saved bad pages 150 reaches threshold value 128 -kern :err : 2026-01-07T10:03:00,456789-06:00 {1}[Hardware Error]: Hardware error from APEI Generic Hardware Error Source: 1 -kern :err : 2026-01-07T10:03:00,567890-06:00 {1}[Hardware Error]: event severity: recoverable -kern :err : 2026-01-07T10:03:00,678901-06:00 {1}[Hardware Error]: Error 0, type: recoverable -kern :err : 2026-01-07T10:03:00,789012-06:00 {1}[Hardware Error]: section_type: PCIe error -kern :err : 2026-01-07T10:03:00,890123-06:00 {1}[Hardware Error]: port_type: 4, root port -kern :err : 2026-01-07T10:03:01,123456-06:00 {1}[Hardware Error]: version: 3.0 -kern :err : 2026-01-07T10:03:01,234567-06:00 {1}[Hardware Error]: command: 0x0547, status: 0x4010 -kern :err : 2026-01-07T10:03:01,345678-06:00 {1}[Hardware Error]: device_id: 0000:11:01.0 -kern :err : 2026-01-07T10:03:01,456789-06:00 {1}[Hardware Error]: slot: 5 -kern :err : 2026-01-07T10:03:01,567890-06:00 {1}[Hardware Error]: secondary_bus: 0x12 -kern :err : 2026-01-07T10:03:01,678901-06:00 {1}[Hardware Error]: vendor_id: 0x1002, device_id: 0x74a1 -kern :err : 2026-01-07T10:03:01,789012-06:00 {1}[Hardware Error]: class_code: 060400 -kern :err : 2026-01-07T10:03:01,890123-06:00 {1}[Hardware Error]: bridge: secondary_status: 0x2000, control: 0x0003 -kern :err : 2026-01-07T10:04:00,123456-06:00 amdgpu 0000:02:00.0: amdgpu: Error Address(PA):0x12345000 Row:0x1000 Col:0x0 Bank:0x5 Channel:0x10 -kern :err : 2026-01-07T10:05:00,123456-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: Accelerator Check Architecture events logged -kern :err : 2026-01-07T10:05:00,234567-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].CONTROL=0x00000000000003ff -kern :err : 2026-01-07T10:05:00,345678-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].STATUS=0x98000800003e0000 -kern :err : 2026-01-07T10:05:00,456789-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].ADDR=0x0000000000000000 -kern :err : 2026-01-07T10:05:00,567890-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].MISC=0xd008000000000000 -kern :err : 2026-01-07T10:05:00,678901-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].CONFIG=0x00000021000001fb -kern :err : 2026-01-07T10:05:00,789012-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].IPID=0x0001100138430401 -kern :err : 2026-01-07T10:05:00,890123-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].SYND=0x0000000000000000 -kern :err : 2026-01-07T10:05:01,123456-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].DESTAT=0x0000000000000000 -kern :err : 2026-01-07T10:05:01,234567-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].DEADDR=0x0000000000000000 -kern :err : 2026-01-07T10:05:01,345678-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].CONTROL_MASK=0x0000000000000000 +kern :info : 2026-01-07T10:00:00,123456-06:00 Linux version 5.15.0-91-generic (buildd@amd64-builder) (gcc version 11.4.0) #101-Ubuntu SMP +kern :info : 2026-01-07T10:00:00,234567-06:00 Command line: BOOT_IMAGE=/boot/vmlinuz-5.15.0-91-generic root=UUID=a1b2c3d4 ro quiet splash vt.handoff=7 +kern :info : 2026-01-07T10:00:01,345678-06:00 KERNEL supported cpus: +kern :info : 2026-01-07T10:00:01,456789-06:00 Intel GenuineIntel +kern :info : 2026-01-07T10:00:01,567890-06:00 AMD AuthenticAMD +kern :info : 2026-01-07T10:00:02,678901-06:00 x86/fpu: Supporting XSAVE feature 0x001: 'x87 floating point registers' +kern :info : 2026-01-07T10:00:02,789012-06:00 x86/fpu: Supporting XSAVE feature 0x002: 'SSE registers' +kern :info : 2026-01-07T10:00:03,890123-06:00 Memory: 32823616K/33554432K available +kern :warn : 2026-01-07T10:00:05,123456-06:00 pci 0000:00:01.0: BAR 0: failed to assign [mem size 0x01000000] +kern :info : 2026-01-07T10:00:06,234567-06:00 PCI: Using ACPI for IRQ routing +kern :info : 2026-01-07T10:00:07,345678-06:00 NetLabel: Initializing +kern :info : 2026-01-07T10:00:08,456789-06:00 DMA: preallocated 4096 KiB GFP_KERNEL pool for atomic allocations +kern :err : 2026-01-07T10:00:10,567890-06:00 WARNING: CPU: 0 PID: 1 at drivers/gpu/drm/amd/amdgpu/amdgpu_device.c:123 amdgpu_device_init+0x456/0x789 +kern :info : 2026-01-07T10:00:11,678901-06:00 Modules linked in: amdgpu drm_ttm_helper ttm drm_kms_helper +kern :info : 2026-01-07T10:00:12,789012-06:00 CPU: 0 PID: 1 Comm: swapper/0 Not tainted 5.15.0-91-generic #101-Ubuntu +kern :err : 2026-01-07T10:00:15,890123-06:00 AMD-Vi: Event logged [IO_PAGE_FAULT device=00:14.0 domain=0x0000 address=0xfffffffffffffef0 flags=0x0010] +kern :info : 2026-01-07T10:00:16,123456-06:00 SCSI subsystem initialized +kern :info : 2026-01-07T10:00:17,234567-06:00 libata version 3.00 loaded +kern :info : 2026-01-07T10:00:18,345678-06:00 ACPI: Added _OSI(Module Device) +kern :info : 2026-01-07T10:00:19,456789-06:00 ACPI: Added _OSI(Processor Device) +kern :err : 2026-01-07T10:00:20,567890-06:00 ACPI Error: Method parse/execution failed \_SB.PCI0.GPP0.SWUS.SWDS.VGA.LCD._BCM, AE_NOT_FOUND +kern :info : 2026-01-07T10:00:22,678901-06:00 [drm] amdgpu kernel modesetting enabled +kern :info : 2026-01-07T10:00:23,789012-06:00 [drm] initializing kernel modesetting (NAVI21 0x1002:0x73BF) +kern :info : 2026-01-07T10:00:25,890123-06:00 amdgpu 0000:03:00.0: amdgpu: Fetched VBIOS from VFCT +kern :info : 2026-01-07T10:00:26,123456-06:00 amdgpu 0000:03:00.0: amdgpu: ATOM BIOS: 113-D4120100-O04 +kern :info : 2026-01-07T10:00:28,234567-06:00 [drm] GPU posting now... +kern :warn : 2026-01-07T10:00:30,345678-06:00 [drm] *ERROR* Timeout waiting for DMCUB auto-load +kern :info : 2026-01-07T10:00:32,456789-06:00 [drm] Display Core initialized with v3.2.149! +kern :info : 2026-01-07T10:00:35,567890-06:00 [drm] VCN decode and encode initialized successfully +kern :info : 2026-01-07T10:00:38,678901-06:00 [drm] fb0: amdgpudrmfb frame buffer device +kern :info : 2026-01-07T10:00:40,789012-06:00 amdgpu 0000:03:00.0: amdgpu: ring gfx_0.0.0 uses VM inv eng 0 on hub 0 +kern :info : 2026-01-07T10:00:42,890123-06:00 [drm] Initialized amdgpu 3.42.0 20150101 for 0000:03:00.0 on minor 0 +kern :info : 2026-01-07T10:00:45,123456-06:00 EXT4-fs (nvme0n1p2): mounted filesystem with ordered data mode +kern :info : 2026-01-07T10:00:48,234567-06:00 systemd[1]: systemd 249.11-0ubuntu3.6 running in system mode +kern :info : 2026-01-07T10:00:50,345678-06:00 systemd[1]: Detected architecture x86-64 +kern :info : 2026-01-07T10:00:55,456789-06:00 audit: type=1400 audit(1704636055.456:2): apparmor="STATUS" operation="profile_load" +kern :info : 2026-01-07T10:01:00,567890-06:00 Adding 33554428k swap on /swapfile +kern :info : 2026-01-07T10:01:05,678901-06:00 IPv6: ADDRCONF(NETDEV_CHANGE): enp5s0: link becomes ready +kern :info : 2026-01-07T10:01:10,789012-06:00 NFSD: Using UMH upcall client tracking operations +kern :info : 2026-01-07T10:01:15,890123-06:00 NFSD: starting 90-second grace period (net f0000098) +kern :info : 2026-01-07T10:01:20,123456-06:00 Bluetooth: BNEP (Ethernet Emulation) ver 1.3 +kern :info : 2026-01-07T10:01:25,234567-06:00 Bluetooth: BNEP filters: protocol multicast +kern :info : 2026-01-07T10:01:30,345678-06:00 System operational - all services started successfully +kern :err : 2026-01-07T10:02:00,123456-06:00 amdgpu 0000:01:00.0: {14}poison is consumed by client 12, kick off gpu reset flow +kern :err : 2026-01-07T10:02:00,234567-06:00 amdgpu 0000:01:00.0: {15}Poison is created +kern :err : 2026-01-07T10:02:01,345678-06:00 amdgpu 0000:02:00.0: amdgpu: Saved bad pages 150 reaches threshold value 128 +kern :err : 2026-01-07T10:03:00,456789-06:00 {1}[Hardware Error]: Hardware error from APEI Generic Hardware Error Source: 1 +kern :err : 2026-01-07T10:03:00,567890-06:00 {1}[Hardware Error]: event severity: recoverable +kern :err : 2026-01-07T10:03:00,678901-06:00 {1}[Hardware Error]: Error 0, type: recoverable +kern :err : 2026-01-07T10:03:00,789012-06:00 {1}[Hardware Error]: section_type: PCIe error +kern :err : 2026-01-07T10:03:00,890123-06:00 {1}[Hardware Error]: port_type: 4, root port +kern :err : 2026-01-07T10:03:01,123456-06:00 {1}[Hardware Error]: version: 3.0 +kern :err : 2026-01-07T10:03:01,234567-06:00 {1}[Hardware Error]: command: 0x0547, status: 0x4010 +kern :err : 2026-01-07T10:03:01,345678-06:00 {1}[Hardware Error]: device_id: 0000:11:01.0 +kern :err : 2026-01-07T10:03:01,456789-06:00 {1}[Hardware Error]: slot: 5 +kern :err : 2026-01-07T10:03:01,567890-06:00 {1}[Hardware Error]: secondary_bus: 0x12 +kern :err : 2026-01-07T10:03:01,678901-06:00 {1}[Hardware Error]: vendor_id: 0x1002, device_id: 0x74a1 +kern :err : 2026-01-07T10:03:01,789012-06:00 {1}[Hardware Error]: class_code: 060400 +kern :err : 2026-01-07T10:03:01,890123-06:00 {1}[Hardware Error]: bridge: secondary_status: 0x2000, control: 0x0003 +kern :err : 2026-01-07T10:04:00,123456-06:00 amdgpu 0000:02:00.0: amdgpu: Error Address(PA):0x12345000 Row:0x1000 Col:0x0 Bank:0x5 Channel:0x10 +kern :err : 2026-01-07T10:05:00,123456-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: Accelerator Check Architecture events logged +kern :err : 2026-01-07T10:05:00,234567-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].CONTROL=0x00000000000003ff +kern :err : 2026-01-07T10:05:00,345678-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].STATUS=0x98000800003e0000 +kern :err : 2026-01-07T10:05:00,456789-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].ADDR=0x0000000000000000 +kern :err : 2026-01-07T10:05:00,567890-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].MISC=0xd008000000000000 +kern :err : 2026-01-07T10:05:00,678901-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].CONFIG=0x00000021000001fb +kern :err : 2026-01-07T10:05:00,789012-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].IPID=0x0001100138430401 +kern :err : 2026-01-07T10:05:00,890123-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].SYND=0x0000000000000000 +kern :err : 2026-01-07T10:05:01,123456-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].DESTAT=0x0000000000000000 +kern :err : 2026-01-07T10:05:01,234567-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].DEADDR=0x0000000000000000 +kern :err : 2026-01-07T10:05:01,345678-06:00 amdgpu 0000:03:00.0: {1}[Hardware Error]: ACA[01/01].CONTROL_MASK=0x0000000000000000 diff --git a/test/functional/fixtures/nic_plugin_config_full_analyzer_args.json b/test/functional/fixtures/nic_plugin_config_full_analyzer_args.json index f1cc1668..5d0ab0a1 100644 --- a/test/functional/fixtures/nic_plugin_config_full_analyzer_args.json +++ b/test/functional/fixtures/nic_plugin_config_full_analyzer_args.json @@ -1,37 +1,37 @@ -{ - "name": "NicPlugin config with all analyzer_args", - "desc": "NicPlugin check.", - "global_args": {}, - "plugins": { - "NicPlugin": { - "collection_args": {}, - "analysis_args": { - "expected_values": { - "niccli_list": {"require_success": true}, - "niccli_list_devices": {"require_success": true} - }, - "performance_profile_expected": "RoCE", - "support_rdma_disabled_values": ["0", "false", "disabled", "no", "off"], - "pcie_relaxed_ordering_expected": "enabled", - "expected_qos_prio_map": { - "0": 0, - "1": 1, - "2": 0, - "3": 1, - "4": 0, - "5": 1, - "6": 0, - "7": 1 - }, - "expected_qos_pfc_enabled": 255, - "expected_qos_tsa_map": { - "0": "ets", - "1": "ets" - }, - "expected_qos_tc_bandwidth": [50, 50], - "require_qos_consistent_across_adapters": true - } - } - }, - "result_collators": {} -} +{ + "name": "NicPlugin config with all analyzer_args", + "desc": "NicPlugin check.", + "global_args": {}, + "plugins": { + "NicPlugin": { + "collection_args": {}, + "analysis_args": { + "expected_values": { + "niccli_list": {"require_success": true}, + "niccli_list_devices": {"require_success": true} + }, + "performance_profile_expected": "RoCE", + "support_rdma_disabled_values": ["0", "false", "disabled", "no", "off"], + "pcie_relaxed_ordering_expected": "enabled", + "expected_qos_prio_map": { + "0": 0, + "1": 1, + "2": 0, + "3": 1, + "4": 0, + "5": 1, + "6": 0, + "7": 1 + }, + "expected_qos_pfc_enabled": 255, + "expected_qos_tsa_map": { + "0": "ets", + "1": "ets" + }, + "expected_qos_tc_bandwidth": [50, 50], + "require_qos_consistent_across_adapters": true + } + } + }, + "result_collators": {} +} diff --git a/test/functional/fixtures/plugin_config.json b/test/functional/fixtures/plugin_config.json index 000b7b2a..12f8b5df 100644 --- a/test/functional/fixtures/plugin_config.json +++ b/test/functional/fixtures/plugin_config.json @@ -1 +1 @@ -{"name":"plugin_config","desc":"Full config: all collector_args + analyzer_args, values that pass on Ubuntu","global_args":{},"plugins":{"PackagePlugin":{"collection_args":{},"analysis_args":{"exp_package_ver":{"libc|python|apt":null},"regex_match":true,"rocm_regex":null,"enable_rocm_regex":false}},"SysSettingsPlugin":{"collection_args":{"paths":["/sys/kernel/mm/transparent_hugepage/enabled","/sys/kernel/mm/transparent_hugepage/defrag"],"directory_paths":["/sys/class/net"]},"analysis_args":{"checks":[{"path":"/sys/kernel/mm/transparent_hugepage/enabled","expected":["always","madvise","never"],"name":"thp_enabled","pattern":null},{"path":"/sys/kernel/mm/transparent_hugepage/defrag","expected":["always","madvise","never","defer"],"name":"thp_defrag","pattern":null},{"path":"/sys/class/net","expected":[],"name":"net_interfaces","pattern":"^(lo|eth|enp|wl|br-|docker|ens)"}]}}},"result_collators":{}} +{"name":"plugin_config","desc":"Full config: all collector_args + analyzer_args, values that pass on Ubuntu","global_args":{},"plugins":{"PackagePlugin":{"collection_args":{},"analysis_args":{"exp_package_ver":{"libc|python|apt":null},"regex_match":true,"rocm_regex":null,"enable_rocm_regex":false}},"SysSettingsPlugin":{"collection_args":{"paths":["/sys/kernel/mm/transparent_hugepage/enabled","/sys/kernel/mm/transparent_hugepage/defrag"],"directory_paths":["/sys/class/net"]},"analysis_args":{"checks":[{"path":"/sys/kernel/mm/transparent_hugepage/enabled","expected":["always","madvise","never"],"name":"thp_enabled","pattern":null},{"path":"/sys/kernel/mm/transparent_hugepage/defrag","expected":["always","madvise","never","defer"],"name":"thp_defrag","pattern":null},{"path":"/sys/class/net","expected":[],"name":"net_interfaces","pattern":"^(lo|eth|enp|wl|br-|docker|ens)"}]}}},"result_collators":{}} diff --git a/test/functional/fixtures/redfish_connection_config.json b/test/functional/fixtures/redfish_connection_config.json index 6a0475b9..c3c98fe8 100644 --- a/test/functional/fixtures/redfish_connection_config.json +++ b/test/functional/fixtures/redfish_connection_config.json @@ -1,9 +1,9 @@ -{ - "RedfishConnectionManager": { - "host": "https://bmc.example.com", - "username": "ADMIN", - "password": "placeholder", - "verify_ssl": false, - "timeout_seconds": 30 - } -} +{ + "RedfishConnectionManager": { + "host": "https://bmc.example.com", + "username": "ADMIN", + "password": "placeholder", + "verify_ssl": false, + "timeout_seconds": 30 + } +} diff --git a/test/functional/fixtures/redfish_endpoint_plugin_config.json b/test/functional/fixtures/redfish_endpoint_plugin_config.json index f79cf256..e777a7e4 100644 --- a/test/functional/fixtures/redfish_endpoint_plugin_config.json +++ b/test/functional/fixtures/redfish_endpoint_plugin_config.json @@ -1,12 +1,12 @@ -{ - "plugins": { - "RedfishEndpointPlugin": { - "collection_args": { - "uris": ["/redfish/v1", "/redfish/v1/Systems"] - }, - "analysis_args": { - "checks": {} - } - } - } -} +{ + "plugins": { + "RedfishEndpointPlugin": { + "collection_args": { + "uris": ["/redfish/v1", "/redfish/v1/Systems"] + }, + "analysis_args": { + "checks": {} + } + } + } +} diff --git a/test/functional/fixtures/redfish_endpoint_plugin_config_full_args.json b/test/functional/fixtures/redfish_endpoint_plugin_config_full_args.json index 099057c5..c0cf2e5a 100644 --- a/test/functional/fixtures/redfish_endpoint_plugin_config_full_args.json +++ b/test/functional/fixtures/redfish_endpoint_plugin_config_full_args.json @@ -1,26 +1,26 @@ -{ - "plugins": { - "RedfishEndpointPlugin": { - "collection_args": { - "uris": ["/redfish/v1", "/redfish/v1/Systems"], - "discover_tree": false, - "tree_max_depth": 3, - "tree_max_endpoints": 100, - "max_workers": 2 - }, - "analysis_args": { - "checks": { - "/redfish/v1/Systems/1": { - "Status/Health": { "anyOf": ["OK", "Warning"] }, - "PowerState": { "eq": "On" }, - "PowerConsumedWatts": { "min": 0, "max": 500 } - }, - "*": { - "Status/State": { "eq": "Enabled" }, - "Status/Health": { "anyOf": ["OK", "Warning"] } - } - } - } - } - } -} +{ + "plugins": { + "RedfishEndpointPlugin": { + "collection_args": { + "uris": ["/redfish/v1", "/redfish/v1/Systems"], + "discover_tree": false, + "tree_max_depth": 3, + "tree_max_endpoints": 100, + "max_workers": 2 + }, + "analysis_args": { + "checks": { + "/redfish/v1/Systems/1": { + "Status/Health": { "anyOf": ["OK", "Warning"] }, + "PowerState": { "eq": "On" }, + "PowerConsumedWatts": { "min": 0, "max": 500 } + }, + "*": { + "Status/State": { "eq": "Enabled" }, + "Status/Health": { "anyOf": ["OK", "Warning"] } + } + } + } + } + } +} diff --git a/test/functional/fixtures/regex_search_multi_logs/app_error.log b/test/functional/fixtures/regex_search_multi_logs/app_error.log index 391fb32c..25ba7a23 100644 --- a/test/functional/fixtures/regex_search_multi_logs/app_error.log +++ b/test/functional/fixtures/regex_search_multi_logs/app_error.log @@ -1,3 +1,3 @@ -startup complete -ERROR: dependency timeout connecting to backend -shutdown clean +startup complete +ERROR: dependency timeout connecting to backend +shutdown clean diff --git a/test/functional/fixtures/regex_search_multi_logs/no_errors.log b/test/functional/fixtures/regex_search_multi_logs/no_errors.log index 854d9322..e433bfc4 100644 --- a/test/functional/fixtures/regex_search_multi_logs/no_errors.log +++ b/test/functional/fixtures/regex_search_multi_logs/no_errors.log @@ -1,2 +1,2 @@ -2026-04-09 service healthy -all checks passed +2026-04-09 service healthy +all checks passed diff --git a/test/functional/fixtures/regex_search_multi_logs/storage_warn.log b/test/functional/fixtures/regex_search_multi_logs/storage_warn.log index bc11a75a..432bf314 100644 --- a/test/functional/fixtures/regex_search_multi_logs/storage_warn.log +++ b/test/functional/fixtures/regex_search_multi_logs/storage_warn.log @@ -1,3 +1,3 @@ -mount ok -WARNING: disk full on /data within 5% -idle +mount ok +WARNING: disk full on /data within 5% +idle diff --git a/test/functional/fixtures/regex_search_sample.log b/test/functional/fixtures/regex_search_sample.log index 7564228b..0490f4ec 100644 --- a/test/functional/fixtures/regex_search_sample.log +++ b/test/functional/fixtures/regex_search_sample.log @@ -1,4 +1,4 @@ -2026-04-09T10:00:00Z service started OK -2026-04-09T10:01:00Z ERROR: connection reset by peer -2026-04-09T10:02:00Z routine check passed -2026-04-09T10:03:00Z WARNING: disk full on /var +2026-04-09T10:00:00Z service started OK +2026-04-09T10:01:00Z ERROR: connection reset by peer +2026-04-09T10:02:00Z routine check passed +2026-04-09T10:03:00Z WARNING: disk full on /var diff --git a/test/functional/fixtures/sys_settings_plugin_config.json b/test/functional/fixtures/sys_settings_plugin_config.json index 2a2013c7..d912520b 100644 --- a/test/functional/fixtures/sys_settings_plugin_config.json +++ b/test/functional/fixtures/sys_settings_plugin_config.json @@ -1,36 +1,36 @@ -{ - "name": "SysSettingsPlugin config", - "desc": "Config for testing SysSettingsPlugin (sysfs settings)", - "global_args": {}, - "plugins": { - "SysSettingsPlugin": { - "collection_args": { - "paths": [ - "/sys/kernel/mm/transparent_hugepage/enabled", - "/sys/kernel/mm/transparent_hugepage/defrag", - "/sys/kernel/mm/transparent_hugepage/shmem_enabled" - ] - }, - "analysis_args": { - "checks": [ - { - "path": "/sys/kernel/mm/transparent_hugepage/enabled", - "expected": ["always", "madvise", "never"], - "name": "thp_enabled" - }, - { - "path": "/sys/kernel/mm/transparent_hugepage/defrag", - "expected": ["always", "madvise", "never", "defer"], - "name": "thp_defrag" - }, - { - "path": "/sys/kernel/mm/transparent_hugepage/shmem_enabled", - "expected": [], - "name": "thp_shmem" - } - ] - } - } - }, - "result_collators": {} -} +{ + "name": "SysSettingsPlugin config", + "desc": "Config for testing SysSettingsPlugin (sysfs settings)", + "global_args": {}, + "plugins": { + "SysSettingsPlugin": { + "collection_args": { + "paths": [ + "/sys/kernel/mm/transparent_hugepage/enabled", + "/sys/kernel/mm/transparent_hugepage/defrag", + "/sys/kernel/mm/transparent_hugepage/shmem_enabled" + ] + }, + "analysis_args": { + "checks": [ + { + "path": "/sys/kernel/mm/transparent_hugepage/enabled", + "expected": ["always", "madvise", "never"], + "name": "thp_enabled" + }, + { + "path": "/sys/kernel/mm/transparent_hugepage/defrag", + "expected": ["always", "madvise", "never", "defer"], + "name": "thp_defrag" + }, + { + "path": "/sys/kernel/mm/transparent_hugepage/shmem_enabled", + "expected": [], + "name": "thp_shmem" + } + ] + } + } + }, + "result_collators": {} +} diff --git a/test/functional/test_cli_no_console_log.py b/test/functional/test_cli_no_console_log.py index ed9fd8fa..9a5ee964 100644 --- a/test/functional/test_cli_no_console_log.py +++ b/test/functional/test_cli_no_console_log.py @@ -1,50 +1,50 @@ -############################################################################### -# -# MIT License -# -# 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. -# -############################################################################### - -import subprocess -import sys - - -def test_no_console_log_with_log_path_none_still_parses(): - """--log-path None + --no-console-log defaults to ./scraper_logs_* (no argparse error).""" - result = subprocess.run( - [ - sys.executable, - "-m", - "nodescraper.cli.cli", - "--log-path", - "None", - "--no-console-log", - "run-plugins", - "OsPlugin", - "-h", - ], - capture_output=True, - text=True, - ) - assert result.returncode == 0, result.stderr - combined = (result.stdout or "") + (result.stderr or "") - assert "no-console-log requires" not in combined.lower() +############################################################################### +# +# MIT License +# +# 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. +# +############################################################################### + +import subprocess +import sys + + +def test_no_console_log_with_log_path_none_still_parses(): + """--log-path None + --no-console-log defaults to ./scraper_logs_* (no argparse error).""" + result = subprocess.run( + [ + sys.executable, + "-m", + "nodescraper.cli.cli", + "--log-path", + "None", + "--no-console-log", + "run-plugins", + "OsPlugin", + "-h", + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + combined = (result.stdout or "") + (result.stderr or "") + assert "no-console-log requires" not in combined.lower() diff --git a/test/functional/test_nic_plugin.py b/test/functional/test_nic_plugin.py index b862845e..c9fa0e64 100644 --- a/test/functional/test_nic_plugin.py +++ b/test/functional/test_nic_plugin.py @@ -1,135 +1,135 @@ -############################################################################### -# -# MIT License -# -# Copyright (c) 2026 Advanced Micro Devices, Inc. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -############################################################################### - -from pathlib import Path - -import pytest - - -@pytest.fixture -def fixtures_dir(): - """Return path to fixtures directory.""" - return Path(__file__).parent / "fixtures" - - -@pytest.fixture -def nic_plugin_config_full_analyzer_args(fixtures_dir): - """Return path to NicPlugin config with all analyzer_args populated.""" - return fixtures_dir / "nic_plugin_config_full_analyzer_args.json" - - -@pytest.fixture -def nic_plugin_config_minimal(fixtures_dir): - """Return path to minimal NicPlugin config (niccli_plugin_config.json).""" - return fixtures_dir / "niccli_plugin_config.json" - - -def test_nic_plugin_with_full_analyzer_args_config( - run_cli_command, nic_plugin_config_full_analyzer_args, tmp_path -): - """Test NicPlugin using config with all analyzer_args (performance_profile, getqos, etc.).""" - assert ( - nic_plugin_config_full_analyzer_args.exists() - ), f"Config file not found: {nic_plugin_config_full_analyzer_args}" - - log_path = str(tmp_path / "logs_nic_full_args") - result = run_cli_command( - [ - "--log-path", - log_path, - f"--plugin-configs={nic_plugin_config_full_analyzer_args}", - ], - check=False, - ) - - assert result.returncode in [ - 0, - 1, - 2, - ], f"Unexpected return code: {result.returncode}. stdout: {result.stdout[:500]!r}" - output = result.stdout + result.stderr - assert len(output) > 0 - assert "NicPlugin" in output or "nic" in output.lower() - - -def test_nic_plugin_with_minimal_config(run_cli_command, nic_plugin_config_minimal, tmp_path): - """Test NicPlugin using minimal config (default collection_args, no analysis_args).""" - assert nic_plugin_config_minimal.exists(), f"Config file not found: {nic_plugin_config_minimal}" - - log_path = str(tmp_path / "logs_nic_minimal") - result = run_cli_command( - ["--log-path", log_path, f"--plugin-configs={nic_plugin_config_minimal}"], - check=False, - ) - - assert result.returncode in [ - 0, - 1, - 2, - ], f"Unexpected return code: {result.returncode}. stdout: {result.stdout[:500]!r}" - output = result.stdout + result.stderr - assert len(output) > 0 - assert "NicPlugin" in output or "nic" in output.lower() - - -def test_nic_plugin_with_run_plugins_subcommand(run_cli_command, tmp_path): - """Test NicPlugin via run-plugins subcommand (no config).""" - log_path = str(tmp_path / "logs_nic_subcommand") - result = run_cli_command(["--log-path", log_path, "run-plugins", "NicPlugin"], check=False) - - assert result.returncode in [ - 0, - 1, - 2, - ], f"Unexpected return code: {result.returncode}. stdout: {result.stdout[:500]!r}" - output = result.stdout + result.stderr - assert len(output) > 0 - assert "NicPlugin" in output or "nic" in output.lower() - - -def test_nic_plugin_full_config_validates_analysis_args( - run_cli_command, nic_plugin_config_full_analyzer_args, tmp_path -): - """Config with all analyzer_args loads and runs without validation error.""" - assert nic_plugin_config_full_analyzer_args.exists() - - log_path = str(tmp_path / "logs_nic_validate") - result = run_cli_command( - [ - "--log-path", - log_path, - f"--plugin-configs={nic_plugin_config_full_analyzer_args}", - ], - check=False, - ) - - assert result.returncode in [ - 0, - 1, - 2, - ], f"Unexpected return code: {result.returncode}. stdout: {result.stdout[:500]!r}" - output = result.stdout + result.stderr - assert "NicPlugin" in output +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### + +from pathlib import Path + +import pytest + + +@pytest.fixture +def fixtures_dir(): + """Return path to fixtures directory.""" + return Path(__file__).parent / "fixtures" + + +@pytest.fixture +def nic_plugin_config_full_analyzer_args(fixtures_dir): + """Return path to NicPlugin config with all analyzer_args populated.""" + return fixtures_dir / "nic_plugin_config_full_analyzer_args.json" + + +@pytest.fixture +def nic_plugin_config_minimal(fixtures_dir): + """Return path to minimal NicPlugin config (niccli_plugin_config.json).""" + return fixtures_dir / "niccli_plugin_config.json" + + +def test_nic_plugin_with_full_analyzer_args_config( + run_cli_command, nic_plugin_config_full_analyzer_args, tmp_path +): + """Test NicPlugin using config with all analyzer_args (performance_profile, getqos, etc.).""" + assert ( + nic_plugin_config_full_analyzer_args.exists() + ), f"Config file not found: {nic_plugin_config_full_analyzer_args}" + + log_path = str(tmp_path / "logs_nic_full_args") + result = run_cli_command( + [ + "--log-path", + log_path, + f"--plugin-configs={nic_plugin_config_full_analyzer_args}", + ], + check=False, + ) + + assert result.returncode in [ + 0, + 1, + 2, + ], f"Unexpected return code: {result.returncode}. stdout: {result.stdout[:500]!r}" + output = result.stdout + result.stderr + assert len(output) > 0 + assert "NicPlugin" in output or "nic" in output.lower() + + +def test_nic_plugin_with_minimal_config(run_cli_command, nic_plugin_config_minimal, tmp_path): + """Test NicPlugin using minimal config (default collection_args, no analysis_args).""" + assert nic_plugin_config_minimal.exists(), f"Config file not found: {nic_plugin_config_minimal}" + + log_path = str(tmp_path / "logs_nic_minimal") + result = run_cli_command( + ["--log-path", log_path, f"--plugin-configs={nic_plugin_config_minimal}"], + check=False, + ) + + assert result.returncode in [ + 0, + 1, + 2, + ], f"Unexpected return code: {result.returncode}. stdout: {result.stdout[:500]!r}" + output = result.stdout + result.stderr + assert len(output) > 0 + assert "NicPlugin" in output or "nic" in output.lower() + + +def test_nic_plugin_with_run_plugins_subcommand(run_cli_command, tmp_path): + """Test NicPlugin via run-plugins subcommand (no config).""" + log_path = str(tmp_path / "logs_nic_subcommand") + result = run_cli_command(["--log-path", log_path, "run-plugins", "NicPlugin"], check=False) + + assert result.returncode in [ + 0, + 1, + 2, + ], f"Unexpected return code: {result.returncode}. stdout: {result.stdout[:500]!r}" + output = result.stdout + result.stderr + assert len(output) > 0 + assert "NicPlugin" in output or "nic" in output.lower() + + +def test_nic_plugin_full_config_validates_analysis_args( + run_cli_command, nic_plugin_config_full_analyzer_args, tmp_path +): + """Config with all analyzer_args loads and runs without validation error.""" + assert nic_plugin_config_full_analyzer_args.exists() + + log_path = str(tmp_path / "logs_nic_validate") + result = run_cli_command( + [ + "--log-path", + log_path, + f"--plugin-configs={nic_plugin_config_full_analyzer_args}", + ], + check=False, + ) + + assert result.returncode in [ + 0, + 1, + 2, + ], f"Unexpected return code: {result.returncode}. stdout: {result.stdout[:500]!r}" + output = result.stdout + result.stderr + assert "NicPlugin" in output diff --git a/test/functional/test_redfish_endpoint_plugin.py b/test/functional/test_redfish_endpoint_plugin.py index f2513b62..b9f439fd 100644 --- a/test/functional/test_redfish_endpoint_plugin.py +++ b/test/functional/test_redfish_endpoint_plugin.py @@ -1,390 +1,390 @@ -############################################################################### -# -# 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 unittest.mock import MagicMock - -import pytest - -from nodescraper.connection.redfish import RedfishGetResult -from nodescraper.enums import EventCategory, ExecutionStatus -from nodescraper.plugins.ooband.redfish_endpoint import ( - RedfishEndpointCollector, - RedfishEndpointCollectorArgs, -) -from nodescraper.plugins.ooband.redfish_endpoint import endpoint_collector as ec - - -@pytest.fixture -def redfish_endpoint_collector(system_info, redfish_conn_mock): - return RedfishEndpointCollector( - system_info=system_info, - connection=redfish_conn_mock, - ) - - -def test_redfish_endpoint_collector_no_uris(redfish_endpoint_collector): - result, data = redfish_endpoint_collector.collect_data() - assert result.status == ExecutionStatus.NOT_RAN - assert ( - result.message - == "No collection mode configured: set collection_args.discover_tree to true or provide collection_args.uris" - ) - assert data is None - - -def test_redfish_endpoint_collector_no_uris_with_args(redfish_endpoint_collector): - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs(uris=[]) - ) - assert result.status == ExecutionStatus.NOT_RAN - assert data is None - - -def test_redfish_endpoint_collector_one_uri_success(redfish_endpoint_collector, redfish_conn_mock): - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/redfish/v1", - success=True, - data={"Name": "Root"}, - status_code=200, - ) - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs(uris=["/redfish/v1"]) - ) - assert result.status == ExecutionStatus.OK - assert result.message == "Collected 1 Redfish endpoint(s)" - assert data is not None - assert data.responses["/redfish/v1"]["Name"] == "Root" - redfish_conn_mock.run_get.assert_called_once() - call_path = redfish_conn_mock.run_get.call_args[0][0] - assert call_path == "/redfish/v1" or call_path.strip("/") == "redfish/v1" - - -def test_redfish_endpoint_collector_uri_normalized_with_leading_slash( - redfish_endpoint_collector, redfish_conn_mock -): - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/redfish/v1/Systems", - success=True, - data={"Members": []}, - status_code=200, - ) - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs(uris=["redfish/v1/Systems"]) - ) - assert result.status == ExecutionStatus.OK - assert data is not None - assert "/redfish/v1/Systems" in data.responses or "redfish/v1/Systems" in data.responses - - -def test_redfish_endpoint_collector_one_fail_no_success( - redfish_endpoint_collector, redfish_conn_mock -): - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/redfish/v1", - success=False, - error="Connection refused", - status_code=None, - ) - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs(uris=["/redfish/v1"]) - ) - assert result.status == ExecutionStatus.ERROR - assert result.message.startswith("No Redfish endpoints could be read") - assert data is None - assert len(result.events) >= 1 - assert any( - e.category == EventCategory.RUNTIME.value or "Redfish GET failed" in (e.description or "") - for e in result.events - ) - - -def test_redfish_endpoint_collector_mixed_success_fail( - redfish_endpoint_collector, redfish_conn_mock -): - def run_get_side_effect(path): - path_str = str(path) - if "Systems" in path_str: - return RedfishGetResult( - path=path_str if path_str.startswith("/") else "/" + path_str, - success=True, - data={"Id": "1"}, - status_code=200, - ) - return RedfishGetResult( - path=path_str if path_str.startswith("/") else "/" + path_str, - success=False, - error="Not Found", - status_code=404, - ) - - redfish_conn_mock.run_get.side_effect = run_get_side_effect - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs(uris=["/redfish/v1/Systems", "/redfish/v1/Bad"]) - ) - assert result.status == ExecutionStatus.OK - assert data is not None - assert len(data.responses) == 1 - keys = list(data.responses.keys()) - assert any("Systems" in k for k in keys) - assert list(data.responses.values())[0].get("Id") == "1" - - -def test_normalize_path_empty_or_invalid(): - api = "redfish/v1" - assert ec._normalize_path("", api) == "" - assert ec._normalize_path(None, api) == "" # type: ignore[arg-type] - assert ec._normalize_path(" ", api) == "" - - -def test_normalize_path_relative_path(): - api = "redfish/v1" - assert ec._normalize_path("/redfish/v1", api) == "/redfish/v1" - assert ec._normalize_path("/redfish/v1/", api) == "/redfish/v1" - assert ec._normalize_path("redfish/v1/Systems", api) == "/redfish/v1/Systems" - assert ec._normalize_path(" /redfish/v1/Chassis ", api) == "/redfish/v1/Chassis" - - -def test_normalize_path_full_url(): - api = "redfish/v1" - assert ec._normalize_path("https://host/redfish/v1/Systems", api) == "/redfish/v1/Systems" - assert ec._normalize_path("http://bmc/redfish/v1", api) == "/redfish/v1" - - -def test_normalize_path_outside_api_root(): - api = "redfish/v1" - assert ec._normalize_path("/other/root", api) == "" - assert ec._normalize_path("https://host/other/path", api) == "" - - -def test_extract_odata_ids_empty(): - assert ec._extract_odata_ids({}) == [] - assert ec._extract_odata_ids([]) == [] - assert ec._extract_odata_ids("x") == [] - - -def test_extract_odata_ids_single(): - assert ec._extract_odata_ids({"@odata.id": "/redfish/v1"}) == ["/redfish/v1"] - assert ec._extract_odata_ids({"@odata.id": "https://host/redfish/v1"}) == [ - "https://host/redfish/v1" - ] - - -def test_extract_odata_ids_members(): - body = { - "Members": [ - {"@odata.id": "/redfish/v1/Systems/1"}, - {"@odata.id": "/redfish/v1/Systems/2"}, - ] - } - assert set(ec._extract_odata_ids(body)) == {"/redfish/v1/Systems/1", "/redfish/v1/Systems/2"} - - -def test_extract_odata_ids_nested_and_members(): - body = { - "@odata.id": "/redfish/v1", - "Systems": {"@odata.id": "/redfish/v1/Systems", "Members": []}, - "Chassis": { - "Members": [{"@odata.id": "/redfish/v1/Chassis/1"}], - }, - } - ids = ec._extract_odata_ids(body) - assert "/redfish/v1" in ids - assert "/redfish/v1/Systems" in ids - assert "/redfish/v1/Chassis/1" in ids - - -def test_uris_from_args_none(): - assert ec._uris_from_args(None) == [] - - -def test_uris_from_args_empty(): - assert ec._uris_from_args(RedfishEndpointCollectorArgs(uris=[])) == [] - - -def test_uris_from_args_with_uris(): - args = RedfishEndpointCollectorArgs(uris=["/redfish/v1", "/redfish/v1/Systems"]) - assert ec._uris_from_args(args) == ["/redfish/v1", "/redfish/v1/Systems"] - - -def test_fetch_one_calls_run_get(): - conn = MagicMock() - conn.run_get.return_value = RedfishGetResult( - path="/redfish/v1", success=True, data={}, status_code=200 - ) - out = ec._fetch_one(conn, "/redfish/v1") - conn.run_get.assert_called_once_with("/redfish/v1") - assert out.success is True - assert out.path == "/redfish/v1" - - -def test_discover_tree_single_root(): - conn = MagicMock() - conn.run_get.return_value = RedfishGetResult( - path="/redfish/v1", - success=True, - data={"@odata.id": "/redfish/v1", "Name": "Root"}, - status_code=200, - ) - paths, responses, results = ec._discover_tree( - conn, api_root="redfish/v1", max_depth=2, max_endpoints=0 - ) - assert paths == ["/redfish/v1"] - assert list(responses.keys()) == ["/redfish/v1"] - assert responses["/redfish/v1"]["Name"] == "Root" - assert len(results) == 1 - conn.run_get.assert_called_once_with("/redfish/v1") - - -def test_discover_tree_follows_links(): - conn = MagicMock() - root_data = { - "@odata.id": "/redfish/v1", - "Systems": {"@odata.id": "/redfish/v1/Systems"}, - } - systems_data = { - "@odata.id": "/redfish/v1/Systems", - "Members": [{"@odata.id": "/redfish/v1/Systems/1"}], - } - system1_data = {"@odata.id": "/redfish/v1/Systems/1", "Id": "1"} - - def run_get(path): - if path == "/redfish/v1": - return RedfishGetResult(path=path, success=True, data=root_data, status_code=200) - if path == "/redfish/v1/Systems": - return RedfishGetResult(path=path, success=True, data=systems_data, status_code=200) - if path == "/redfish/v1/Systems/1": - return RedfishGetResult(path=path, success=True, data=system1_data, status_code=200) - return RedfishGetResult(path=path, success=False, error="Not Found", status_code=404) - - conn.run_get.side_effect = run_get - paths, responses, results = ec._discover_tree( - conn, api_root="redfish/v1", max_depth=3, max_endpoints=0 - ) - assert "/redfish/v1" in paths - assert "/redfish/v1/Systems" in paths - assert "/redfish/v1/Systems/1" in paths - assert responses["/redfish/v1"]["@odata.id"] == "/redfish/v1" - assert responses["/redfish/v1/Systems"]["@odata.id"] == "/redfish/v1/Systems" - assert responses["/redfish/v1/Systems/1"]["Id"] == "1" - assert len(results) >= 3 - - -def test_discover_tree_respects_max_depth(): - conn = MagicMock() - root_data = {"@odata.id": "/redfish/v1", "Systems": {"@odata.id": "/redfish/v1/Systems"}} - systems_data = { - "@odata.id": "/redfish/v1/Systems", - "Members": [{"@odata.id": "/redfish/v1/Systems/1"}], - } - - def run_get(path): - if path == "/redfish/v1": - return RedfishGetResult(path=path, success=True, data=root_data, status_code=200) - if path == "/redfish/v1/Systems": - return RedfishGetResult(path=path, success=True, data=systems_data, status_code=200) - return RedfishGetResult(path=path, success=True, data={}, status_code=200) - - conn.run_get.side_effect = run_get - paths, responses, results = ec._discover_tree( - conn, api_root="redfish/v1", max_depth=1, max_endpoints=0 - ) - assert "/redfish/v1" in paths - assert "/redfish/v1/Systems" not in paths - assert len(responses) == 1 - - -def test_discover_tree_respects_max_endpoints(): - conn = MagicMock() - conn.run_get.return_value = RedfishGetResult( - path="/redfish/v1", - success=True, - data={"@odata.id": "/redfish/v1", "Systems": {"@odata.id": "/redfish/v1/Systems"}}, - status_code=200, - ) - paths, responses, results = ec._discover_tree( - conn, api_root="redfish/v1", max_depth=5, max_endpoints=1 - ) - assert len(paths) == 1 - assert len(responses) == 1 - conn.run_get.assert_called_once() - - -def test_collect_data_discover_tree_success(redfish_endpoint_collector, redfish_conn_mock): - redfish_conn_mock.api_root = "redfish/v1" - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/redfish/v1", - success=True, - data={"@odata.id": "/redfish/v1", "Name": "Root"}, - status_code=200, - ) - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs(discover_tree=True) - ) - assert result.status == ExecutionStatus.OK - assert result.message == "Collected 1 Redfish endpoint(s) from tree" - assert data is not None - assert "/redfish/v1" in data.responses - assert data.responses["/redfish/v1"]["Name"] == "Root" - - -def test_collect_data_discover_tree_no_responses(redfish_endpoint_collector, redfish_conn_mock): - redfish_conn_mock.api_root = "redfish/v1" - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/redfish/v1", success=False, error="Connection refused", status_code=None - ) - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs(discover_tree=True) - ) - assert result.status == ExecutionStatus.ERROR - assert "No Redfish endpoints discovered from tree" in result.message - assert data is None - - -def test_collect_data_concurrent_two_uris(redfish_endpoint_collector, redfish_conn_mock): - redfish_conn_mock.copy.return_value = redfish_conn_mock - call_count = 0 - - def run_get(path): - nonlocal call_count - call_count += 1 - return RedfishGetResult( - path=path if path.startswith("/") else "/" + path, - success=True, - data={"path": path}, - status_code=200, - ) - - redfish_conn_mock.run_get.side_effect = run_get - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs( - uris=["/redfish/v1", "/redfish/v1/Systems"], max_workers=2 - ) - ) - assert result.status == ExecutionStatus.OK - assert data is not None - assert len(data.responses) == 2 - assert "/redfish/v1" in data.responses or "redfish/v1" in data.responses - assert any("Systems" in k for k in data.responses) - assert redfish_conn_mock.copy.called +############################################################################### +# +# 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 unittest.mock import MagicMock + +import pytest + +from nodescraper.connection.redfish import RedfishGetResult +from nodescraper.enums import EventCategory, ExecutionStatus +from nodescraper.plugins.ooband.redfish_endpoint import ( + RedfishEndpointCollector, + RedfishEndpointCollectorArgs, +) +from nodescraper.plugins.ooband.redfish_endpoint import endpoint_collector as ec + + +@pytest.fixture +def redfish_endpoint_collector(system_info, redfish_conn_mock): + return RedfishEndpointCollector( + system_info=system_info, + connection=redfish_conn_mock, + ) + + +def test_redfish_endpoint_collector_no_uris(redfish_endpoint_collector): + result, data = redfish_endpoint_collector.collect_data() + assert result.status == ExecutionStatus.NOT_RAN + assert ( + result.message + == "No collection mode configured: set collection_args.discover_tree to true or provide collection_args.uris" + ) + assert data is None + + +def test_redfish_endpoint_collector_no_uris_with_args(redfish_endpoint_collector): + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=[]) + ) + assert result.status == ExecutionStatus.NOT_RAN + assert data is None + + +def test_redfish_endpoint_collector_one_uri_success(redfish_endpoint_collector, redfish_conn_mock): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", + success=True, + data={"Name": "Root"}, + status_code=200, + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=["/redfish/v1"]) + ) + assert result.status == ExecutionStatus.OK + assert result.message == "Collected 1 Redfish endpoint(s)" + assert data is not None + assert data.responses["/redfish/v1"]["Name"] == "Root" + redfish_conn_mock.run_get.assert_called_once() + call_path = redfish_conn_mock.run_get.call_args[0][0] + assert call_path == "/redfish/v1" or call_path.strip("/") == "redfish/v1" + + +def test_redfish_endpoint_collector_uri_normalized_with_leading_slash( + redfish_endpoint_collector, redfish_conn_mock +): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1/Systems", + success=True, + data={"Members": []}, + status_code=200, + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=["redfish/v1/Systems"]) + ) + assert result.status == ExecutionStatus.OK + assert data is not None + assert "/redfish/v1/Systems" in data.responses or "redfish/v1/Systems" in data.responses + + +def test_redfish_endpoint_collector_one_fail_no_success( + redfish_endpoint_collector, redfish_conn_mock +): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", + success=False, + error="Connection refused", + status_code=None, + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=["/redfish/v1"]) + ) + assert result.status == ExecutionStatus.ERROR + assert result.message.startswith("No Redfish endpoints could be read") + assert data is None + assert len(result.events) >= 1 + assert any( + e.category == EventCategory.RUNTIME.value or "Redfish GET failed" in (e.description or "") + for e in result.events + ) + + +def test_redfish_endpoint_collector_mixed_success_fail( + redfish_endpoint_collector, redfish_conn_mock +): + def run_get_side_effect(path): + path_str = str(path) + if "Systems" in path_str: + return RedfishGetResult( + path=path_str if path_str.startswith("/") else "/" + path_str, + success=True, + data={"Id": "1"}, + status_code=200, + ) + return RedfishGetResult( + path=path_str if path_str.startswith("/") else "/" + path_str, + success=False, + error="Not Found", + status_code=404, + ) + + redfish_conn_mock.run_get.side_effect = run_get_side_effect + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=["/redfish/v1/Systems", "/redfish/v1/Bad"]) + ) + assert result.status == ExecutionStatus.OK + assert data is not None + assert len(data.responses) == 1 + keys = list(data.responses.keys()) + assert any("Systems" in k for k in keys) + assert list(data.responses.values())[0].get("Id") == "1" + + +def test_normalize_path_empty_or_invalid(): + api = "redfish/v1" + assert ec._normalize_path("", api) == "" + assert ec._normalize_path(None, api) == "" # type: ignore[arg-type] + assert ec._normalize_path(" ", api) == "" + + +def test_normalize_path_relative_path(): + api = "redfish/v1" + assert ec._normalize_path("/redfish/v1", api) == "/redfish/v1" + assert ec._normalize_path("/redfish/v1/", api) == "/redfish/v1" + assert ec._normalize_path("redfish/v1/Systems", api) == "/redfish/v1/Systems" + assert ec._normalize_path(" /redfish/v1/Chassis ", api) == "/redfish/v1/Chassis" + + +def test_normalize_path_full_url(): + api = "redfish/v1" + assert ec._normalize_path("https://host/redfish/v1/Systems", api) == "/redfish/v1/Systems" + assert ec._normalize_path("http://bmc/redfish/v1", api) == "/redfish/v1" + + +def test_normalize_path_outside_api_root(): + api = "redfish/v1" + assert ec._normalize_path("/other/root", api) == "" + assert ec._normalize_path("https://host/other/path", api) == "" + + +def test_extract_odata_ids_empty(): + assert ec._extract_odata_ids({}) == [] + assert ec._extract_odata_ids([]) == [] + assert ec._extract_odata_ids("x") == [] + + +def test_extract_odata_ids_single(): + assert ec._extract_odata_ids({"@odata.id": "/redfish/v1"}) == ["/redfish/v1"] + assert ec._extract_odata_ids({"@odata.id": "https://host/redfish/v1"}) == [ + "https://host/redfish/v1" + ] + + +def test_extract_odata_ids_members(): + body = { + "Members": [ + {"@odata.id": "/redfish/v1/Systems/1"}, + {"@odata.id": "/redfish/v1/Systems/2"}, + ] + } + assert set(ec._extract_odata_ids(body)) == {"/redfish/v1/Systems/1", "/redfish/v1/Systems/2"} + + +def test_extract_odata_ids_nested_and_members(): + body = { + "@odata.id": "/redfish/v1", + "Systems": {"@odata.id": "/redfish/v1/Systems", "Members": []}, + "Chassis": { + "Members": [{"@odata.id": "/redfish/v1/Chassis/1"}], + }, + } + ids = ec._extract_odata_ids(body) + assert "/redfish/v1" in ids + assert "/redfish/v1/Systems" in ids + assert "/redfish/v1/Chassis/1" in ids + + +def test_uris_from_args_none(): + assert ec._uris_from_args(None) == [] + + +def test_uris_from_args_empty(): + assert ec._uris_from_args(RedfishEndpointCollectorArgs(uris=[])) == [] + + +def test_uris_from_args_with_uris(): + args = RedfishEndpointCollectorArgs(uris=["/redfish/v1", "/redfish/v1/Systems"]) + assert ec._uris_from_args(args) == ["/redfish/v1", "/redfish/v1/Systems"] + + +def test_fetch_one_calls_run_get(): + conn = MagicMock() + conn.run_get.return_value = RedfishGetResult( + path="/redfish/v1", success=True, data={}, status_code=200 + ) + out = ec._fetch_one(conn, "/redfish/v1") + conn.run_get.assert_called_once_with("/redfish/v1") + assert out.success is True + assert out.path == "/redfish/v1" + + +def test_discover_tree_single_root(): + conn = MagicMock() + conn.run_get.return_value = RedfishGetResult( + path="/redfish/v1", + success=True, + data={"@odata.id": "/redfish/v1", "Name": "Root"}, + status_code=200, + ) + paths, responses, results = ec._discover_tree( + conn, api_root="redfish/v1", max_depth=2, max_endpoints=0 + ) + assert paths == ["/redfish/v1"] + assert list(responses.keys()) == ["/redfish/v1"] + assert responses["/redfish/v1"]["Name"] == "Root" + assert len(results) == 1 + conn.run_get.assert_called_once_with("/redfish/v1") + + +def test_discover_tree_follows_links(): + conn = MagicMock() + root_data = { + "@odata.id": "/redfish/v1", + "Systems": {"@odata.id": "/redfish/v1/Systems"}, + } + systems_data = { + "@odata.id": "/redfish/v1/Systems", + "Members": [{"@odata.id": "/redfish/v1/Systems/1"}], + } + system1_data = {"@odata.id": "/redfish/v1/Systems/1", "Id": "1"} + + def run_get(path): + if path == "/redfish/v1": + return RedfishGetResult(path=path, success=True, data=root_data, status_code=200) + if path == "/redfish/v1/Systems": + return RedfishGetResult(path=path, success=True, data=systems_data, status_code=200) + if path == "/redfish/v1/Systems/1": + return RedfishGetResult(path=path, success=True, data=system1_data, status_code=200) + return RedfishGetResult(path=path, success=False, error="Not Found", status_code=404) + + conn.run_get.side_effect = run_get + paths, responses, results = ec._discover_tree( + conn, api_root="redfish/v1", max_depth=3, max_endpoints=0 + ) + assert "/redfish/v1" in paths + assert "/redfish/v1/Systems" in paths + assert "/redfish/v1/Systems/1" in paths + assert responses["/redfish/v1"]["@odata.id"] == "/redfish/v1" + assert responses["/redfish/v1/Systems"]["@odata.id"] == "/redfish/v1/Systems" + assert responses["/redfish/v1/Systems/1"]["Id"] == "1" + assert len(results) >= 3 + + +def test_discover_tree_respects_max_depth(): + conn = MagicMock() + root_data = {"@odata.id": "/redfish/v1", "Systems": {"@odata.id": "/redfish/v1/Systems"}} + systems_data = { + "@odata.id": "/redfish/v1/Systems", + "Members": [{"@odata.id": "/redfish/v1/Systems/1"}], + } + + def run_get(path): + if path == "/redfish/v1": + return RedfishGetResult(path=path, success=True, data=root_data, status_code=200) + if path == "/redfish/v1/Systems": + return RedfishGetResult(path=path, success=True, data=systems_data, status_code=200) + return RedfishGetResult(path=path, success=True, data={}, status_code=200) + + conn.run_get.side_effect = run_get + paths, responses, results = ec._discover_tree( + conn, api_root="redfish/v1", max_depth=1, max_endpoints=0 + ) + assert "/redfish/v1" in paths + assert "/redfish/v1/Systems" not in paths + assert len(responses) == 1 + + +def test_discover_tree_respects_max_endpoints(): + conn = MagicMock() + conn.run_get.return_value = RedfishGetResult( + path="/redfish/v1", + success=True, + data={"@odata.id": "/redfish/v1", "Systems": {"@odata.id": "/redfish/v1/Systems"}}, + status_code=200, + ) + paths, responses, results = ec._discover_tree( + conn, api_root="redfish/v1", max_depth=5, max_endpoints=1 + ) + assert len(paths) == 1 + assert len(responses) == 1 + conn.run_get.assert_called_once() + + +def test_collect_data_discover_tree_success(redfish_endpoint_collector, redfish_conn_mock): + redfish_conn_mock.api_root = "redfish/v1" + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", + success=True, + data={"@odata.id": "/redfish/v1", "Name": "Root"}, + status_code=200, + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(discover_tree=True) + ) + assert result.status == ExecutionStatus.OK + assert result.message == "Collected 1 Redfish endpoint(s) from tree" + assert data is not None + assert "/redfish/v1" in data.responses + assert data.responses["/redfish/v1"]["Name"] == "Root" + + +def test_collect_data_discover_tree_no_responses(redfish_endpoint_collector, redfish_conn_mock): + redfish_conn_mock.api_root = "redfish/v1" + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", success=False, error="Connection refused", status_code=None + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(discover_tree=True) + ) + assert result.status == ExecutionStatus.ERROR + assert "No Redfish endpoints discovered from tree" in result.message + assert data is None + + +def test_collect_data_concurrent_two_uris(redfish_endpoint_collector, redfish_conn_mock): + redfish_conn_mock.copy.return_value = redfish_conn_mock + call_count = 0 + + def run_get(path): + nonlocal call_count + call_count += 1 + return RedfishGetResult( + path=path if path.startswith("/") else "/" + path, + success=True, + data={"path": path}, + status_code=200, + ) + + redfish_conn_mock.run_get.side_effect = run_get + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs( + uris=["/redfish/v1", "/redfish/v1/Systems"], max_workers=2 + ) + ) + assert result.status == ExecutionStatus.OK + assert data is not None + assert len(data.responses) == 2 + assert "/redfish/v1" in data.responses or "redfish/v1" in data.responses + assert any("Systems" in k for k in data.responses) + assert redfish_conn_mock.copy.called diff --git a/test/functional/test_sys_settings_plugin.py b/test/functional/test_sys_settings_plugin.py index 03869184..18fdf59e 100644 --- a/test/functional/test_sys_settings_plugin.py +++ b/test/functional/test_sys_settings_plugin.py @@ -1,110 +1,110 @@ -############################################################################### -# -# 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. -# -############################################################################### -"""Functional tests for SysSettingsPlugin with --plugin-configs.""" - -from pathlib import Path - -import pytest - - -@pytest.fixture -def fixtures_dir(): - """Return path to fixtures directory.""" - return Path(__file__).parent / "fixtures" - - -@pytest.fixture -def sys_settings_config_file(fixtures_dir): - """Return path to SysSettingsPlugin config file.""" - return fixtures_dir / "sys_settings_plugin_config.json" - - -@pytest.fixture -def plugin_config_json(fixtures_dir): - """Return path to fixture plugin_config.json.""" - return fixtures_dir / "plugin_config.json" - - -def test_sys_settings_plugin_with_config_file(run_cli_command, sys_settings_config_file, tmp_path): - """Test SysSettingsPlugin using config file with collection_args and analysis_args.""" - assert sys_settings_config_file.exists(), f"Config file not found: {sys_settings_config_file}" - - log_path = str(tmp_path / "logs_sys_settings") - result = run_cli_command( - ["--log-path", log_path, f"--plugin-configs={sys_settings_config_file}"], check=False - ) - - assert result.returncode in [0, 1, 2] - output = result.stdout + result.stderr - assert len(output) > 0 - assert "SysSettingsPlugin" in output or "syssettings" in output.lower() - - -def test_sys_settings_plugin_with_run_plugins_subcommand(run_cli_command, tmp_path): - """Test SysSettingsPlugin via run-plugins subcommand (no config; collector gets no paths).""" - log_path = str(tmp_path / "logs_sys_settings_subcommand") - result = run_cli_command( - ["--log-path", log_path, "run-plugins", "SysSettingsPlugin"], check=False - ) - - assert result.returncode in [0, 1, 2] - output = result.stdout + result.stderr - assert len(output) > 0 - # Without config, plugin runs with no paths -> NOT_RAN or similar - assert "SysSettings" in output or "sys" in output.lower() - - -def test_sys_settings_plugin_output_contains_plugin_result( - run_cli_command, sys_settings_config_file, tmp_path -): - """On Linux, plugin runs and table shows SysSettingsPlugin with a status.""" - assert sys_settings_config_file.exists() - - log_path = str(tmp_path / "logs_sys_settings_result") - result = run_cli_command( - ["--log-path", log_path, f"--plugin-configs={sys_settings_config_file}"], check=False - ) - - output = result.stdout + result.stderr - # Table or status line should mention the plugin - assert "SysSettingsPlugin" in output - - -def test_sys_settings_plugin_with_plugin_config_json(run_cli_command, plugin_config_json, tmp_path): - """Functional test: run node-scraper with project plugin_config.json (paths + directory_paths + checks).""" - assert plugin_config_json.exists(), f"Config not found: {plugin_config_json}" - - log_path = str(tmp_path / "logs_plugin_config") - result = run_cli_command( - ["--log-path", log_path, f"--plugin-configs={plugin_config_json}"], check=False - ) - - assert result.returncode in [0, 1, 2] - output = result.stdout + result.stderr - assert len(output) > 0 - assert "SysSettingsPlugin" in output - # Config exercises paths + directory_paths (/sys/class/net) + pattern check; collector/analyzer run - assert "Sysfs" in output or "sys" in output.lower() +############################################################################### +# +# 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. +# +############################################################################### +"""Functional tests for SysSettingsPlugin with --plugin-configs.""" + +from pathlib import Path + +import pytest + + +@pytest.fixture +def fixtures_dir(): + """Return path to fixtures directory.""" + return Path(__file__).parent / "fixtures" + + +@pytest.fixture +def sys_settings_config_file(fixtures_dir): + """Return path to SysSettingsPlugin config file.""" + return fixtures_dir / "sys_settings_plugin_config.json" + + +@pytest.fixture +def plugin_config_json(fixtures_dir): + """Return path to fixture plugin_config.json.""" + return fixtures_dir / "plugin_config.json" + + +def test_sys_settings_plugin_with_config_file(run_cli_command, sys_settings_config_file, tmp_path): + """Test SysSettingsPlugin using config file with collection_args and analysis_args.""" + assert sys_settings_config_file.exists(), f"Config file not found: {sys_settings_config_file}" + + log_path = str(tmp_path / "logs_sys_settings") + result = run_cli_command( + ["--log-path", log_path, f"--plugin-configs={sys_settings_config_file}"], check=False + ) + + assert result.returncode in [0, 1, 2] + output = result.stdout + result.stderr + assert len(output) > 0 + assert "SysSettingsPlugin" in output or "syssettings" in output.lower() + + +def test_sys_settings_plugin_with_run_plugins_subcommand(run_cli_command, tmp_path): + """Test SysSettingsPlugin via run-plugins subcommand (no config; collector gets no paths).""" + log_path = str(tmp_path / "logs_sys_settings_subcommand") + result = run_cli_command( + ["--log-path", log_path, "run-plugins", "SysSettingsPlugin"], check=False + ) + + assert result.returncode in [0, 1, 2] + output = result.stdout + result.stderr + assert len(output) > 0 + # Without config, plugin runs with no paths -> NOT_RAN or similar + assert "SysSettings" in output or "sys" in output.lower() + + +def test_sys_settings_plugin_output_contains_plugin_result( + run_cli_command, sys_settings_config_file, tmp_path +): + """On Linux, plugin runs and table shows SysSettingsPlugin with a status.""" + assert sys_settings_config_file.exists() + + log_path = str(tmp_path / "logs_sys_settings_result") + result = run_cli_command( + ["--log-path", log_path, f"--plugin-configs={sys_settings_config_file}"], check=False + ) + + output = result.stdout + result.stderr + # Table or status line should mention the plugin + assert "SysSettingsPlugin" in output + + +def test_sys_settings_plugin_with_plugin_config_json(run_cli_command, plugin_config_json, tmp_path): + """Functional test: run node-scraper with project plugin_config.json (paths + directory_paths + checks).""" + assert plugin_config_json.exists(), f"Config not found: {plugin_config_json}" + + log_path = str(tmp_path / "logs_plugin_config") + result = run_cli_command( + ["--log-path", log_path, f"--plugin-configs={plugin_config_json}"], check=False + ) + + assert result.returncode in [0, 1, 2] + output = result.stdout + result.stderr + assert len(output) > 0 + assert "SysSettingsPlugin" in output + # Config exercises paths + directory_paths (/sys/class/net) + pattern check; collector/analyzer run + assert "Sysfs" in output or "sys" in output.lower() diff --git a/test/unit/cli/test_cli_no_console_stdout.py b/test/unit/cli/test_cli_no_console_stdout.py index afa53d13..c762511d 100644 --- a/test/unit/cli/test_cli_no_console_stdout.py +++ b/test/unit/cli/test_cli_no_console_stdout.py @@ -1,152 +1,152 @@ -############################################################################### -# -# MIT License -# -# 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. -# -############################################################################### - -import io -import json -from contextlib import redirect_stdout -from unittest.mock import MagicMock, patch - -import pytest - -from nodescraper.cli.cli import main - - -def _assert_main_leaves_stdout_empty(argv: list[str]) -> None: - out = io.StringIO() - with redirect_stdout(out): - with pytest.raises(SystemExit) as exc: - main(argv) - assert out.getvalue() == "", f"Unexpected stdout: {out.getvalue()!r}" - code = exc.value.code - if code is None: - code = 0 - assert code in (0, 1), f"Unexpected exit code: {exc.value.code!r}" - - -@pytest.fixture -def no_console_base(tmp_path): - log_base = tmp_path / "logs" - log_base.mkdir(parents=True, exist_ok=True) - return ["--log-path", str(log_base), "--no-console-log", "--log-level", "ERROR"] - - -def test_describe_no_stdout(no_console_base): - _assert_main_leaves_stdout_empty( - no_console_base + ["describe", "plugin", "BiosPlugin"], - ) - - -def test_summary_no_stdout(no_console_base, tmp_path): - search = tmp_path / "search_here" - search.mkdir() - _assert_main_leaves_stdout_empty( - no_console_base + ["summary", "--search-path", str(search)], - ) - - -def test_gen_plugin_config_no_stdout(no_console_base, tmp_path): - out_dir = tmp_path / "cfg_out" - out_dir.mkdir() - _assert_main_leaves_stdout_empty( - no_console_base - + [ - "gen-plugin-config", - "--plugins", - "BiosPlugin", - "--output-path", - str(out_dir), - "--config-name", - "out_config.json", - ], - ) - - -def test_compare_runs_no_stdout(no_console_base, tmp_path): - d1 = tmp_path / "run_a" - d2 = tmp_path / "run_b" - d1.mkdir() - d2.mkdir() - _assert_main_leaves_stdout_empty( - no_console_base + ["compare-runs", str(d1), str(d2)], - ) - - -def test_run_plugins_empty_config_no_stdout(no_console_base, tmp_path): - cfg = tmp_path / "empty_plugins.json" - cfg.write_text( - json.dumps( - { - "name": "empty", - "desc": "", - "plugins": {}, - "global_args": {}, - "result_collators": {}, - } - ), - encoding="utf-8", - ) - _assert_main_leaves_stdout_empty( - no_console_base + ["run-plugins", f"--plugin-configs={cfg}"], - ) - - -@patch("nodescraper.cli.cli.get_oem_diagnostic_allowable_values", return_value=["DiagTypeA"]) -@patch("nodescraper.cli.cli.RedfishConnection") -def test_show_redfish_oem_allowable_no_stdout( - mock_conn_cls, - _mock_get_allowable, - no_console_base, - tmp_path, -): - conn_path = tmp_path / "conn.json" - conn_path.write_text( - json.dumps( - { - "RedfishConnectionManager": { - "host": "127.0.0.1", - "username": "u", - "password": "p", - "verify_ssl": False, - } - } - ), - encoding="utf-8", - ) - mock_inst = MagicMock() - mock_conn_cls.return_value = mock_inst - - _assert_main_leaves_stdout_empty( - no_console_base - + [ - "--connection-config", - str(conn_path), - "show-redfish-oem-allowable", - "--log-service-path", - "redfish/v1/Systems/1/LogServices/Logs", - ], - ) - mock_inst._ensure_session.assert_called_once() - mock_inst.close.assert_called_once() +############################################################################### +# +# MIT License +# +# 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. +# +############################################################################### + +import io +import json +from contextlib import redirect_stdout +from unittest.mock import MagicMock, patch + +import pytest + +from nodescraper.cli.cli import main + + +def _assert_main_leaves_stdout_empty(argv: list[str]) -> None: + out = io.StringIO() + with redirect_stdout(out): + with pytest.raises(SystemExit) as exc: + main(argv) + assert out.getvalue() == "", f"Unexpected stdout: {out.getvalue()!r}" + code = exc.value.code + if code is None: + code = 0 + assert code in (0, 1), f"Unexpected exit code: {exc.value.code!r}" + + +@pytest.fixture +def no_console_base(tmp_path): + log_base = tmp_path / "logs" + log_base.mkdir(parents=True, exist_ok=True) + return ["--log-path", str(log_base), "--no-console-log", "--log-level", "ERROR"] + + +def test_describe_no_stdout(no_console_base): + _assert_main_leaves_stdout_empty( + no_console_base + ["describe", "plugin", "BiosPlugin"], + ) + + +def test_summary_no_stdout(no_console_base, tmp_path): + search = tmp_path / "search_here" + search.mkdir() + _assert_main_leaves_stdout_empty( + no_console_base + ["summary", "--search-path", str(search)], + ) + + +def test_gen_plugin_config_no_stdout(no_console_base, tmp_path): + out_dir = tmp_path / "cfg_out" + out_dir.mkdir() + _assert_main_leaves_stdout_empty( + no_console_base + + [ + "gen-plugin-config", + "--plugins", + "BiosPlugin", + "--output-path", + str(out_dir), + "--config-name", + "out_config.json", + ], + ) + + +def test_compare_runs_no_stdout(no_console_base, tmp_path): + d1 = tmp_path / "run_a" + d2 = tmp_path / "run_b" + d1.mkdir() + d2.mkdir() + _assert_main_leaves_stdout_empty( + no_console_base + ["compare-runs", str(d1), str(d2)], + ) + + +def test_run_plugins_empty_config_no_stdout(no_console_base, tmp_path): + cfg = tmp_path / "empty_plugins.json" + cfg.write_text( + json.dumps( + { + "name": "empty", + "desc": "", + "plugins": {}, + "global_args": {}, + "result_collators": {}, + } + ), + encoding="utf-8", + ) + _assert_main_leaves_stdout_empty( + no_console_base + ["run-plugins", f"--plugin-configs={cfg}"], + ) + + +@patch("nodescraper.cli.cli.get_oem_diagnostic_allowable_values", return_value=["DiagTypeA"]) +@patch("nodescraper.cli.cli.RedfishConnection") +def test_show_redfish_oem_allowable_no_stdout( + mock_conn_cls, + _mock_get_allowable, + no_console_base, + tmp_path, +): + conn_path = tmp_path / "conn.json" + conn_path.write_text( + json.dumps( + { + "RedfishConnectionManager": { + "host": "127.0.0.1", + "username": "u", + "password": "p", + "verify_ssl": False, + } + } + ), + encoding="utf-8", + ) + mock_inst = MagicMock() + mock_conn_cls.return_value = mock_inst + + _assert_main_leaves_stdout_empty( + no_console_base + + [ + "--connection-config", + str(conn_path), + "show-redfish-oem-allowable", + "--log-service-path", + "redfish/v1/Systems/1/LogServices/Logs", + ], + ) + mock_inst._ensure_session.assert_called_once() + mock_inst.close.assert_called_once() diff --git a/test/unit/cli/test_plugin_configs_cli.py b/test/unit/cli/test_plugin_configs_cli.py index be6fd383..54c90fe9 100644 --- a/test/unit/cli/test_plugin_configs_cli.py +++ b/test/unit/cli/test_plugin_configs_cli.py @@ -1,33 +1,33 @@ -############################################################################### -# -# MIT License -# -# Copyright (C) 2026 Advanced Micro Devices, Inc. -# -############################################################################### - -from __future__ import annotations - -from nodescraper.cli.cli import build_parser -from nodescraper.configregistry import ConfigRegistry -from nodescraper.pluginrecipe.all_ib_plugins import AllIbPlugins -from nodescraper.pluginrecipe.node_status import NodeStatus -from nodescraper.pluginregistry import PluginRegistry - - -def _parser(): - plugin_reg = PluginRegistry() - config_reg = ConfigRegistry(load_entry_point_configs=False) - for recipe in (NodeStatus, AllIbPlugins): - config_reg.configs[recipe.name()] = recipe.plugin_config() - return build_parser(plugin_reg, config_reg)[0] - - -def test_plugin_configs_equals_form_parses_csv() -> None: - ns = _parser().parse_args(["--plugin-configs=NodeStatus,AllIbPlugins"]) - assert ns.plugin_configs == ["NodeStatus", "AllIbPlugins"] - - -def test_plugin_configs_space_separated_parses() -> None: - ns = _parser().parse_args(["--plugin-configs", "NodeStatus,AllIbPlugins"]) - assert ns.plugin_configs == ["NodeStatus", "AllIbPlugins"] +############################################################################### +# +# MIT License +# +# Copyright (C) 2026 Advanced Micro Devices, Inc. +# +############################################################################### + +from __future__ import annotations + +from nodescraper.cli.cli import build_parser +from nodescraper.configregistry import ConfigRegistry +from nodescraper.pluginrecipe.all_ib_plugins import AllIbPlugins +from nodescraper.pluginrecipe.node_status import NodeStatus +from nodescraper.pluginregistry import PluginRegistry + + +def _parser(): + plugin_reg = PluginRegistry() + config_reg = ConfigRegistry(load_entry_point_configs=False) + for recipe in (NodeStatus, AllIbPlugins): + config_reg.configs[recipe.name()] = recipe.plugin_config() + return build_parser(plugin_reg, config_reg)[0] + + +def test_plugin_configs_equals_form_parses_csv() -> None: + ns = _parser().parse_args(["--plugin-configs=NodeStatus,AllIbPlugins"]) + assert ns.plugin_configs == ["NodeStatus", "AllIbPlugins"] + + +def test_plugin_configs_space_separated_parses() -> None: + ns = _parser().parse_args(["--plugin-configs", "NodeStatus,AllIbPlugins"]) + assert ns.plugin_configs == ["NodeStatus", "AllIbPlugins"] diff --git a/test/unit/connection/redfish/test_redfish_connection_ssl.py b/test/unit/connection/redfish/test_redfish_connection_ssl.py index a4d0c9c4..aac80b2c 100644 --- a/test/unit/connection/redfish/test_redfish_connection_ssl.py +++ b/test/unit/connection/redfish/test_redfish_connection_ssl.py @@ -1,80 +1,80 @@ -############################################################################### -# -# 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 unittest.mock import MagicMock, patch - -import pytest - -from nodescraper.connection.redfish import RedfishConnection - - -def test_verify_ssl_false_disables_trust_env() -> None: - conn = RedfishConnection( - base_url="https://bmc.example", - username="u", - password="p", - verify_ssl=False, - use_session_auth=False, - ) - with patch( - "nodescraper.connection.redfish.redfish_connection.requests.Session" - ) as mock_session_cls: - mock_session = MagicMock() - mock_session_cls.return_value = mock_session - session = conn._ensure_session() - - assert session is mock_session - assert mock_session.verify is False - assert mock_session.trust_env is False - - -@pytest.mark.parametrize("env_var", ["REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"]) -def test_verify_ssl_false_passes_verify_on_login( - env_var: str, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setenv(env_var, "/etc/ssl/certs/ca-bundle.crt") - conn = RedfishConnection( - base_url="https://bmc.example", - username="u", - password="p", - verify_ssl=False, - ) - with patch( - "nodescraper.connection.redfish.redfish_connection.requests.Session" - ) as mock_session_cls: - mock_session = MagicMock() - mock_response = MagicMock() - mock_response.ok = True - mock_response.headers = { - "X-Auth-Token": "token", - "Location": "/redfish/v1/SessionService/Sessions/1", - } - mock_session.post.return_value = mock_response - mock_session_cls.return_value = mock_session - conn._ensure_session() - - mock_session.post.assert_called_once() - assert mock_session.verify is False - assert mock_session.trust_env is False +############################################################################### +# +# 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 unittest.mock import MagicMock, patch + +import pytest + +from nodescraper.connection.redfish import RedfishConnection + + +def test_verify_ssl_false_disables_trust_env() -> None: + conn = RedfishConnection( + base_url="https://bmc.example", + username="u", + password="p", + verify_ssl=False, + use_session_auth=False, + ) + with patch( + "nodescraper.connection.redfish.redfish_connection.requests.Session" + ) as mock_session_cls: + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + session = conn._ensure_session() + + assert session is mock_session + assert mock_session.verify is False + assert mock_session.trust_env is False + + +@pytest.mark.parametrize("env_var", ["REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"]) +def test_verify_ssl_false_passes_verify_on_login( + env_var: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(env_var, "/etc/ssl/certs/ca-bundle.crt") + conn = RedfishConnection( + base_url="https://bmc.example", + username="u", + password="p", + verify_ssl=False, + ) + with patch( + "nodescraper.connection.redfish.redfish_connection.requests.Session" + ) as mock_session_cls: + mock_session = MagicMock() + mock_response = MagicMock() + mock_response.ok = True + mock_response.headers = { + "X-Auth-Token": "token", + "Location": "/redfish/v1/SessionService/Sessions/1", + } + mock_session.post.return_value = mock_response + mock_session_cls.return_value = mock_session + conn._ensure_session() + + mock_session.post.assert_called_once() + assert mock_session.verify is False + assert mock_session.trust_env is False diff --git a/test/unit/connection/redfish/test_redfish_oem_diag.py b/test/unit/connection/redfish/test_redfish_oem_diag.py index 4a92d9a5..8151f6af 100644 --- a/test/unit/connection/redfish/test_redfish_oem_diag.py +++ b/test/unit/connection/redfish/test_redfish_oem_diag.py @@ -1,536 +1,536 @@ -############################################################################### -# -# 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 logging -from unittest.mock import MagicMock, patch - -from requests.status_codes import codes - -from nodescraper.connection.redfish import RedfishConnectionError -from nodescraper.connection.redfish.redfish_oem_diag import ( - DEFAULT_TASK_TIMEOUT_S, - RF_ANNOTATION_ALLOWABLE, - _download_log_and_save, - _entries_collection_path, - _get_task_monitor_uri, - _pick_member_href, - _strip_port_from_url, - _task_resource_path, - collect_oem_diagnostic_data, - get_oem_diagnostic_allowable_values, -) - - -def test_rf_annotation_allowable_constant(): - assert RF_ANNOTATION_ALLOWABLE == "OEMDiagnosticDataType@Redfish.AllowableValues" - - -def test_default_task_timeout_s(): - assert DEFAULT_TASK_TIMEOUT_S == 1800 - - -class TestGetOemDiagnosticAllowableValues: - def test_returns_list_from_collect_action(self): - conn = MagicMock() - conn.get.return_value = { - "Actions": { - "LogService.CollectDiagnosticData": { - "OEMDiagnosticDataType@Redfish.AllowableValues": ["Dmesg", "AllLogs"], - } - } - } - result = get_oem_diagnostic_allowable_values( - conn, "redfish/v1/Systems/1/LogServices/DiagLogs" - ) - assert result == ["Dmesg", "AllLogs"] - - def test_returns_list_from_octothorpe_action_key(self): - conn = MagicMock() - conn.get.return_value = { - "Actions": { - "#LogService.CollectDiagnosticData": { - "OEMDiagnosticDataType@Redfish.AllowableValues": ["JournalControl"], - } - } - } - result = get_oem_diagnostic_allowable_values( - conn, "redfish/v1/Systems/UBB/LogServices/DiagLogs" - ) - assert result == ["JournalControl"] - - def test_returns_none_on_connection_error(self): - conn = MagicMock() - conn.get.side_effect = RedfishConnectionError("fail") - result = get_oem_diagnostic_allowable_values(conn, "redfish/v1/LogServices/DiagLogs") - assert result is None - - def test_returns_none_when_data_not_dict(self): - conn = MagicMock() - conn.get.return_value = [] - result = get_oem_diagnostic_allowable_values(conn, "redfish/v1/LogServices/DiagLogs") - assert result is None - - def test_returns_none_when_no_actions(self): - conn = MagicMock() - conn.get.return_value = {} - result = get_oem_diagnostic_allowable_values(conn, "redfish/v1/LogServices/DiagLogs") - assert result is None - - -class TestStripPortFromUrl: - def test_strips_port_443(self): - url = "https://host:443/redfish/v1/TaskService/Tasks/1" - assert _strip_port_from_url(url) == "https://host/redfish/v1/TaskService/Tasks/1" - - def test_strips_other_port(self): - url = "https://host:8443/redfish/v1" - assert _strip_port_from_url(url) == "https://host/redfish/v1" - - def test_returns_none_when_no_port(self): - url = "https://host/redfish/v1" - assert _strip_port_from_url(url) is None - - def test_returns_none_for_relative_path(self): - assert _strip_port_from_url("redfish/v1/Systems/1") is None - - -class TestGetTaskMonitorUri: - def test_returns_task_monitor_from_body(self): - conn = MagicMock() - conn.base_url = "https://host/redfish/v1" - body = {"TaskMonitor": "TaskService/Tasks/1/Monitor"} - result = _get_task_monitor_uri(body, conn) - assert result == "https://host/redfish/v1/TaskService/Tasks/1/Monitor" - - def test_returns_from_odata_id_plus_monitor(self): - conn = MagicMock() - conn.base_url = "https://host/redfish/v1" - body = {"@odata.id": "TaskService/Tasks/1"} - result = _get_task_monitor_uri(body, conn) - assert result == "https://host/redfish/v1/TaskService/Tasks/1/Monitor" - - def test_returns_none_for_empty_body(self): - conn = MagicMock() - assert _get_task_monitor_uri({}, conn) is None - - def test_prefers_task_monitor_over_odata_id(self): - conn = MagicMock() - conn.base_url = "https://host/redfish/v1" - body = { - "TaskMonitor": "TaskService/Tasks/1/Monitor", - "@odata.id": "TaskService/Tasks/2", - } - result = _get_task_monitor_uri(body, conn) - assert result == "https://host/redfish/v1/TaskService/Tasks/1/Monitor" - - -class TestDownloadLogAndSave: - def test_returns_none_when_no_additional_data_uri(self): - conn = MagicMock() - log_entry_json = {"Id": "1", "Name": "LogEntry"} - result = _download_log_and_save( - conn, log_entry_json, "Dmesg", None, logging.getLogger("test") - ) - assert result is None - conn.get_response.assert_not_called() - - def test_downloads_and_returns_bytes_when_additional_data_uri_present(self): - conn = MagicMock() - conn.base_url = "https://host/redfish/v1" - resp = MagicMock() - resp.status_code = codes.ok - resp.content = b"log bytes" - conn.get_response.return_value = resp - log_entry_json = {"AdditionalDataURI": "/redfish/v1/LogServices/1/Entries/1/Attachment"} - result = _download_log_and_save( - conn, log_entry_json, "Dmesg", None, logging.getLogger("test") - ) - assert result == b"log bytes" - conn.get_response.assert_called_once() - - def test_writes_archive_and_metadata_to_output_dir(self, tmp_path): - conn = MagicMock() - conn.base_url = "https://host/redfish/v1" - resp = MagicMock() - resp.status_code = codes.ok - resp.content = b"log bytes" - conn.get_response.return_value = resp - log_entry_json = { - "AdditionalDataURI": "/redfish/v1/LogServices/1/Entries/1/Attachment", - "Id": "1", - } - result = _download_log_and_save( - conn, log_entry_json, "AllLogs", tmp_path, logging.getLogger("test") - ) - assert result == b"log bytes" - assert (tmp_path / "AllLogs.tar.xz").read_bytes() == b"log bytes" - metadata = (tmp_path / "AllLogs_log_entry.json").read_text(encoding="utf-8") - assert "Id" in metadata and "1" in metadata - - -def test_collect_manager_diagnostic_payload(): - conn = MagicMock() - resp = MagicMock() - resp.status_code = 500 - resp.text = "fail" - conn.post.return_value = resp - collect_oem_diagnostic_data( - conn, - "redfish/v1/Managers/dummy-amc/LogServices/Dump", - diagnostic_data_type="Manager", - ) - payload = conn.post.call_args.kwargs["json"] - assert payload == {"DiagnosticDataType": "Manager"} - - -def test_task_resource_path_accepts_tasks_not_monitors(): - assert ( - _task_resource_path("/redfish/v1/TaskService/Tasks/dummy-1") - == "redfish/v1/TaskService/Tasks/dummy-1" - ) - assert _task_resource_path("redfish/v1/TaskService/TaskMonitors/1") is None - - -def test_collect_polls_task_resource_until_completed(): - conn = MagicMock() - conn.base_url = "https://bmc.example.test" - post_resp = MagicMock() - post_resp.status_code = codes.accepted - post_resp.headers = {"Location": "/redfish/v1/TaskService/TaskMonitors/1"} - post_resp.text = "" - post_resp.json.return_value = { - "@odata.id": "/redfish/v1/TaskService/Tasks/dummy-1", - "TaskState": "Running", - } - conn.post.return_value = post_resp - - running = MagicMock() - running.status_code = codes.ok - running.json.return_value = {"TaskState": "Running"} - done = MagicMock() - done.status_code = codes.ok - done.json.return_value = { - "TaskState": "Completed", - "Payload": { - "HttpHeaders": [ - "Location: /redfish/v1/Systems/dummy-system/LogServices/DiagLogs/Entries/1" - ] - }, - } - entry = MagicMock() - entry.status_code = codes.ok - entry.json.return_value = { - "Id": "1", - "AdditionalDataURI": ( - "/redfish/v1/Systems/dummy-system/LogServices/DiagLogs/Entries/1/attachment" - ), - } - attachment = MagicMock() - attachment.status_code = codes.ok - attachment.content = b"archive" - conn.get_response.side_effect = [running, done, entry, attachment] - - with patch("nodescraper.connection.redfish.redfish_oem_diag.time.sleep"): - log_bytes, metadata, err = collect_oem_diagnostic_data( - conn, - "redfish/v1/Systems/dummy-system/LogServices/DiagLogs", - oem_diagnostic_type="AllLogs", - task_timeout_s=30, - ) - assert err is None - assert log_bytes == b"archive" - assert metadata is not None - assert metadata["Id"] == "1" - polled = [c.args[0] for c in conn.get_response.call_args_list] - assert polled[0] == "redfish/v1/TaskService/Tasks/dummy-1" - assert polled[1] == "redfish/v1/TaskService/Tasks/dummy-1" - - -def test_collect_ssh_failure_during_poll_returns_error(): - conn = MagicMock() - conn.base_url = "https://bmc.example.test" - post_resp = MagicMock() - post_resp.status_code = codes.accepted - post_resp.headers = {"Location": "/redfish/v1/TaskService/TaskMonitors/1"} - post_resp.text = "" - post_resp.json.return_value = { - "@odata.id": "/redfish/v1/TaskService/Tasks/dummy-1", - "TaskState": "Running", - } - conn.post.return_value = post_resp - conn.get_response.side_effect = RedfishConnectionError("Timeout opening channel.") - - _log_bytes, _metadata, err = collect_oem_diagnostic_data( - conn, - "redfish/v1/Systems/dummy-system/LogServices/DiagLogs", - oem_diagnostic_type="AllLogs", - task_timeout_s=30, - ) - assert err == "Timeout opening channel." - - -def test_collect_taskmonitor_404_does_not_spin(): - conn = MagicMock() - conn.base_url = "https://bmc.example.test" - post_resp = MagicMock() - post_resp.status_code = codes.accepted - post_resp.headers = {"Location": "/redfish/v1/TaskService/TaskMonitors/1"} - post_resp.text = "" - post_resp.json.return_value = {} - conn.post.return_value = post_resp - missing = MagicMock() - missing.status_code = codes.not_found - conn.get_response.return_value = missing - - _log_bytes, _metadata, err = collect_oem_diagnostic_data( - conn, - "redfish/v1/Systems/dummy-system/LogServices/DiagLogs", - oem_diagnostic_type="AllLogs", - task_timeout_s=30, - ) - assert err is not None - assert "404" in err - assert conn.get_response.call_count == 1 - - -class TestCollectOemDiagnosticDataRetryAfter: - @staticmethod - def _conn_for_retry_after(retry_after: str) -> MagicMock: - conn = MagicMock() - conn.base_url = "https://host" - post_resp = MagicMock() - post_resp.status_code = codes.accepted - post_resp.headers = { - "Location": "/redfish/v1/TaskService/TaskMonitors/1", - "Retry-After": retry_after, - } - post_resp.json.return_value = {} - conn.post.return_value = post_resp - - accepted = MagicMock() - accepted.status_code = codes.accepted - accepted.json.return_value = {} - monitor_resp = MagicMock() - monitor_resp.status_code = codes.ok - monitor_resp.json.return_value = {"@odata.id": "/redfish/v1/TaskService/Tasks/1"} - task_resp = MagicMock() - task_resp.status_code = codes.ok - task_resp.json.return_value = {"TaskState": "Completed", "Payload": {"HttpHeaders": []}} - conn.get_response.side_effect = [accepted, monitor_resp, task_resp] - return conn - - @patch("nodescraper.connection.redfish.redfish_oem_diag.time.sleep") - def test_numeric_retry_after_is_used_as_poll_interval(self, mock_sleep): - """A Retry-After in seconds drives the poll interval after a 202 monitor.""" - conn = self._conn_for_retry_after("3") - - _, _, error = collect_oem_diagnostic_data( - conn, "redfish/v1/Systems/UBB/LogServices/DiagLogs", "AllLogs" - ) - - assert error == "Location header missing in task Payload.HttpHeaders" - mock_sleep.assert_called_with(3) - - @patch("nodescraper.connection.redfish.redfish_oem_diag.time.sleep") - def test_http_date_retry_after_does_not_raise(self, mock_sleep): - """An HTTP-date Retry-After must be handled, not crash the collection.""" - conn = self._conn_for_retry_after("Fri, 31 Dec 1999 23:59:59 GMT") - - _, _, error = collect_oem_diagnostic_data( - conn, "redfish/v1/Systems/UBB/LogServices/DiagLogs", "AllLogs" - ) - - assert error == "Location header missing in task Payload.HttpHeaders" - mock_sleep.assert_called_with(1) - - -def test_entries_collection_path_from_dump_entry(): - assert ( - _entries_collection_path("redfish/v1/Managers/AMC/LogServices/Dump/Entries/29") - == "redfish/v1/Managers/AMC/LogServices/Dump/Entries" - ) - - -def test_pick_member_href_prefers_wanted_id(): - body = { - "Members": [ - {"@odata.id": "/redfish/v1/Managers/AMC/LogServices/Dump/Entries/28"}, - {"@odata.id": "/redfish/v1/Managers/AMC/LogServices/Dump/Entries/29"}, - ] - } - assert _pick_member_href(body, "29") == "/redfish/v1/Managers/AMC/LogServices/Dump/Entries/29" - assert _pick_member_href(body, "31") is None - assert _pick_member_href({"Members": []}, "29") is None - - -def _dump_collect_conn(get_side_effect): - conn = MagicMock() - conn.base_url = "http://192.0.2.1" - post_resp = MagicMock() - post_resp.status_code = codes.accepted - post_resp.headers = {"Location": "/redfish/v1/TaskService/TaskMonitors/1"} - post_resp.text = "" - post_resp.json.return_value = { - "@odata.id": "/redfish/v1/TaskService/Tasks/dummy-1", - "TaskState": "Running", - } - conn.post.return_value = post_resp - conn.get_response.side_effect = get_side_effect - return conn - - -def _ok_json(payload): - resp = MagicMock() - resp.status_code = codes.ok - resp.json.return_value = payload - return resp - - -def _status(code): - resp = MagicMock() - resp.status_code = code - resp.json.return_value = {} - return resp - - -@patch("nodescraper.connection.redfish.redfish_oem_diag.time.sleep") -def test_collect_retries_log_entry_404(mock_sleep): - """Dump LogEntry 404s are retried before failing.""" - running = _ok_json({"TaskState": "Running"}) - done = _ok_json( - { - "TaskState": "Completed", - "Payload": { - "HttpHeaders": ["Location: /redfish/v1/Managers/AMC/LogServices/Dump/Entries/29"] - }, - } - ) - entry = _ok_json( - { - "Id": "29", - "AdditionalDataURI": "/redfish/v1/Managers/AMC/LogServices/Dump/Entries/29/attachment", - } - ) - attachment = MagicMock() - attachment.status_code = codes.ok - attachment.content = b"mgr-dump" - conn = _dump_collect_conn([running, done, _status(codes.not_found), entry, attachment]) - - log_bytes, metadata, err = collect_oem_diagnostic_data( - conn, - "redfish/v1/Managers/AMC/LogServices/Dump", - diagnostic_data_type="Manager", - task_timeout_s=30, - ) - - assert err is None - assert log_bytes == b"mgr-dump" - assert metadata["Id"] == "29" - mock_sleep.assert_called() - - -@patch("nodescraper.connection.redfish.redfish_oem_diag.time.sleep") -def test_collect_log_entry_falls_back_to_entries_collection(mock_sleep): - """After LogEntry 404s, list Dump/Entries and GET the matching member.""" - running = _ok_json({"TaskState": "Running"}) - done = _ok_json( - { - "TaskState": "Completed", - "Payload": { - "HttpHeaders": ["Location: /redfish/v1/Managers/AMC/LogServices/Dump/Entries/29"] - }, - } - ) - missing = _status(codes.not_found) - coll = _ok_json( - { - "Members": [ - {"@odata.id": "/redfish/v1/Managers/AMC/LogServices/Dump/Entries/28"}, - {"@odata.id": "/redfish/v1/Managers/AMC/LogServices/Dump/Entries/29"}, - ] - } - ) - member = _ok_json( - { - "Id": "29", - "AdditionalDataURI": "/redfish/v1/Managers/AMC/LogServices/Dump/Entries/29/attachment", - } - ) - attachment = MagicMock() - attachment.status_code = codes.ok - attachment.content = b"mgr-dump" - conn = _dump_collect_conn( - [running, done, missing, missing, missing, missing, coll, member, attachment] - ) - - log_bytes, metadata, err = collect_oem_diagnostic_data( - conn, - "redfish/v1/Managers/AMC/LogServices/Dump", - diagnostic_data_type="Manager", - task_timeout_s=30, - ) - - assert err is None - assert log_bytes == b"mgr-dump" - assert metadata["Id"] == "29" - listed = [c.args[0] for c in conn.get_response.call_args_list] - assert "redfish/v1/Managers/AMC/LogServices/Dump/Entries" in listed - - -@patch("nodescraper.connection.redfish.redfish_oem_diag.time.sleep") -def test_collect_log_entry_collection_miss_keeps_entry_status(mock_sleep): - """Collection 200 without the requested Id must not report status 200.""" - running = _ok_json({"TaskState": "Running"}) - done = _ok_json( - { - "TaskState": "Completed", - "Payload": { - "HttpHeaders": ["Location: /redfish/v1/Managers/AMC/LogServices/Dump/Entries/31"] - }, - } - ) - missing = _status(codes.not_found) - coll = _ok_json( - { - "Members": [ - {"@odata.id": "/redfish/v1/Managers/AMC/LogServices/Dump/Entries/30"}, - ] - } - ) - conn = _dump_collect_conn([running, done, missing, missing, missing, missing, coll]) - - log_bytes, metadata, err = collect_oem_diagnostic_data( - conn, - "redfish/v1/Managers/AMC/LogServices/Dump", - diagnostic_data_type="Manager", - task_timeout_s=30, - ) - - assert log_bytes is None - assert metadata is None - assert err is not None - assert "status 200" not in err - assert "status 404" in err - assert "Id 31 not in redfish/v1/Managers/AMC/LogServices/Dump/Entries" in err +############################################################################### +# +# 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 logging +from unittest.mock import MagicMock, patch + +from requests.status_codes import codes + +from nodescraper.connection.redfish import RedfishConnectionError +from nodescraper.connection.redfish.redfish_oem_diag import ( + DEFAULT_TASK_TIMEOUT_S, + RF_ANNOTATION_ALLOWABLE, + _download_log_and_save, + _entries_collection_path, + _get_task_monitor_uri, + _pick_member_href, + _strip_port_from_url, + _task_resource_path, + collect_oem_diagnostic_data, + get_oem_diagnostic_allowable_values, +) + + +def test_rf_annotation_allowable_constant(): + assert RF_ANNOTATION_ALLOWABLE == "OEMDiagnosticDataType@Redfish.AllowableValues" + + +def test_default_task_timeout_s(): + assert DEFAULT_TASK_TIMEOUT_S == 1800 + + +class TestGetOemDiagnosticAllowableValues: + def test_returns_list_from_collect_action(self): + conn = MagicMock() + conn.get.return_value = { + "Actions": { + "LogService.CollectDiagnosticData": { + "OEMDiagnosticDataType@Redfish.AllowableValues": ["Dmesg", "AllLogs"], + } + } + } + result = get_oem_diagnostic_allowable_values( + conn, "redfish/v1/Systems/1/LogServices/DiagLogs" + ) + assert result == ["Dmesg", "AllLogs"] + + def test_returns_list_from_octothorpe_action_key(self): + conn = MagicMock() + conn.get.return_value = { + "Actions": { + "#LogService.CollectDiagnosticData": { + "OEMDiagnosticDataType@Redfish.AllowableValues": ["JournalControl"], + } + } + } + result = get_oem_diagnostic_allowable_values( + conn, "redfish/v1/Systems/UBB/LogServices/DiagLogs" + ) + assert result == ["JournalControl"] + + def test_returns_none_on_connection_error(self): + conn = MagicMock() + conn.get.side_effect = RedfishConnectionError("fail") + result = get_oem_diagnostic_allowable_values(conn, "redfish/v1/LogServices/DiagLogs") + assert result is None + + def test_returns_none_when_data_not_dict(self): + conn = MagicMock() + conn.get.return_value = [] + result = get_oem_diagnostic_allowable_values(conn, "redfish/v1/LogServices/DiagLogs") + assert result is None + + def test_returns_none_when_no_actions(self): + conn = MagicMock() + conn.get.return_value = {} + result = get_oem_diagnostic_allowable_values(conn, "redfish/v1/LogServices/DiagLogs") + assert result is None + + +class TestStripPortFromUrl: + def test_strips_port_443(self): + url = "https://host:443/redfish/v1/TaskService/Tasks/1" + assert _strip_port_from_url(url) == "https://host/redfish/v1/TaskService/Tasks/1" + + def test_strips_other_port(self): + url = "https://host:8443/redfish/v1" + assert _strip_port_from_url(url) == "https://host/redfish/v1" + + def test_returns_none_when_no_port(self): + url = "https://host/redfish/v1" + assert _strip_port_from_url(url) is None + + def test_returns_none_for_relative_path(self): + assert _strip_port_from_url("redfish/v1/Systems/1") is None + + +class TestGetTaskMonitorUri: + def test_returns_task_monitor_from_body(self): + conn = MagicMock() + conn.base_url = "https://host/redfish/v1" + body = {"TaskMonitor": "TaskService/Tasks/1/Monitor"} + result = _get_task_monitor_uri(body, conn) + assert result == "https://host/redfish/v1/TaskService/Tasks/1/Monitor" + + def test_returns_from_odata_id_plus_monitor(self): + conn = MagicMock() + conn.base_url = "https://host/redfish/v1" + body = {"@odata.id": "TaskService/Tasks/1"} + result = _get_task_monitor_uri(body, conn) + assert result == "https://host/redfish/v1/TaskService/Tasks/1/Monitor" + + def test_returns_none_for_empty_body(self): + conn = MagicMock() + assert _get_task_monitor_uri({}, conn) is None + + def test_prefers_task_monitor_over_odata_id(self): + conn = MagicMock() + conn.base_url = "https://host/redfish/v1" + body = { + "TaskMonitor": "TaskService/Tasks/1/Monitor", + "@odata.id": "TaskService/Tasks/2", + } + result = _get_task_monitor_uri(body, conn) + assert result == "https://host/redfish/v1/TaskService/Tasks/1/Monitor" + + +class TestDownloadLogAndSave: + def test_returns_none_when_no_additional_data_uri(self): + conn = MagicMock() + log_entry_json = {"Id": "1", "Name": "LogEntry"} + result = _download_log_and_save( + conn, log_entry_json, "Dmesg", None, logging.getLogger("test") + ) + assert result is None + conn.get_response.assert_not_called() + + def test_downloads_and_returns_bytes_when_additional_data_uri_present(self): + conn = MagicMock() + conn.base_url = "https://host/redfish/v1" + resp = MagicMock() + resp.status_code = codes.ok + resp.content = b"log bytes" + conn.get_response.return_value = resp + log_entry_json = {"AdditionalDataURI": "/redfish/v1/LogServices/1/Entries/1/Attachment"} + result = _download_log_and_save( + conn, log_entry_json, "Dmesg", None, logging.getLogger("test") + ) + assert result == b"log bytes" + conn.get_response.assert_called_once() + + def test_writes_archive_and_metadata_to_output_dir(self, tmp_path): + conn = MagicMock() + conn.base_url = "https://host/redfish/v1" + resp = MagicMock() + resp.status_code = codes.ok + resp.content = b"log bytes" + conn.get_response.return_value = resp + log_entry_json = { + "AdditionalDataURI": "/redfish/v1/LogServices/1/Entries/1/Attachment", + "Id": "1", + } + result = _download_log_and_save( + conn, log_entry_json, "AllLogs", tmp_path, logging.getLogger("test") + ) + assert result == b"log bytes" + assert (tmp_path / "AllLogs.tar.xz").read_bytes() == b"log bytes" + metadata = (tmp_path / "AllLogs_log_entry.json").read_text(encoding="utf-8") + assert "Id" in metadata and "1" in metadata + + +def test_collect_manager_diagnostic_payload(): + conn = MagicMock() + resp = MagicMock() + resp.status_code = 500 + resp.text = "fail" + conn.post.return_value = resp + collect_oem_diagnostic_data( + conn, + "redfish/v1/Managers/dummy-amc/LogServices/Dump", + diagnostic_data_type="Manager", + ) + payload = conn.post.call_args.kwargs["json"] + assert payload == {"DiagnosticDataType": "Manager"} + + +def test_task_resource_path_accepts_tasks_not_monitors(): + assert ( + _task_resource_path("/redfish/v1/TaskService/Tasks/dummy-1") + == "redfish/v1/TaskService/Tasks/dummy-1" + ) + assert _task_resource_path("redfish/v1/TaskService/TaskMonitors/1") is None + + +def test_collect_polls_task_resource_until_completed(): + conn = MagicMock() + conn.base_url = "https://bmc.example.test" + post_resp = MagicMock() + post_resp.status_code = codes.accepted + post_resp.headers = {"Location": "/redfish/v1/TaskService/TaskMonitors/1"} + post_resp.text = "" + post_resp.json.return_value = { + "@odata.id": "/redfish/v1/TaskService/Tasks/dummy-1", + "TaskState": "Running", + } + conn.post.return_value = post_resp + + running = MagicMock() + running.status_code = codes.ok + running.json.return_value = {"TaskState": "Running"} + done = MagicMock() + done.status_code = codes.ok + done.json.return_value = { + "TaskState": "Completed", + "Payload": { + "HttpHeaders": [ + "Location: /redfish/v1/Systems/dummy-system/LogServices/DiagLogs/Entries/1" + ] + }, + } + entry = MagicMock() + entry.status_code = codes.ok + entry.json.return_value = { + "Id": "1", + "AdditionalDataURI": ( + "/redfish/v1/Systems/dummy-system/LogServices/DiagLogs/Entries/1/attachment" + ), + } + attachment = MagicMock() + attachment.status_code = codes.ok + attachment.content = b"archive" + conn.get_response.side_effect = [running, done, entry, attachment] + + with patch("nodescraper.connection.redfish.redfish_oem_diag.time.sleep"): + log_bytes, metadata, err = collect_oem_diagnostic_data( + conn, + "redfish/v1/Systems/dummy-system/LogServices/DiagLogs", + oem_diagnostic_type="AllLogs", + task_timeout_s=30, + ) + assert err is None + assert log_bytes == b"archive" + assert metadata is not None + assert metadata["Id"] == "1" + polled = [c.args[0] for c in conn.get_response.call_args_list] + assert polled[0] == "redfish/v1/TaskService/Tasks/dummy-1" + assert polled[1] == "redfish/v1/TaskService/Tasks/dummy-1" + + +def test_collect_ssh_failure_during_poll_returns_error(): + conn = MagicMock() + conn.base_url = "https://bmc.example.test" + post_resp = MagicMock() + post_resp.status_code = codes.accepted + post_resp.headers = {"Location": "/redfish/v1/TaskService/TaskMonitors/1"} + post_resp.text = "" + post_resp.json.return_value = { + "@odata.id": "/redfish/v1/TaskService/Tasks/dummy-1", + "TaskState": "Running", + } + conn.post.return_value = post_resp + conn.get_response.side_effect = RedfishConnectionError("Timeout opening channel.") + + _log_bytes, _metadata, err = collect_oem_diagnostic_data( + conn, + "redfish/v1/Systems/dummy-system/LogServices/DiagLogs", + oem_diagnostic_type="AllLogs", + task_timeout_s=30, + ) + assert err == "Timeout opening channel." + + +def test_collect_taskmonitor_404_does_not_spin(): + conn = MagicMock() + conn.base_url = "https://bmc.example.test" + post_resp = MagicMock() + post_resp.status_code = codes.accepted + post_resp.headers = {"Location": "/redfish/v1/TaskService/TaskMonitors/1"} + post_resp.text = "" + post_resp.json.return_value = {} + conn.post.return_value = post_resp + missing = MagicMock() + missing.status_code = codes.not_found + conn.get_response.return_value = missing + + _log_bytes, _metadata, err = collect_oem_diagnostic_data( + conn, + "redfish/v1/Systems/dummy-system/LogServices/DiagLogs", + oem_diagnostic_type="AllLogs", + task_timeout_s=30, + ) + assert err is not None + assert "404" in err + assert conn.get_response.call_count == 1 + + +class TestCollectOemDiagnosticDataRetryAfter: + @staticmethod + def _conn_for_retry_after(retry_after: str) -> MagicMock: + conn = MagicMock() + conn.base_url = "https://host" + post_resp = MagicMock() + post_resp.status_code = codes.accepted + post_resp.headers = { + "Location": "/redfish/v1/TaskService/TaskMonitors/1", + "Retry-After": retry_after, + } + post_resp.json.return_value = {} + conn.post.return_value = post_resp + + accepted = MagicMock() + accepted.status_code = codes.accepted + accepted.json.return_value = {} + monitor_resp = MagicMock() + monitor_resp.status_code = codes.ok + monitor_resp.json.return_value = {"@odata.id": "/redfish/v1/TaskService/Tasks/1"} + task_resp = MagicMock() + task_resp.status_code = codes.ok + task_resp.json.return_value = {"TaskState": "Completed", "Payload": {"HttpHeaders": []}} + conn.get_response.side_effect = [accepted, monitor_resp, task_resp] + return conn + + @patch("nodescraper.connection.redfish.redfish_oem_diag.time.sleep") + def test_numeric_retry_after_is_used_as_poll_interval(self, mock_sleep): + """A Retry-After in seconds drives the poll interval after a 202 monitor.""" + conn = self._conn_for_retry_after("3") + + _, _, error = collect_oem_diagnostic_data( + conn, "redfish/v1/Systems/UBB/LogServices/DiagLogs", "AllLogs" + ) + + assert error == "Location header missing in task Payload.HttpHeaders" + mock_sleep.assert_called_with(3) + + @patch("nodescraper.connection.redfish.redfish_oem_diag.time.sleep") + def test_http_date_retry_after_does_not_raise(self, mock_sleep): + """An HTTP-date Retry-After must be handled, not crash the collection.""" + conn = self._conn_for_retry_after("Fri, 31 Dec 1999 23:59:59 GMT") + + _, _, error = collect_oem_diagnostic_data( + conn, "redfish/v1/Systems/UBB/LogServices/DiagLogs", "AllLogs" + ) + + assert error == "Location header missing in task Payload.HttpHeaders" + mock_sleep.assert_called_with(1) + + +def test_entries_collection_path_from_dump_entry(): + assert ( + _entries_collection_path("redfish/v1/Managers/AMC/LogServices/Dump/Entries/29") + == "redfish/v1/Managers/AMC/LogServices/Dump/Entries" + ) + + +def test_pick_member_href_prefers_wanted_id(): + body = { + "Members": [ + {"@odata.id": "/redfish/v1/Managers/AMC/LogServices/Dump/Entries/28"}, + {"@odata.id": "/redfish/v1/Managers/AMC/LogServices/Dump/Entries/29"}, + ] + } + assert _pick_member_href(body, "29") == "/redfish/v1/Managers/AMC/LogServices/Dump/Entries/29" + assert _pick_member_href(body, "31") is None + assert _pick_member_href({"Members": []}, "29") is None + + +def _dump_collect_conn(get_side_effect): + conn = MagicMock() + conn.base_url = "http://192.0.2.1" + post_resp = MagicMock() + post_resp.status_code = codes.accepted + post_resp.headers = {"Location": "/redfish/v1/TaskService/TaskMonitors/1"} + post_resp.text = "" + post_resp.json.return_value = { + "@odata.id": "/redfish/v1/TaskService/Tasks/dummy-1", + "TaskState": "Running", + } + conn.post.return_value = post_resp + conn.get_response.side_effect = get_side_effect + return conn + + +def _ok_json(payload): + resp = MagicMock() + resp.status_code = codes.ok + resp.json.return_value = payload + return resp + + +def _status(code): + resp = MagicMock() + resp.status_code = code + resp.json.return_value = {} + return resp + + +@patch("nodescraper.connection.redfish.redfish_oem_diag.time.sleep") +def test_collect_retries_log_entry_404(mock_sleep): + """Dump LogEntry 404s are retried before failing.""" + running = _ok_json({"TaskState": "Running"}) + done = _ok_json( + { + "TaskState": "Completed", + "Payload": { + "HttpHeaders": ["Location: /redfish/v1/Managers/AMC/LogServices/Dump/Entries/29"] + }, + } + ) + entry = _ok_json( + { + "Id": "29", + "AdditionalDataURI": "/redfish/v1/Managers/AMC/LogServices/Dump/Entries/29/attachment", + } + ) + attachment = MagicMock() + attachment.status_code = codes.ok + attachment.content = b"mgr-dump" + conn = _dump_collect_conn([running, done, _status(codes.not_found), entry, attachment]) + + log_bytes, metadata, err = collect_oem_diagnostic_data( + conn, + "redfish/v1/Managers/AMC/LogServices/Dump", + diagnostic_data_type="Manager", + task_timeout_s=30, + ) + + assert err is None + assert log_bytes == b"mgr-dump" + assert metadata["Id"] == "29" + mock_sleep.assert_called() + + +@patch("nodescraper.connection.redfish.redfish_oem_diag.time.sleep") +def test_collect_log_entry_falls_back_to_entries_collection(mock_sleep): + """After LogEntry 404s, list Dump/Entries and GET the matching member.""" + running = _ok_json({"TaskState": "Running"}) + done = _ok_json( + { + "TaskState": "Completed", + "Payload": { + "HttpHeaders": ["Location: /redfish/v1/Managers/AMC/LogServices/Dump/Entries/29"] + }, + } + ) + missing = _status(codes.not_found) + coll = _ok_json( + { + "Members": [ + {"@odata.id": "/redfish/v1/Managers/AMC/LogServices/Dump/Entries/28"}, + {"@odata.id": "/redfish/v1/Managers/AMC/LogServices/Dump/Entries/29"}, + ] + } + ) + member = _ok_json( + { + "Id": "29", + "AdditionalDataURI": "/redfish/v1/Managers/AMC/LogServices/Dump/Entries/29/attachment", + } + ) + attachment = MagicMock() + attachment.status_code = codes.ok + attachment.content = b"mgr-dump" + conn = _dump_collect_conn( + [running, done, missing, missing, missing, missing, coll, member, attachment] + ) + + log_bytes, metadata, err = collect_oem_diagnostic_data( + conn, + "redfish/v1/Managers/AMC/LogServices/Dump", + diagnostic_data_type="Manager", + task_timeout_s=30, + ) + + assert err is None + assert log_bytes == b"mgr-dump" + assert metadata["Id"] == "29" + listed = [c.args[0] for c in conn.get_response.call_args_list] + assert "redfish/v1/Managers/AMC/LogServices/Dump/Entries" in listed + + +@patch("nodescraper.connection.redfish.redfish_oem_diag.time.sleep") +def test_collect_log_entry_collection_miss_keeps_entry_status(mock_sleep): + """Collection 200 without the requested Id must not report status 200.""" + running = _ok_json({"TaskState": "Running"}) + done = _ok_json( + { + "TaskState": "Completed", + "Payload": { + "HttpHeaders": ["Location: /redfish/v1/Managers/AMC/LogServices/Dump/Entries/31"] + }, + } + ) + missing = _status(codes.not_found) + coll = _ok_json( + { + "Members": [ + {"@odata.id": "/redfish/v1/Managers/AMC/LogServices/Dump/Entries/30"}, + ] + } + ) + conn = _dump_collect_conn([running, done, missing, missing, missing, missing, coll]) + + log_bytes, metadata, err = collect_oem_diagnostic_data( + conn, + "redfish/v1/Managers/AMC/LogServices/Dump", + diagnostic_data_type="Manager", + task_timeout_s=30, + ) + + assert log_bytes is None + assert metadata is None + assert err is not None + assert "status 200" not in err + assert "status 404" in err + assert "Id 31 not in redfish/v1/Managers/AMC/LogServices/Dump/Entries" in err diff --git a/test/unit/connection/redfish/test_redfish_transport_errors.py b/test/unit/connection/redfish/test_redfish_transport_errors.py index 56d0b152..df464660 100644 --- a/test/unit/connection/redfish/test_redfish_transport_errors.py +++ b/test/unit/connection/redfish/test_redfish_transport_errors.py @@ -1,118 +1,118 @@ -############################################################################### -# -# 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 socket -from unittest.mock import MagicMock, patch - -import pytest -import requests -from pydantic import SecretStr - -from nodescraper.connection.redfish import RedfishConnection, RedfishConnectionError -from nodescraper.connection.redfish.redfish_manager import RedfishConnectionManager -from nodescraper.connection.redfish.redfish_params import RedfishConnectionParams -from nodescraper.enums import ExecutionStatus, SystemLocation -from nodescraper.models import SystemInfo - -_TEST_HOST = "10.6.189.112" - - -@pytest.fixture -def rf_conn() -> RedfishConnection: - return RedfishConnection( - base_url=f"https://{_TEST_HOST}", - username="user", - password="pass", - use_session_auth=False, - ) - - -def test_get_response_wraps_connect_timeout(rf_conn: RedfishConnection) -> None: - with patch.object(rf_conn, "_ensure_session") as ensure_session: - session = MagicMock() - session.get.side_effect = requests.exceptions.ConnectTimeout("timed out") - ensure_session.return_value = session - - with pytest.raises(RedfishConnectionError, match="timed out"): - rf_conn.get_response("redfish/v1") - - -def test_get_response_wraps_connection_refused(rf_conn: RedfishConnection) -> None: - root = ConnectionRefusedError(111, "Connection refused") - wrapped = requests.exceptions.ConnectionError("failed", response=None) - wrapped.__cause__ = root - - with patch.object(rf_conn, "_ensure_session") as ensure_session: - session = MagicMock() - session.get.side_effect = wrapped - ensure_session.return_value = session - - with pytest.raises(RedfishConnectionError, match="connection failed"): - rf_conn.get_response("redfish/v1") - - -def test_get_response_wraps_name_resolution_error(rf_conn: RedfishConnection) -> None: - dns_error = requests.exceptions.ConnectionError( - f"HTTPSConnectionPool(host='{_TEST_HOST}', port=443): " - f"Failed to resolve '{_TEST_HOST}' " - "([Errno -5] No address associated with hostname)" - ) - dns_error.__cause__ = socket.gaierror(-5, "No address associated with hostname") - - with patch.object(rf_conn, "_ensure_session") as ensure_session: - session = MagicMock() - session.get.side_effect = dns_error - ensure_session.return_value = session - - with pytest.raises(RedfishConnectionError, match="hostname could not be resolved"): - rf_conn.get_response("redfish/v1") - - -def test_redfish_manager_connect_logs_clean_timeout() -> None: - manager = RedfishConnectionManager( - system_info=SystemInfo(name="test", location=SystemLocation.LOCAL), - connection_args=RedfishConnectionParams( - host=_TEST_HOST, - username="user", - password=SecretStr("pass"), - use_https=True, - use_session_auth=False, - ), - ) - with patch( - "nodescraper.connection.redfish.redfish_manager.RedfishConnection" - ) as connection_cls: - connection = connection_cls.return_value - connection.get_service_root.side_effect = RedfishConnectionError( - f"Redfish connection timed out: {_TEST_HOST}" - ) - - result = manager.connect() - - assert result.status == ExecutionStatus.EXECUTION_FAILURE - assert len(result.events) == 1 - assert "timed out" in result.events[0].description - assert "traceback" not in result.events[0].data - assert "exception_type" not in result.events[0].data +############################################################################### +# +# 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 socket +from unittest.mock import MagicMock, patch + +import pytest +import requests +from pydantic import SecretStr + +from nodescraper.connection.redfish import RedfishConnection, RedfishConnectionError +from nodescraper.connection.redfish.redfish_manager import RedfishConnectionManager +from nodescraper.connection.redfish.redfish_params import RedfishConnectionParams +from nodescraper.enums import ExecutionStatus, SystemLocation +from nodescraper.models import SystemInfo + +_TEST_HOST = "10.6.189.112" + + +@pytest.fixture +def rf_conn() -> RedfishConnection: + return RedfishConnection( + base_url=f"https://{_TEST_HOST}", + username="user", + password="pass", + use_session_auth=False, + ) + + +def test_get_response_wraps_connect_timeout(rf_conn: RedfishConnection) -> None: + with patch.object(rf_conn, "_ensure_session") as ensure_session: + session = MagicMock() + session.get.side_effect = requests.exceptions.ConnectTimeout("timed out") + ensure_session.return_value = session + + with pytest.raises(RedfishConnectionError, match="timed out"): + rf_conn.get_response("redfish/v1") + + +def test_get_response_wraps_connection_refused(rf_conn: RedfishConnection) -> None: + root = ConnectionRefusedError(111, "Connection refused") + wrapped = requests.exceptions.ConnectionError("failed", response=None) + wrapped.__cause__ = root + + with patch.object(rf_conn, "_ensure_session") as ensure_session: + session = MagicMock() + session.get.side_effect = wrapped + ensure_session.return_value = session + + with pytest.raises(RedfishConnectionError, match="connection failed"): + rf_conn.get_response("redfish/v1") + + +def test_get_response_wraps_name_resolution_error(rf_conn: RedfishConnection) -> None: + dns_error = requests.exceptions.ConnectionError( + f"HTTPSConnectionPool(host='{_TEST_HOST}', port=443): " + f"Failed to resolve '{_TEST_HOST}' " + "([Errno -5] No address associated with hostname)" + ) + dns_error.__cause__ = socket.gaierror(-5, "No address associated with hostname") + + with patch.object(rf_conn, "_ensure_session") as ensure_session: + session = MagicMock() + session.get.side_effect = dns_error + ensure_session.return_value = session + + with pytest.raises(RedfishConnectionError, match="hostname could not be resolved"): + rf_conn.get_response("redfish/v1") + + +def test_redfish_manager_connect_logs_clean_timeout() -> None: + manager = RedfishConnectionManager( + system_info=SystemInfo(name="test", location=SystemLocation.LOCAL), + connection_args=RedfishConnectionParams( + host=_TEST_HOST, + username="user", + password=SecretStr("pass"), + use_https=True, + use_session_auth=False, + ), + ) + with patch( + "nodescraper.connection.redfish.redfish_manager.RedfishConnection" + ) as connection_cls: + connection = connection_cls.return_value + connection.get_service_root.side_effect = RedfishConnectionError( + f"Redfish connection timed out: {_TEST_HOST}" + ) + + result = manager.connect() + + assert result.status == ExecutionStatus.EXECUTION_FAILURE + assert len(result.events) == 1 + assert "timed out" in result.events[0].description + assert "traceback" not in result.events[0].data + assert "exception_type" not in result.events[0].data diff --git a/test/unit/framework/fixtures/compare_runs_sysctl/run1/sysctl_plugin/sysctl_collector/result.json b/test/unit/framework/fixtures/compare_runs_sysctl/run1/sysctl_plugin/sysctl_collector/result.json index 042a8d56..e2f6f3f1 100644 --- a/test/unit/framework/fixtures/compare_runs_sysctl/run1/sysctl_plugin/sysctl_collector/result.json +++ b/test/unit/framework/fixtures/compare_runs_sysctl/run1/sysctl_plugin/sysctl_collector/result.json @@ -1,8 +1,8 @@ -{ - "status": "OK", - "message": "SYSCTL data collected", - "task": "SysctlCollector", - "parent": "SysctlPlugin", - "start_time": "2026-02-10T18:00:00.000000", - "end_time": "2026-02-10T18:00:01.000000" -} +{ + "status": "OK", + "message": "SYSCTL data collected", + "task": "SysctlCollector", + "parent": "SysctlPlugin", + "start_time": "2026-02-10T18:00:00.000000", + "end_time": "2026-02-10T18:00:01.000000" +} diff --git a/test/unit/framework/fixtures/compare_runs_sysctl/run1/sysctl_plugin/sysctl_collector/sysctldatamodel.json b/test/unit/framework/fixtures/compare_runs_sysctl/run1/sysctl_plugin/sysctl_collector/sysctldatamodel.json index bd25b557..0780005f 100644 --- a/test/unit/framework/fixtures/compare_runs_sysctl/run1/sysctl_plugin/sysctl_collector/sysctldatamodel.json +++ b/test/unit/framework/fixtures/compare_runs_sysctl/run1/sysctl_plugin/sysctl_collector/sysctldatamodel.json @@ -1,13 +1,13 @@ -{ - "vm_swappiness": 60, - "vm_numa_balancing": 0, - "vm_oom_kill_allocating_task": 0, - "vm_compaction_proactiveness": 0, - "vm_compact_unevictable_allowed": 1, - "vm_extfrag_threshold": 500, - "vm_zone_reclaim_mode": 0, - "vm_dirty_background_ratio": 10, - "vm_dirty_ratio": 20, - "vm_dirty_writeback_centisecs": 500, - "kernel_numa_balancing": 0 -} +{ + "vm_swappiness": 60, + "vm_numa_balancing": 0, + "vm_oom_kill_allocating_task": 0, + "vm_compaction_proactiveness": 0, + "vm_compact_unevictable_allowed": 1, + "vm_extfrag_threshold": 500, + "vm_zone_reclaim_mode": 0, + "vm_dirty_background_ratio": 10, + "vm_dirty_ratio": 20, + "vm_dirty_writeback_centisecs": 500, + "kernel_numa_balancing": 0 +} diff --git a/test/unit/framework/fixtures/compare_runs_sysctl/run2/sysctl_plugin/sysctl_collector/result.json b/test/unit/framework/fixtures/compare_runs_sysctl/run2/sysctl_plugin/sysctl_collector/result.json index 042a8d56..e2f6f3f1 100644 --- a/test/unit/framework/fixtures/compare_runs_sysctl/run2/sysctl_plugin/sysctl_collector/result.json +++ b/test/unit/framework/fixtures/compare_runs_sysctl/run2/sysctl_plugin/sysctl_collector/result.json @@ -1,8 +1,8 @@ -{ - "status": "OK", - "message": "SYSCTL data collected", - "task": "SysctlCollector", - "parent": "SysctlPlugin", - "start_time": "2026-02-10T18:00:00.000000", - "end_time": "2026-02-10T18:00:01.000000" -} +{ + "status": "OK", + "message": "SYSCTL data collected", + "task": "SysctlCollector", + "parent": "SysctlPlugin", + "start_time": "2026-02-10T18:00:00.000000", + "end_time": "2026-02-10T18:00:01.000000" +} diff --git a/test/unit/framework/fixtures/compare_runs_sysctl/run2/sysctl_plugin/sysctl_collector/sysctldatamodel.json b/test/unit/framework/fixtures/compare_runs_sysctl/run2/sysctl_plugin/sysctl_collector/sysctldatamodel.json index 2e8df42f..21691f53 100644 --- a/test/unit/framework/fixtures/compare_runs_sysctl/run2/sysctl_plugin/sysctl_collector/sysctldatamodel.json +++ b/test/unit/framework/fixtures/compare_runs_sysctl/run2/sysctl_plugin/sysctl_collector/sysctldatamodel.json @@ -1,13 +1,13 @@ -{ - "vm_swappiness": 10, - "vm_numa_balancing": 0, - "vm_oom_kill_allocating_task": 0, - "vm_compaction_proactiveness": 0, - "vm_compact_unevictable_allowed": 1, - "vm_extfrag_threshold": 500, - "vm_zone_reclaim_mode": 0, - "vm_dirty_background_ratio": 5, - "vm_dirty_ratio": 15, - "vm_dirty_writeback_centisecs": 500, - "kernel_numa_balancing": 0 -} +{ + "vm_swappiness": 10, + "vm_numa_balancing": 0, + "vm_oom_kill_allocating_task": 0, + "vm_compaction_proactiveness": 0, + "vm_compact_unevictable_allowed": 1, + "vm_extfrag_threshold": 500, + "vm_zone_reclaim_mode": 0, + "vm_dirty_background_ratio": 5, + "vm_dirty_ratio": 15, + "vm_dirty_writeback_centisecs": 500, + "kernel_numa_balancing": 0 +} diff --git a/test/unit/framework/test_plugin_execution_target.py b/test/unit/framework/test_plugin_execution_target.py index 1433c55a..91ef30be 100644 --- a/test/unit/framework/test_plugin_execution_target.py +++ b/test/unit/framework/test_plugin_execution_target.py @@ -1,90 +1,90 @@ -############################################################################### -# -# 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 framework.common.shared_utils import DummyDataModel - -from nodescraper.base.inbanddataplugin import InBandDataPlugin -from nodescraper.base.oobanddataplugin import OOBandDataPlugin -from nodescraper.enums import SystemLocation -from nodescraper.helpers.plugin_execution_target import ( - format_in_band_target_summary, - format_plugin_execution_target, -) -from nodescraper.models import SystemInfo - - -class _DummyOobPlugin(OOBandDataPlugin): - DATA_MODEL = DummyDataModel - - -class _DummyInBandPlugin(InBandDataPlugin): - DATA_MODEL = DummyDataModel - - -def test_format_in_band_target_summary_local(): - summary = format_in_band_target_summary(SystemInfo(name="workstation01")) - assert summary == "In-band default: local host (workstation01)" - - -def test_format_in_band_target_summary_remote(): - summary = format_in_band_target_summary( - SystemInfo(name="workstation01", location=SystemLocation.REMOTE), - connection_configs={ - "InBandConnectionManager": { - "hostname": "sut.example.com", - } - }, - ) - assert summary == "In-band default: remote host via SSH (sut.example.com)" - - -def test_format_plugin_execution_target_redfish(): - target = format_plugin_execution_target( - _DummyOobPlugin, - system_info=SystemInfo(name="workstation01"), - connection_configs={ - "RedfishConnectionManager": {"host": "bmc.example.com"}, - }, - ) - assert target == "Execution target: BMC via Redfish OOB (bmc.example.com)" - - -def test_format_plugin_execution_target_inband_local(): - target = format_plugin_execution_target( - _DummyInBandPlugin, - system_info=SystemInfo(name="workstation01", location=SystemLocation.LOCAL), - ) - assert target == "Execution target: local host (workstation01)" - - -def test_format_plugin_execution_target_inband_remote(): - target = format_plugin_execution_target( - _DummyInBandPlugin, - system_info=SystemInfo(name="workstation01", location=SystemLocation.REMOTE), - connection_configs={ - "InBandConnectionManager": {"hostname": "sut.example.com"}, - }, - ) - assert target == "Execution target: remote host via SSH (sut.example.com)" +############################################################################### +# +# 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 framework.common.shared_utils import DummyDataModel + +from nodescraper.base.inbanddataplugin import InBandDataPlugin +from nodescraper.base.oobanddataplugin import OOBandDataPlugin +from nodescraper.enums import SystemLocation +from nodescraper.helpers.plugin_execution_target import ( + format_in_band_target_summary, + format_plugin_execution_target, +) +from nodescraper.models import SystemInfo + + +class _DummyOobPlugin(OOBandDataPlugin): + DATA_MODEL = DummyDataModel + + +class _DummyInBandPlugin(InBandDataPlugin): + DATA_MODEL = DummyDataModel + + +def test_format_in_band_target_summary_local(): + summary = format_in_band_target_summary(SystemInfo(name="workstation01")) + assert summary == "In-band default: local host (workstation01)" + + +def test_format_in_band_target_summary_remote(): + summary = format_in_band_target_summary( + SystemInfo(name="workstation01", location=SystemLocation.REMOTE), + connection_configs={ + "InBandConnectionManager": { + "hostname": "sut.example.com", + } + }, + ) + assert summary == "In-band default: remote host via SSH (sut.example.com)" + + +def test_format_plugin_execution_target_redfish(): + target = format_plugin_execution_target( + _DummyOobPlugin, + system_info=SystemInfo(name="workstation01"), + connection_configs={ + "RedfishConnectionManager": {"host": "bmc.example.com"}, + }, + ) + assert target == "Execution target: BMC via Redfish OOB (bmc.example.com)" + + +def test_format_plugin_execution_target_inband_local(): + target = format_plugin_execution_target( + _DummyInBandPlugin, + system_info=SystemInfo(name="workstation01", location=SystemLocation.LOCAL), + ) + assert target == "Execution target: local host (workstation01)" + + +def test_format_plugin_execution_target_inband_remote(): + target = format_plugin_execution_target( + _DummyInBandPlugin, + system_info=SystemInfo(name="workstation01", location=SystemLocation.REMOTE), + connection_configs={ + "InBandConnectionManager": {"hostname": "sut.example.com"}, + }, + ) + assert target == "Execution target: remote host via SSH (sut.example.com)" diff --git a/test/unit/framework/test_tablesummary.py b/test/unit/framework/test_tablesummary.py index 6dd07234..16da5bee 100644 --- a/test/unit/framework/test_tablesummary.py +++ b/test/unit/framework/test_tablesummary.py @@ -1,95 +1,95 @@ -############################################################################### -# -# 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 logging - -from serviceability_dummy_data import DUMMY_AFID_A, DUMMY_UNIT_A - -from nodescraper.enums import ExecutionStatus -from nodescraper.models import DataPluginResult, PluginResult, TaskResult -from nodescraper.plugins.serviceability.se_models import ( - HubTriageResult, - ServiceabilityBlock, -) -from nodescraper.plugins.serviceability.serviceability_data import ( - ServiceabilityDataModel, -) -from nodescraper.resultcollators.tablesummary import TableSummary - - -def test_tablesummary_prints_connection_serviceability_then_plugin(caplog): - block = ServiceabilityBlock( - hub_top_results=[ - HubTriageResult( - afid=DUMMY_AFID_A, - location=DUMMY_UNIT_A, - service_action_num=11018, - service_action_title="Check and Retry FW Bundle", - priority=1, - sa_severity=20, - tier_label="Secondary", - hub_sort_priority=1000, - ) - ], - hub_triage_results=[ - HubTriageResult( - afid=DUMMY_AFID_A, - location=DUMMY_UNIT_A, - service_action_num=11018, - service_action_title="Check and Retry FW Bundle", - priority=1, - sa_severity=20, - tier_label="Secondary", - hub_sort_priority=1000, - ) - ], - ) - plugin_results = [ - PluginResult( - status=ExecutionStatus.OK, - source="Mi4xxServiceabilityPlugin", - message="Plugin tasks completed successfully", - result_data=DataPluginResult( - system_data=ServiceabilityDataModel(serviceability=block), - ), - ) - ] - connection_results = [ - TaskResult( - task="RedfishConnectionManager", - status=ExecutionStatus.OK, - message="task completed successfully", - ) - ] - - logger = logging.getLogger("test_tablesummary") - caplog.set_level(logging.INFO, logger="test_tablesummary") - TableSummary(logger=logger).collate_results(plugin_results, connection_results) - - output = caplog.text - connection_pos = output.index("| Connection") - serviceability_pos = output.index("Top Hub service action") - plugin_pos = output.index("| Plugin") - assert connection_pos < serviceability_pos < plugin_pos +############################################################################### +# +# 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 logging + +from serviceability_dummy_data import DUMMY_AFID_A, DUMMY_UNIT_A + +from nodescraper.enums import ExecutionStatus +from nodescraper.models import DataPluginResult, PluginResult, TaskResult +from nodescraper.plugins.serviceability.se_models import ( + HubTriageResult, + ServiceabilityBlock, +) +from nodescraper.plugins.serviceability.serviceability_data import ( + ServiceabilityDataModel, +) +from nodescraper.resultcollators.tablesummary import TableSummary + + +def test_tablesummary_prints_connection_serviceability_then_plugin(caplog): + block = ServiceabilityBlock( + hub_top_results=[ + HubTriageResult( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + service_action_num=11018, + service_action_title="Check and Retry FW Bundle", + priority=1, + sa_severity=20, + tier_label="Secondary", + hub_sort_priority=1000, + ) + ], + hub_triage_results=[ + HubTriageResult( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + service_action_num=11018, + service_action_title="Check and Retry FW Bundle", + priority=1, + sa_severity=20, + tier_label="Secondary", + hub_sort_priority=1000, + ) + ], + ) + plugin_results = [ + PluginResult( + status=ExecutionStatus.OK, + source="Mi4xxServiceabilityPlugin", + message="Plugin tasks completed successfully", + result_data=DataPluginResult( + system_data=ServiceabilityDataModel(serviceability=block), + ), + ) + ] + connection_results = [ + TaskResult( + task="RedfishConnectionManager", + status=ExecutionStatus.OK, + message="task completed successfully", + ) + ] + + logger = logging.getLogger("test_tablesummary") + caplog.set_level(logging.INFO, logger="test_tablesummary") + TableSummary(logger=logger).collate_results(plugin_results, connection_results) + + output = caplog.text + connection_pos = output.index("| Connection") + serviceability_pos = output.index("Top Hub service action") + plugin_pos = output.index("| Plugin") + assert connection_pos < serviceability_pos < plugin_pos diff --git a/test/unit/plugin/test_niccli_collector.py b/test/unit/plugin/test_niccli_collector.py index 46a2fcff..90161728 100644 --- a/test/unit/plugin/test_niccli_collector.py +++ b/test/unit/plugin/test_niccli_collector.py @@ -1,450 +1,450 @@ -############################################################################### -# -# MIT License -# -# Copyright (c) 2025 Advanced Micro Devices, Inc. -# -############################################################################### -from unittest.mock import MagicMock - -import pytest - -from nodescraper.enums.executionstatus import ExecutionStatus -from nodescraper.enums.systeminteraction import SystemInteractionLevel -from nodescraper.models.systeminfo import OSFamily -from nodescraper.plugins.inband.nic.nic_collector import ( - NicCollector, - _parse_bcmcli_device_list, - _parse_bcmcli_qos, - _parse_niccli_version, -) -from nodescraper.plugins.inband.nic.nic_data import ( - NicCliDevice, - NicCliQos, - NicDataModel, - PensandoNicCard, -) - - -@pytest.fixture -def collector(system_info, conn_mock): - return NicCollector( - system_info=system_info, - system_interaction_level=SystemInteractionLevel.PASSIVE, - connection=conn_mock, - ) - - -NICCLI_LISTDEV_OUTPUT = """1) Broadcom BCM57608 1x400G QSFP-DD PCIe Ethernet NIC (Adp#1 Port#1) - Device Interface : abcd1p1 - MAC Address : 81:82:83:84:85:88 - PCI Address : 0000:22:00.0 -""" - -NICCLI_QOS_OUTPUT = """IEEE 8021QAZ ETS Configuration TLV: - PRIO_MAP: 0:0 1:0 2:0 3:1 4:0 5:0 6:0 7:2 - TC Bandwidth: 50% 50% 0% - TSA_MAP: 0:ets 1:ets 2:strict -IEEE 8021QAZ PFC TLV: - PFC enabled: 3 -IEEE 8021QAZ APP TLV: - APP#0: - Priority: 7 - Sel: 5 - DSCP: 48 - - APP#1: - Priority: 3 - Sel: 5 - DSCP: 26 - - APP#2: - Priority: 3 - Sel: 3 - UDP or DCCP: 4791 - -TC Rate Limit: 100% 100% 100% 0% 0% 0% 0% 0% -""" - - -def test_parse_niccli_listdev_device(collector): - """Test parsing Broadcom NIC device from niccli --list_devices output.""" - devices = collector._parse_niccli_listdev(NICCLI_LISTDEV_OUTPUT) - - assert len(devices) == 1 - device1 = devices[0] - assert device1.device_num == 1 - assert device1.model == "Broadcom BCM57608 1x400G QSFP-DD PCIe Ethernet NIC" - assert device1.adapter_port == "Adp#1 Port#1" - assert device1.interface_name == "abcd1p1" - assert device1.mac_address == "81:82:83:84:85:88" - assert device1.pci_address == "0000:22:00.0" - - -def test_parse_niccli_listdev_empty_output(collector): - """Test parsing empty niccli --list_devices output.""" - devices = collector._parse_niccli_listdev("") - assert len(devices) == 0 - - -def test_parse_niccli_listdev_malformed_output(collector): - """Test parsing malformed niccli --list_devices output gracefully.""" - malformed = """some random text -not a valid device line -123 invalid format -""" - devices = collector._parse_niccli_listdev(malformed) - assert isinstance(devices, list) - - -def test_parse_niccli_qos_complete(collector): - """Test parsing complete Broadcom NIC QoS output with all fields.""" - qos = collector._parse_niccli_qos(1, NICCLI_QOS_OUTPUT) - - assert qos.device_num == 1 - assert qos.raw_output == NICCLI_QOS_OUTPUT - assert len(qos.prio_map) == 8 - assert qos.prio_map[0] == 0 - assert qos.prio_map[3] == 1 - assert qos.prio_map[7] == 2 - assert len(qos.tc_bandwidth) == 3 - assert qos.tc_bandwidth[0] == 50 - assert qos.tc_bandwidth[1] == 50 - assert qos.tc_bandwidth[2] == 0 - assert len(qos.tsa_map) == 3 - assert qos.tsa_map[0] == "ets" - assert qos.tsa_map[2] == "strict" - assert qos.pfc_enabled == 3 - assert len(qos.app_entries) == 3 - assert qos.app_entries[0].priority == 7 - assert qos.app_entries[0].sel == 5 - assert qos.app_entries[0].dscp == 48 - assert qos.app_entries[2].protocol == "UDP or DCCP" - assert qos.app_entries[2].port == 4791 - assert len(qos.tc_rate_limit) == 8 - assert qos.tc_rate_limit[0] == 100 - - -def test_parse_niccli_qos_empty_output(collector): - """Test parsing empty QoS output.""" - qos = collector._parse_niccli_qos(1, "") - assert qos.device_num == 1 - assert qos.raw_output == "" - assert len(qos.prio_map) == 0 - assert len(qos.tc_bandwidth) == 0 - assert len(qos.tsa_map) == 0 - assert qos.pfc_enabled is None - assert len(qos.app_entries) == 0 - assert len(qos.tc_rate_limit) == 0 - - -def test_parse_niccli_qos_multiple_app_protocols(collector): - """Test parsing QoS with APP entries having different protocols.""" - qos_multi_protocol = """IEEE 8021QAZ ETS Configuration TLV: - PRIO_MAP: 0:0 - TC Bandwidth: 100% - TSA_MAP: 0:ets -IEEE 8021QAZ PFC TLV: - PFC enabled: 0 -IEEE 8021QAZ APP TLV: - APP#0: - Priority: 5 - Sel: 3 - TCP: 8080 - - APP#1: - Priority: 6 - Sel: 3 - UDP: 9000 - -TC Rate Limit: 100% -""" - qos = collector._parse_niccli_qos(3, qos_multi_protocol) - assert len(qos.app_entries) == 2 - assert qos.app_entries[0].priority == 5 - assert qos.app_entries[0].sel == 3 - assert qos.app_entries[0].protocol == "TCP" - assert qos.app_entries[0].port == 8080 - assert qos.app_entries[1].priority == 6 - assert qos.app_entries[1].protocol == "UDP" - assert qos.app_entries[1].port == 9000 - - -def test_parse_niccli_qos_malformed_values(collector): - """Test parsing QoS output with malformed values gracefully.""" - malformed = """IEEE 8021QAZ ETS Configuration TLV: - PRIO_MAP: 0:invalid 1:1 bad:data - TC Bandwidth: 50% invalid 50% - TSA_MAP: 0:ets bad:value 1:strict -IEEE 8021QAZ PFC TLV: - PFC enabled: not_a_number -TC Rate Limit: 100% bad% 100% -""" - qos = collector._parse_niccli_qos(1, malformed) - assert qos.device_num == 1 - assert 1 in qos.prio_map - assert qos.prio_map[1] == 1 - assert 50 in qos.tc_bandwidth - assert qos.tsa_map.get(0) == "ets" - assert qos.tsa_map.get(1) == "strict" - assert qos.pfc_enabled is None - - -def test_nic_data_model_with_broadcom_nic(collector): - """Test creating NicDataModel with Broadcom NIC data.""" - device = NicCliDevice( - device_num=1, - model="Broadcom BCM57608 1x400G QSFP-DD PCIe Ethernet NIC", - adapter_port="Adp#1 Port#1", - interface_name="benic1p1", - mac_address="8C:84:74:37:C3:70", - pci_address="0000:06:00.0", - ) - qos = NicCliQos( - device_num=1, - raw_output="test output", - prio_map={0: 0, 1: 1}, - tc_bandwidth=[50, 50], - tsa_map={0: "ets", 1: "strict"}, - pfc_enabled=3, - tc_rate_limit=[100, 100], - ) - data = NicDataModel( - broadcom_nic_devices=[device], - broadcom_nic_qos={1: qos}, - ) - assert len(data.broadcom_nic_devices) == 1 - assert len(data.broadcom_nic_qos) == 1 - assert data.broadcom_nic_devices[0].device_num == 1 - assert data.broadcom_nic_devices[0].interface_name == "benic1p1" - assert data.broadcom_nic_qos[1].device_num == 1 - assert data.broadcom_nic_qos[1].pfc_enabled == 3 - - -def test_nic_data_model_with_pensando_nic(collector): - """Test creating NicDataModel with Pensando NIC data.""" - card1 = PensandoNicCard( - id="42424650-4c32-3533-3330-323934000000", - pcie_bdf="0000:06:00.0", - asic="salina", - fw_partition="A", - serial_number="FPL25330294", - ) - card2 = PensandoNicCard( - id="42424650-4c32-3533-3731-304535000000", - pcie_bdf="0000:16:00.0", - asic="salina", - fw_partition="A", - serial_number="FPL253710E5", - ) - data = NicDataModel( - pensando_nic_cards=[card1, card2], - ) - assert len(data.pensando_nic_cards) == 2 - assert data.pensando_nic_cards[0].id == "42424650-4c32-3533-3330-323934000000" - assert data.pensando_nic_cards[0].pcie_bdf == "0000:06:00.0" - assert data.pensando_nic_cards[0].asic == "salina" - assert data.pensando_nic_cards[1].serial_number == "FPL253710E5" - - -def test_collect_data_not_ran_when_no_nic_hardware(collector, conn_mock): - """Skip collection when discovery finds no Broadcom or Pensando NICs.""" - collector.system_info.os_family = OSFamily.LINUX - collector._run_sut_cmd = MagicMock( - return_value=MagicMock(exit_code=1, stdout="", stderr="not found", command="") - ) - - result, data = collector.collect_data() - - assert result.status == ExecutionStatus.NOT_RAN - assert data is None - assert "skipped" in result.message.lower() - assert collector._run_sut_cmd.call_count <= 6 - - -def test_collect_data_skips_nicctl_commands_when_no_pensando_cards(collector, conn_mock): - """Do not run nicctl global/legacy commands when nicctl show card finds no cards.""" - collector.system_info.os_family = OSFamily.LINUX - commands_run: list[str] = [] - - def run_sut_cmd_side_effect(cmd, **kwargs): - commands_run.append(cmd) - if "niccli" in cmd and ("--list" in cmd or "--list_devices" in cmd or "--listdev" in cmd): - return MagicMock(exit_code=0, stdout=NICCLI_LISTDEV_OUTPUT, stderr="", command=cmd) - if cmd.strip() == "nicctl show card": - return MagicMock(exit_code=1, stdout="", stderr="no card", command=cmd) - return MagicMock(exit_code=0, stdout="", stderr="", command=cmd) - - collector._run_sut_cmd = MagicMock(side_effect=run_sut_cmd_side_effect) - - result, data = collector.collect_data() - - assert result.status == ExecutionStatus.OK - assert data is not None - assert not any(c.startswith("nicctl show card flash") for c in commands_run) - assert not any(c == "nicctl --version" for c in commands_run) - - -def test_collect_data_success(collector, conn_mock): - """Test successful collection of niccli/nicctl data.""" - collector.system_info.os_family = OSFamily.LINUX - - def run_sut_cmd_side_effect(cmd, **kwargs): - if "niccli" in cmd and ("--list" in cmd or "--list_devices" in cmd): - return MagicMock(exit_code=0, stdout=NICCLI_LISTDEV_OUTPUT, stderr="", command=cmd) - if cmd.strip() == "nicctl show card": - return MagicMock( - exit_code=0, - stdout="1111111-4c32-3533-3330-12345000000 0000:06:00.0\n", - stderr="", - command=cmd, - ) - if "nicctl" in cmd or "niccli" in cmd: - return MagicMock(exit_code=0, stdout="", stderr="", command=cmd) - return MagicMock(exit_code=1, stdout="", stderr="", command=cmd) - - collector._run_sut_cmd = MagicMock(side_effect=run_sut_cmd_side_effect) - - result, data = collector.collect_data() - - assert result.status == ExecutionStatus.OK - assert data is not None - assert isinstance(data, NicDataModel) - assert len(data.results) >= 1 - - -BCMCLI_DEVICE_LIST_OUTPUT = """\ -PCI Address Device Type Firmware Version --------- ----------- ---------------- -0000:03:00.0 BCM957608 1.140.0 -0000:04:00.0 BCM957608 1.140.0 -""" - -BCMCLI_QOS_OUTPUT = """\ -ETS Configuration ------------------ -TC TX_BW RX_BW TSA RateLimit Priority -0 50 50 ETS 0 0 1 2 -1 50 50 ETS 0 3 4 -2 0 0 strict 0 5 6 7 -PFC configuration ------------------ -priority 0 1 2 3 4 5 6 7 -enabled 0 0 0 1 0 0 0 0 -APP TLV Configuration ---------------------- -Index Selector Priority DSCP/Protocol -0 5 7 48 -""" - - -def test_parse_niccli_version(): - assert _parse_niccli_version("niccli v234") == 234 - assert _parse_niccli_version("") is None - - -def test_parse_bcmcli_device_list(): - """PCI addresses parsed from table; empty input returns empty list.""" - ids = _parse_bcmcli_device_list(BCMCLI_DEVICE_LIST_OUTPUT) - assert ids == ["0000:03:00.0", "0000:04:00.0"] - assert _parse_bcmcli_device_list("") == [] - - -def test_parse_bcmcli_qos(): - """ETS rows, PFC bitmask, and APP entries all parsed correctly.""" - qos = _parse_bcmcli_qos("0000:03:00.0", BCMCLI_QOS_OUTPUT) - assert qos.prio_map[0] == 0 and qos.prio_map[3] == 1 and qos.prio_map[5] == 2 - assert qos.tc_bandwidth == [50, 50, 0] - assert qos.tsa_map[0] == "ETS" and qos.tsa_map[2] == "strict" - assert qos.pfc_enabled == 8 # only priority 3 set (1 << 3) - assert len(qos.app_entries) == 1 and qos.app_entries[0].priority == 7 - - -def test_collect_data_uses_bcmcli_when_detected(collector): - """Both bcmcli probes succeed; bcmcli path used, broadcom_cli_type='bcmcli'.""" - commands_run: list[str] = [] - - def side_effect(cmd, **kwargs): - commands_run.append(cmd) - if "bcmcli_show version" in cmd or "which bcmcli_show" in cmd: - return MagicMock(exit_code=0, stdout="bcmcli v1.140", stderr="", command=cmd) - if "bcmcli_show device_list" in cmd: - return MagicMock(exit_code=0, stdout=BCMCLI_DEVICE_LIST_OUTPUT, stderr="", command=cmd) - if "nicctl show card" in cmd: - return MagicMock(exit_code=1, stdout="", stderr="", command=cmd) - return MagicMock(exit_code=0, stdout="", stderr="", command=cmd) - - collector._run_sut_cmd = MagicMock(side_effect=side_effect) - result, data = collector.collect_data() - - assert result.status == ExecutionStatus.OK - assert data.broadcom_cli_type == "bcmcli" - assert any("bcmcli_" in c for c in commands_run) - - -def test_detect_broadcom_cli_falls_back_when_device_list_empty(collector): - """bcmcli_show version exits 0 but device_list empty; falls back to niccli.""" - - def side_effect(cmd, **kwargs): - if "bcmcli_show version" in cmd: - return MagicMock(exit_code=0, stdout="bcmcli v1.140", stderr="", command=cmd) - if "bcmcli_show device_list" in cmd: - return MagicMock( - exit_code=0, stdout="PCI Address Device Type\n", stderr="", command=cmd - ) - if "niccli --version" in cmd: - return MagicMock(exit_code=0, stdout="niccli v234", stderr="", command=cmd) - if "--list_devices" in cmd or "--listdev" in cmd or "--list" in cmd: - return MagicMock(exit_code=0, stdout=NICCLI_LISTDEV_OUTPUT, stderr="", command=cmd) - if "nicctl show card" in cmd: - return MagicMock(exit_code=1, stdout="", stderr="", command=cmd) - return MagicMock(exit_code=0, stdout="", stderr="", command=cmd) - - collector._run_sut_cmd = MagicMock(side_effect=side_effect) - _, data = collector.collect_data() - - assert data.broadcom_cli_type == "niccli" - - -def test_broadcom_cli_override_skips_detection(collector): - """broadcom_cli_override='niccli' bypasses bcmcli probes entirely.""" - from nodescraper.plugins.inband.nic.collector_args import NicCollectorArgs - - commands_run: list[str] = [] - - def side_effect(cmd, **kwargs): - commands_run.append(cmd) - if "niccli --version" in cmd: - return MagicMock(exit_code=0, stdout="niccli v234", stderr="", command=cmd) - if "--list_devices" in cmd or "--listdev" in cmd or "--list" in cmd: - return MagicMock(exit_code=0, stdout=NICCLI_LISTDEV_OUTPUT, stderr="", command=cmd) - if "nicctl show card" in cmd: - return MagicMock(exit_code=1, stdout="", stderr="", command=cmd) - return MagicMock(exit_code=0, stdout="", stderr="", command=cmd) - - collector._run_sut_cmd = MagicMock(side_effect=side_effect) - _, data = collector.collect_data(args=NicCollectorArgs(broadcom_cli_override="niccli")) - - assert data.broadcom_cli_type == "niccli" - assert not any("bcmcli_show version" in c for c in commands_run) - - -def test_collect_data_niccli_version_routing(collector): - """niccli v234 uses --dev/--getoption syntax; legacy -dev/-getoption not run.""" - commands_run: list[str] = [] - - def side_effect(cmd, **kwargs): - commands_run.append(cmd) - if cmd == "niccli --version": - return MagicMock(exit_code=0, stdout="niccli v234", stderr="", command=cmd) - if "--list_devices" in cmd or "--listdev" in cmd or "--list" in cmd: - return MagicMock(exit_code=0, stdout=NICCLI_LISTDEV_OUTPUT, stderr="", command=cmd) - if "nicctl show card" in cmd: - return MagicMock(exit_code=1, stdout="", stderr="", command=cmd) - return MagicMock(exit_code=0, stdout="", stderr="", command=cmd) - - collector._run_sut_cmd = MagicMock(side_effect=side_effect) - collector.collect_data() - - assert any("--dev" in c and "--getoption" in c for c in commands_run) - assert not any(" -dev " in c for c in commands_run) +############################################################################### +# +# MIT License +# +# Copyright (c) 2025 Advanced Micro Devices, Inc. +# +############################################################################### +from unittest.mock import MagicMock + +import pytest + +from nodescraper.enums.executionstatus import ExecutionStatus +from nodescraper.enums.systeminteraction import SystemInteractionLevel +from nodescraper.models.systeminfo import OSFamily +from nodescraper.plugins.inband.nic.nic_collector import ( + NicCollector, + _parse_bcmcli_device_list, + _parse_bcmcli_qos, + _parse_niccli_version, +) +from nodescraper.plugins.inband.nic.nic_data import ( + NicCliDevice, + NicCliQos, + NicDataModel, + PensandoNicCard, +) + + +@pytest.fixture +def collector(system_info, conn_mock): + return NicCollector( + system_info=system_info, + system_interaction_level=SystemInteractionLevel.PASSIVE, + connection=conn_mock, + ) + + +NICCLI_LISTDEV_OUTPUT = """1) Broadcom BCM57608 1x400G QSFP-DD PCIe Ethernet NIC (Adp#1 Port#1) + Device Interface : abcd1p1 + MAC Address : 81:82:83:84:85:88 + PCI Address : 0000:22:00.0 +""" + +NICCLI_QOS_OUTPUT = """IEEE 8021QAZ ETS Configuration TLV: + PRIO_MAP: 0:0 1:0 2:0 3:1 4:0 5:0 6:0 7:2 + TC Bandwidth: 50% 50% 0% + TSA_MAP: 0:ets 1:ets 2:strict +IEEE 8021QAZ PFC TLV: + PFC enabled: 3 +IEEE 8021QAZ APP TLV: + APP#0: + Priority: 7 + Sel: 5 + DSCP: 48 + + APP#1: + Priority: 3 + Sel: 5 + DSCP: 26 + + APP#2: + Priority: 3 + Sel: 3 + UDP or DCCP: 4791 + +TC Rate Limit: 100% 100% 100% 0% 0% 0% 0% 0% +""" + + +def test_parse_niccli_listdev_device(collector): + """Test parsing Broadcom NIC device from niccli --list_devices output.""" + devices = collector._parse_niccli_listdev(NICCLI_LISTDEV_OUTPUT) + + assert len(devices) == 1 + device1 = devices[0] + assert device1.device_num == 1 + assert device1.model == "Broadcom BCM57608 1x400G QSFP-DD PCIe Ethernet NIC" + assert device1.adapter_port == "Adp#1 Port#1" + assert device1.interface_name == "abcd1p1" + assert device1.mac_address == "81:82:83:84:85:88" + assert device1.pci_address == "0000:22:00.0" + + +def test_parse_niccli_listdev_empty_output(collector): + """Test parsing empty niccli --list_devices output.""" + devices = collector._parse_niccli_listdev("") + assert len(devices) == 0 + + +def test_parse_niccli_listdev_malformed_output(collector): + """Test parsing malformed niccli --list_devices output gracefully.""" + malformed = """some random text +not a valid device line +123 invalid format +""" + devices = collector._parse_niccli_listdev(malformed) + assert isinstance(devices, list) + + +def test_parse_niccli_qos_complete(collector): + """Test parsing complete Broadcom NIC QoS output with all fields.""" + qos = collector._parse_niccli_qos(1, NICCLI_QOS_OUTPUT) + + assert qos.device_num == 1 + assert qos.raw_output == NICCLI_QOS_OUTPUT + assert len(qos.prio_map) == 8 + assert qos.prio_map[0] == 0 + assert qos.prio_map[3] == 1 + assert qos.prio_map[7] == 2 + assert len(qos.tc_bandwidth) == 3 + assert qos.tc_bandwidth[0] == 50 + assert qos.tc_bandwidth[1] == 50 + assert qos.tc_bandwidth[2] == 0 + assert len(qos.tsa_map) == 3 + assert qos.tsa_map[0] == "ets" + assert qos.tsa_map[2] == "strict" + assert qos.pfc_enabled == 3 + assert len(qos.app_entries) == 3 + assert qos.app_entries[0].priority == 7 + assert qos.app_entries[0].sel == 5 + assert qos.app_entries[0].dscp == 48 + assert qos.app_entries[2].protocol == "UDP or DCCP" + assert qos.app_entries[2].port == 4791 + assert len(qos.tc_rate_limit) == 8 + assert qos.tc_rate_limit[0] == 100 + + +def test_parse_niccli_qos_empty_output(collector): + """Test parsing empty QoS output.""" + qos = collector._parse_niccli_qos(1, "") + assert qos.device_num == 1 + assert qos.raw_output == "" + assert len(qos.prio_map) == 0 + assert len(qos.tc_bandwidth) == 0 + assert len(qos.tsa_map) == 0 + assert qos.pfc_enabled is None + assert len(qos.app_entries) == 0 + assert len(qos.tc_rate_limit) == 0 + + +def test_parse_niccli_qos_multiple_app_protocols(collector): + """Test parsing QoS with APP entries having different protocols.""" + qos_multi_protocol = """IEEE 8021QAZ ETS Configuration TLV: + PRIO_MAP: 0:0 + TC Bandwidth: 100% + TSA_MAP: 0:ets +IEEE 8021QAZ PFC TLV: + PFC enabled: 0 +IEEE 8021QAZ APP TLV: + APP#0: + Priority: 5 + Sel: 3 + TCP: 8080 + + APP#1: + Priority: 6 + Sel: 3 + UDP: 9000 + +TC Rate Limit: 100% +""" + qos = collector._parse_niccli_qos(3, qos_multi_protocol) + assert len(qos.app_entries) == 2 + assert qos.app_entries[0].priority == 5 + assert qos.app_entries[0].sel == 3 + assert qos.app_entries[0].protocol == "TCP" + assert qos.app_entries[0].port == 8080 + assert qos.app_entries[1].priority == 6 + assert qos.app_entries[1].protocol == "UDP" + assert qos.app_entries[1].port == 9000 + + +def test_parse_niccli_qos_malformed_values(collector): + """Test parsing QoS output with malformed values gracefully.""" + malformed = """IEEE 8021QAZ ETS Configuration TLV: + PRIO_MAP: 0:invalid 1:1 bad:data + TC Bandwidth: 50% invalid 50% + TSA_MAP: 0:ets bad:value 1:strict +IEEE 8021QAZ PFC TLV: + PFC enabled: not_a_number +TC Rate Limit: 100% bad% 100% +""" + qos = collector._parse_niccli_qos(1, malformed) + assert qos.device_num == 1 + assert 1 in qos.prio_map + assert qos.prio_map[1] == 1 + assert 50 in qos.tc_bandwidth + assert qos.tsa_map.get(0) == "ets" + assert qos.tsa_map.get(1) == "strict" + assert qos.pfc_enabled is None + + +def test_nic_data_model_with_broadcom_nic(collector): + """Test creating NicDataModel with Broadcom NIC data.""" + device = NicCliDevice( + device_num=1, + model="Broadcom BCM57608 1x400G QSFP-DD PCIe Ethernet NIC", + adapter_port="Adp#1 Port#1", + interface_name="benic1p1", + mac_address="8C:84:74:37:C3:70", + pci_address="0000:06:00.0", + ) + qos = NicCliQos( + device_num=1, + raw_output="test output", + prio_map={0: 0, 1: 1}, + tc_bandwidth=[50, 50], + tsa_map={0: "ets", 1: "strict"}, + pfc_enabled=3, + tc_rate_limit=[100, 100], + ) + data = NicDataModel( + broadcom_nic_devices=[device], + broadcom_nic_qos={1: qos}, + ) + assert len(data.broadcom_nic_devices) == 1 + assert len(data.broadcom_nic_qos) == 1 + assert data.broadcom_nic_devices[0].device_num == 1 + assert data.broadcom_nic_devices[0].interface_name == "benic1p1" + assert data.broadcom_nic_qos[1].device_num == 1 + assert data.broadcom_nic_qos[1].pfc_enabled == 3 + + +def test_nic_data_model_with_pensando_nic(collector): + """Test creating NicDataModel with Pensando NIC data.""" + card1 = PensandoNicCard( + id="42424650-4c32-3533-3330-323934000000", + pcie_bdf="0000:06:00.0", + asic="salina", + fw_partition="A", + serial_number="FPL25330294", + ) + card2 = PensandoNicCard( + id="42424650-4c32-3533-3731-304535000000", + pcie_bdf="0000:16:00.0", + asic="salina", + fw_partition="A", + serial_number="FPL253710E5", + ) + data = NicDataModel( + pensando_nic_cards=[card1, card2], + ) + assert len(data.pensando_nic_cards) == 2 + assert data.pensando_nic_cards[0].id == "42424650-4c32-3533-3330-323934000000" + assert data.pensando_nic_cards[0].pcie_bdf == "0000:06:00.0" + assert data.pensando_nic_cards[0].asic == "salina" + assert data.pensando_nic_cards[1].serial_number == "FPL253710E5" + + +def test_collect_data_not_ran_when_no_nic_hardware(collector, conn_mock): + """Skip collection when discovery finds no Broadcom or Pensando NICs.""" + collector.system_info.os_family = OSFamily.LINUX + collector._run_sut_cmd = MagicMock( + return_value=MagicMock(exit_code=1, stdout="", stderr="not found", command="") + ) + + result, data = collector.collect_data() + + assert result.status == ExecutionStatus.NOT_RAN + assert data is None + assert "skipped" in result.message.lower() + assert collector._run_sut_cmd.call_count <= 6 + + +def test_collect_data_skips_nicctl_commands_when_no_pensando_cards(collector, conn_mock): + """Do not run nicctl global/legacy commands when nicctl show card finds no cards.""" + collector.system_info.os_family = OSFamily.LINUX + commands_run: list[str] = [] + + def run_sut_cmd_side_effect(cmd, **kwargs): + commands_run.append(cmd) + if "niccli" in cmd and ("--list" in cmd or "--list_devices" in cmd or "--listdev" in cmd): + return MagicMock(exit_code=0, stdout=NICCLI_LISTDEV_OUTPUT, stderr="", command=cmd) + if cmd.strip() == "nicctl show card": + return MagicMock(exit_code=1, stdout="", stderr="no card", command=cmd) + return MagicMock(exit_code=0, stdout="", stderr="", command=cmd) + + collector._run_sut_cmd = MagicMock(side_effect=run_sut_cmd_side_effect) + + result, data = collector.collect_data() + + assert result.status == ExecutionStatus.OK + assert data is not None + assert not any(c.startswith("nicctl show card flash") for c in commands_run) + assert not any(c == "nicctl --version" for c in commands_run) + + +def test_collect_data_success(collector, conn_mock): + """Test successful collection of niccli/nicctl data.""" + collector.system_info.os_family = OSFamily.LINUX + + def run_sut_cmd_side_effect(cmd, **kwargs): + if "niccli" in cmd and ("--list" in cmd or "--list_devices" in cmd): + return MagicMock(exit_code=0, stdout=NICCLI_LISTDEV_OUTPUT, stderr="", command=cmd) + if cmd.strip() == "nicctl show card": + return MagicMock( + exit_code=0, + stdout="1111111-4c32-3533-3330-12345000000 0000:06:00.0\n", + stderr="", + command=cmd, + ) + if "nicctl" in cmd or "niccli" in cmd: + return MagicMock(exit_code=0, stdout="", stderr="", command=cmd) + return MagicMock(exit_code=1, stdout="", stderr="", command=cmd) + + collector._run_sut_cmd = MagicMock(side_effect=run_sut_cmd_side_effect) + + result, data = collector.collect_data() + + assert result.status == ExecutionStatus.OK + assert data is not None + assert isinstance(data, NicDataModel) + assert len(data.results) >= 1 + + +BCMCLI_DEVICE_LIST_OUTPUT = """\ +PCI Address Device Type Firmware Version +-------- ----------- ---------------- +0000:03:00.0 BCM957608 1.140.0 +0000:04:00.0 BCM957608 1.140.0 +""" + +BCMCLI_QOS_OUTPUT = """\ +ETS Configuration +----------------- +TC TX_BW RX_BW TSA RateLimit Priority +0 50 50 ETS 0 0 1 2 +1 50 50 ETS 0 3 4 +2 0 0 strict 0 5 6 7 +PFC configuration +----------------- +priority 0 1 2 3 4 5 6 7 +enabled 0 0 0 1 0 0 0 0 +APP TLV Configuration +--------------------- +Index Selector Priority DSCP/Protocol +0 5 7 48 +""" + + +def test_parse_niccli_version(): + assert _parse_niccli_version("niccli v234") == 234 + assert _parse_niccli_version("") is None + + +def test_parse_bcmcli_device_list(): + """PCI addresses parsed from table; empty input returns empty list.""" + ids = _parse_bcmcli_device_list(BCMCLI_DEVICE_LIST_OUTPUT) + assert ids == ["0000:03:00.0", "0000:04:00.0"] + assert _parse_bcmcli_device_list("") == [] + + +def test_parse_bcmcli_qos(): + """ETS rows, PFC bitmask, and APP entries all parsed correctly.""" + qos = _parse_bcmcli_qos("0000:03:00.0", BCMCLI_QOS_OUTPUT) + assert qos.prio_map[0] == 0 and qos.prio_map[3] == 1 and qos.prio_map[5] == 2 + assert qos.tc_bandwidth == [50, 50, 0] + assert qos.tsa_map[0] == "ETS" and qos.tsa_map[2] == "strict" + assert qos.pfc_enabled == 8 # only priority 3 set (1 << 3) + assert len(qos.app_entries) == 1 and qos.app_entries[0].priority == 7 + + +def test_collect_data_uses_bcmcli_when_detected(collector): + """Both bcmcli probes succeed; bcmcli path used, broadcom_cli_type='bcmcli'.""" + commands_run: list[str] = [] + + def side_effect(cmd, **kwargs): + commands_run.append(cmd) + if "bcmcli_show version" in cmd or "which bcmcli_show" in cmd: + return MagicMock(exit_code=0, stdout="bcmcli v1.140", stderr="", command=cmd) + if "bcmcli_show device_list" in cmd: + return MagicMock(exit_code=0, stdout=BCMCLI_DEVICE_LIST_OUTPUT, stderr="", command=cmd) + if "nicctl show card" in cmd: + return MagicMock(exit_code=1, stdout="", stderr="", command=cmd) + return MagicMock(exit_code=0, stdout="", stderr="", command=cmd) + + collector._run_sut_cmd = MagicMock(side_effect=side_effect) + result, data = collector.collect_data() + + assert result.status == ExecutionStatus.OK + assert data.broadcom_cli_type == "bcmcli" + assert any("bcmcli_" in c for c in commands_run) + + +def test_detect_broadcom_cli_falls_back_when_device_list_empty(collector): + """bcmcli_show version exits 0 but device_list empty; falls back to niccli.""" + + def side_effect(cmd, **kwargs): + if "bcmcli_show version" in cmd: + return MagicMock(exit_code=0, stdout="bcmcli v1.140", stderr="", command=cmd) + if "bcmcli_show device_list" in cmd: + return MagicMock( + exit_code=0, stdout="PCI Address Device Type\n", stderr="", command=cmd + ) + if "niccli --version" in cmd: + return MagicMock(exit_code=0, stdout="niccli v234", stderr="", command=cmd) + if "--list_devices" in cmd or "--listdev" in cmd or "--list" in cmd: + return MagicMock(exit_code=0, stdout=NICCLI_LISTDEV_OUTPUT, stderr="", command=cmd) + if "nicctl show card" in cmd: + return MagicMock(exit_code=1, stdout="", stderr="", command=cmd) + return MagicMock(exit_code=0, stdout="", stderr="", command=cmd) + + collector._run_sut_cmd = MagicMock(side_effect=side_effect) + _, data = collector.collect_data() + + assert data.broadcom_cli_type == "niccli" + + +def test_broadcom_cli_override_skips_detection(collector): + """broadcom_cli_override='niccli' bypasses bcmcli probes entirely.""" + from nodescraper.plugins.inband.nic.collector_args import NicCollectorArgs + + commands_run: list[str] = [] + + def side_effect(cmd, **kwargs): + commands_run.append(cmd) + if "niccli --version" in cmd: + return MagicMock(exit_code=0, stdout="niccli v234", stderr="", command=cmd) + if "--list_devices" in cmd or "--listdev" in cmd or "--list" in cmd: + return MagicMock(exit_code=0, stdout=NICCLI_LISTDEV_OUTPUT, stderr="", command=cmd) + if "nicctl show card" in cmd: + return MagicMock(exit_code=1, stdout="", stderr="", command=cmd) + return MagicMock(exit_code=0, stdout="", stderr="", command=cmd) + + collector._run_sut_cmd = MagicMock(side_effect=side_effect) + _, data = collector.collect_data(args=NicCollectorArgs(broadcom_cli_override="niccli")) + + assert data.broadcom_cli_type == "niccli" + assert not any("bcmcli_show version" in c for c in commands_run) + + +def test_collect_data_niccli_version_routing(collector): + """niccli v234 uses --dev/--getoption syntax; legacy -dev/-getoption not run.""" + commands_run: list[str] = [] + + def side_effect(cmd, **kwargs): + commands_run.append(cmd) + if cmd == "niccli --version": + return MagicMock(exit_code=0, stdout="niccli v234", stderr="", command=cmd) + if "--list_devices" in cmd or "--listdev" in cmd or "--list" in cmd: + return MagicMock(exit_code=0, stdout=NICCLI_LISTDEV_OUTPUT, stderr="", command=cmd) + if "nicctl show card" in cmd: + return MagicMock(exit_code=1, stdout="", stderr="", command=cmd) + return MagicMock(exit_code=0, stdout="", stderr="", command=cmd) + + collector._run_sut_cmd = MagicMock(side_effect=side_effect) + collector.collect_data() + + assert any("--dev" in c and "--getoption" in c for c in commands_run) + assert not any(" -dev " in c for c in commands_run) diff --git a/test/unit/plugin/test_redfish_endpoint_analyzer.py b/test/unit/plugin/test_redfish_endpoint_analyzer.py index 0f014245..55163824 100644 --- a/test/unit/plugin/test_redfish_endpoint_analyzer.py +++ b/test/unit/plugin/test_redfish_endpoint_analyzer.py @@ -1,209 +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 pytest - -from nodescraper.enums import ExecutionStatus -from nodescraper.plugins.ooband.redfish_endpoint import ( - RedfishEndpointAnalyzer, - RedfishEndpointAnalyzerArgs, - RedfishEndpointDataModel, -) -from nodescraper.plugins.ooband.redfish_endpoint.endpoint_analyzer import ( - _check_constraint, - _get_by_path, -) - - -@pytest.fixture -def redfish_endpoint_analyzer(system_info): - return RedfishEndpointAnalyzer(system_info=system_info) - - -def test_get_by_path_empty_returns_obj(): - obj = {"a": 1} - assert _get_by_path(obj, "") == obj - assert _get_by_path(obj, " ") == obj - - -def test_get_by_path_single_key(): - assert _get_by_path({"x": 42}, "x") == 42 - assert _get_by_path({"Status": {"Health": "OK"}}, "Status") == {"Health": "OK"} - - -def test_get_by_path_nested_slash(): - obj = {"Status": {"Health": "OK", "State": "Enabled"}} - assert _get_by_path(obj, "Status/Health") == "OK" - assert _get_by_path(obj, "Status/State") == "Enabled" - - -def test_get_by_path_list_index(): - obj = {"PowerControl": [{"PowerConsumedWatts": 100}, {"PowerConsumedWatts": 200}]} - assert _get_by_path(obj, "PowerControl/0/PowerConsumedWatts") == 100 - assert _get_by_path(obj, "PowerControl/1/PowerConsumedWatts") == 200 - - -def test_get_by_path_missing_returns_none(): - assert _get_by_path({"a": 1}, "b") is None - assert _get_by_path({"a": {"b": 2}}, "a/c") is None - assert _get_by_path(None, "a") is None - - -def test_get_by_path_invalid_list_index(): - obj = {"list": [1, 2, 3]} - assert _get_by_path(obj, "list/10") is None - assert _get_by_path(obj, "list/xyz") is None - - -def test_check_constraint_eq_pass(): - ok, msg = _check_constraint("On", {"eq": "On"}) - assert ok is True - - -def test_check_constraint_eq_fail(): - ok, msg = _check_constraint("Off", {"eq": "On"}) - assert ok is False - assert "On" in msg and "Off" in msg - - -def test_check_constraint_min_max_pass(): - ok, _ = _check_constraint(50, {"min": 0, "max": 100}) - assert ok is True - ok, _ = _check_constraint(0, {"min": 0}) - assert ok is True - ok, _ = _check_constraint(100, {"max": 100}) - assert ok is True - - -def test_check_constraint_min_fail(): - ok, msg = _check_constraint(10, {"min": 20}) - assert ok is False - assert "below min" in msg or "20" in msg - - -def test_check_constraint_max_fail(): - ok, msg = _check_constraint(150, {"max": 100}) - assert ok is False - assert "above max" in msg or "100" in msg - - -def test_check_constraint_any_of_pass(): - ok, _ = _check_constraint("OK", {"anyOf": ["OK", "Warning"]}) - assert ok is True - ok, _ = _check_constraint("Warning", {"anyOf": ["OK", "Warning"]}) - assert ok is True - - -def test_check_constraint_any_of_fail(): - ok, msg = _check_constraint("Critical", {"anyOf": ["OK", "Warning"]}) - assert ok is False - assert "any of" in msg or "OK" in msg - - -def test_check_constraint_literal_match(): - ok, _ = _check_constraint("On", "On") - assert ok is True - ok, msg = _check_constraint("Off", "On") - assert ok is False - - -def test_redfish_endpoint_analyzer_no_checks(redfish_endpoint_analyzer): - data = RedfishEndpointDataModel(responses={"/redfish/v1": {}}) - result = redfish_endpoint_analyzer.analyze_data(data, args=None) - assert result.status == ExecutionStatus.OK - assert result.message == "No checks configured" - - -def test_redfish_endpoint_analyzer_empty_checks(redfish_endpoint_analyzer): - data = RedfishEndpointDataModel(responses={"/redfish/v1": {"Status": {"Health": "OK"}}}) - result = redfish_endpoint_analyzer.analyze_data( - data, args=RedfishEndpointAnalyzerArgs(checks={}) - ) - assert result.status == ExecutionStatus.OK - assert result.message == "No checks configured" - - -def test_redfish_endpoint_analyzer_all_pass(redfish_endpoint_analyzer): - data = RedfishEndpointDataModel( - responses={ - "/redfish/v1/Systems/1": {"Status": {"Health": "OK"}, "PowerState": "On"}, - } - ) - args = RedfishEndpointAnalyzerArgs( - checks={ - "/redfish/v1/Systems/1": { - "Status/Health": {"anyOf": ["OK", "Warning"]}, - "PowerState": "On", - }, - } - ) - result = redfish_endpoint_analyzer.analyze_data(data, args=args) - assert result.status == ExecutionStatus.OK - assert result.message == "All Redfish endpoint checks passed" - - -def test_redfish_endpoint_analyzer_one_fail(redfish_endpoint_analyzer): - data = RedfishEndpointDataModel( - responses={ - "/redfish/v1/Systems/1": {"Status": {"Health": "Critical"}}, - } - ) - args = RedfishEndpointAnalyzerArgs( - checks={ - "/redfish/v1/Systems/1": {"Status/Health": {"anyOf": ["OK", "Warning"]}}, - } - ) - result = redfish_endpoint_analyzer.analyze_data(data, args=args) - assert result.status == ExecutionStatus.ERROR - assert "check(s) failed" in result.message - - -def test_redfish_endpoint_analyzer_uri_not_in_responses(redfish_endpoint_analyzer): - data = RedfishEndpointDataModel(responses={"/redfish/v1": {}}) - args = RedfishEndpointAnalyzerArgs( - checks={ - "/redfish/v1/Systems/1": {"Status/Health": "OK"}, - } - ) - result = redfish_endpoint_analyzer.analyze_data(data, args=args) - assert result.status == ExecutionStatus.ERROR - assert "check(s) failed" in result.message or "failed" in result.message - - -def test_redfish_endpoint_analyzer_wildcard_applies_to_all_bodies(redfish_endpoint_analyzer): - data = RedfishEndpointDataModel( - responses={ - "/redfish/v1/Chassis/1": {"Status": {"Health": "OK"}}, - "/redfish/v1/Chassis/2": {"Status": {"Health": "OK"}}, - } - ) - args = RedfishEndpointAnalyzerArgs( - checks={ - "*": {"Status/Health": {"anyOf": ["OK", "Warning"]}}, - } - ) - result = redfish_endpoint_analyzer.analyze_data(data, args=args) - assert result.status == ExecutionStatus.OK - assert result.message == "All Redfish endpoint checks passed" +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +import pytest + +from nodescraper.enums import ExecutionStatus +from nodescraper.plugins.ooband.redfish_endpoint import ( + RedfishEndpointAnalyzer, + RedfishEndpointAnalyzerArgs, + RedfishEndpointDataModel, +) +from nodescraper.plugins.ooband.redfish_endpoint.endpoint_analyzer import ( + _check_constraint, + _get_by_path, +) + + +@pytest.fixture +def redfish_endpoint_analyzer(system_info): + return RedfishEndpointAnalyzer(system_info=system_info) + + +def test_get_by_path_empty_returns_obj(): + obj = {"a": 1} + assert _get_by_path(obj, "") == obj + assert _get_by_path(obj, " ") == obj + + +def test_get_by_path_single_key(): + assert _get_by_path({"x": 42}, "x") == 42 + assert _get_by_path({"Status": {"Health": "OK"}}, "Status") == {"Health": "OK"} + + +def test_get_by_path_nested_slash(): + obj = {"Status": {"Health": "OK", "State": "Enabled"}} + assert _get_by_path(obj, "Status/Health") == "OK" + assert _get_by_path(obj, "Status/State") == "Enabled" + + +def test_get_by_path_list_index(): + obj = {"PowerControl": [{"PowerConsumedWatts": 100}, {"PowerConsumedWatts": 200}]} + assert _get_by_path(obj, "PowerControl/0/PowerConsumedWatts") == 100 + assert _get_by_path(obj, "PowerControl/1/PowerConsumedWatts") == 200 + + +def test_get_by_path_missing_returns_none(): + assert _get_by_path({"a": 1}, "b") is None + assert _get_by_path({"a": {"b": 2}}, "a/c") is None + assert _get_by_path(None, "a") is None + + +def test_get_by_path_invalid_list_index(): + obj = {"list": [1, 2, 3]} + assert _get_by_path(obj, "list/10") is None + assert _get_by_path(obj, "list/xyz") is None + + +def test_check_constraint_eq_pass(): + ok, msg = _check_constraint("On", {"eq": "On"}) + assert ok is True + + +def test_check_constraint_eq_fail(): + ok, msg = _check_constraint("Off", {"eq": "On"}) + assert ok is False + assert "On" in msg and "Off" in msg + + +def test_check_constraint_min_max_pass(): + ok, _ = _check_constraint(50, {"min": 0, "max": 100}) + assert ok is True + ok, _ = _check_constraint(0, {"min": 0}) + assert ok is True + ok, _ = _check_constraint(100, {"max": 100}) + assert ok is True + + +def test_check_constraint_min_fail(): + ok, msg = _check_constraint(10, {"min": 20}) + assert ok is False + assert "below min" in msg or "20" in msg + + +def test_check_constraint_max_fail(): + ok, msg = _check_constraint(150, {"max": 100}) + assert ok is False + assert "above max" in msg or "100" in msg + + +def test_check_constraint_any_of_pass(): + ok, _ = _check_constraint("OK", {"anyOf": ["OK", "Warning"]}) + assert ok is True + ok, _ = _check_constraint("Warning", {"anyOf": ["OK", "Warning"]}) + assert ok is True + + +def test_check_constraint_any_of_fail(): + ok, msg = _check_constraint("Critical", {"anyOf": ["OK", "Warning"]}) + assert ok is False + assert "any of" in msg or "OK" in msg + + +def test_check_constraint_literal_match(): + ok, _ = _check_constraint("On", "On") + assert ok is True + ok, msg = _check_constraint("Off", "On") + assert ok is False + + +def test_redfish_endpoint_analyzer_no_checks(redfish_endpoint_analyzer): + data = RedfishEndpointDataModel(responses={"/redfish/v1": {}}) + result = redfish_endpoint_analyzer.analyze_data(data, args=None) + assert result.status == ExecutionStatus.OK + assert result.message == "No checks configured" + + +def test_redfish_endpoint_analyzer_empty_checks(redfish_endpoint_analyzer): + data = RedfishEndpointDataModel(responses={"/redfish/v1": {"Status": {"Health": "OK"}}}) + result = redfish_endpoint_analyzer.analyze_data( + data, args=RedfishEndpointAnalyzerArgs(checks={}) + ) + assert result.status == ExecutionStatus.OK + assert result.message == "No checks configured" + + +def test_redfish_endpoint_analyzer_all_pass(redfish_endpoint_analyzer): + data = RedfishEndpointDataModel( + responses={ + "/redfish/v1/Systems/1": {"Status": {"Health": "OK"}, "PowerState": "On"}, + } + ) + args = RedfishEndpointAnalyzerArgs( + checks={ + "/redfish/v1/Systems/1": { + "Status/Health": {"anyOf": ["OK", "Warning"]}, + "PowerState": "On", + }, + } + ) + result = redfish_endpoint_analyzer.analyze_data(data, args=args) + assert result.status == ExecutionStatus.OK + assert result.message == "All Redfish endpoint checks passed" + + +def test_redfish_endpoint_analyzer_one_fail(redfish_endpoint_analyzer): + data = RedfishEndpointDataModel( + responses={ + "/redfish/v1/Systems/1": {"Status": {"Health": "Critical"}}, + } + ) + args = RedfishEndpointAnalyzerArgs( + checks={ + "/redfish/v1/Systems/1": {"Status/Health": {"anyOf": ["OK", "Warning"]}}, + } + ) + result = redfish_endpoint_analyzer.analyze_data(data, args=args) + assert result.status == ExecutionStatus.ERROR + assert "check(s) failed" in result.message + + +def test_redfish_endpoint_analyzer_uri_not_in_responses(redfish_endpoint_analyzer): + data = RedfishEndpointDataModel(responses={"/redfish/v1": {}}) + args = RedfishEndpointAnalyzerArgs( + checks={ + "/redfish/v1/Systems/1": {"Status/Health": "OK"}, + } + ) + result = redfish_endpoint_analyzer.analyze_data(data, args=args) + assert result.status == ExecutionStatus.ERROR + assert "check(s) failed" in result.message or "failed" in result.message + + +def test_redfish_endpoint_analyzer_wildcard_applies_to_all_bodies(redfish_endpoint_analyzer): + data = RedfishEndpointDataModel( + responses={ + "/redfish/v1/Chassis/1": {"Status": {"Health": "OK"}}, + "/redfish/v1/Chassis/2": {"Status": {"Health": "OK"}}, + } + ) + args = RedfishEndpointAnalyzerArgs( + checks={ + "*": {"Status/Health": {"anyOf": ["OK", "Warning"]}}, + } + ) + result = redfish_endpoint_analyzer.analyze_data(data, args=args) + assert result.status == ExecutionStatus.OK + assert result.message == "All Redfish endpoint checks passed" diff --git a/test/unit/plugin/test_redfish_endpoint_collector.py b/test/unit/plugin/test_redfish_endpoint_collector.py index 0afe4cb8..b68f57a2 100644 --- a/test/unit/plugin/test_redfish_endpoint_collector.py +++ b/test/unit/plugin/test_redfish_endpoint_collector.py @@ -1,635 +1,635 @@ -############################################################################### -# -# 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 unittest.mock import MagicMock - -import pytest - -from nodescraper.connection.redfish import RedfishGetResult -from nodescraper.enums import EventCategory, ExecutionStatus -from nodescraper.plugins.ooband.redfish_endpoint import ( - RedfishEndpointCollector, - RedfishEndpointCollectorArgs, -) -from nodescraper.plugins.ooband.redfish_endpoint import endpoint_collector as ec - - -@pytest.fixture -def redfish_endpoint_collector(system_info, redfish_conn_mock): - return RedfishEndpointCollector( - system_info=system_info, - connection=redfish_conn_mock, - ) - - -def test_redfish_endpoint_collector_no_uris(redfish_endpoint_collector): - result, data = redfish_endpoint_collector.collect_data() - assert result.status == ExecutionStatus.NOT_RAN - assert "No collection mode configured" in result.message - assert data is None - - -def test_redfish_endpoint_collector_no_uris_with_args(redfish_endpoint_collector): - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs(uris=[]) - ) - assert result.status == ExecutionStatus.NOT_RAN - assert "No collection mode configured" in result.message - assert data is None - - -def test_redfish_endpoint_collector_one_uri_success(redfish_endpoint_collector, redfish_conn_mock): - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/redfish/v1", - success=True, - data={"Name": "Root"}, - status_code=200, - ) - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs(uris=["/redfish/v1"]) - ) - assert result.status == ExecutionStatus.OK - assert result.message == "Collected 1 Redfish endpoint(s)" - assert data is not None - assert data.responses["/redfish/v1"]["Name"] == "Root" - redfish_conn_mock.run_get.assert_called_once() - call_path = redfish_conn_mock.run_get.call_args[0][0] - assert call_path == "/redfish/v1" or call_path.strip("/") == "redfish/v1" - - -def test_redfish_endpoint_collector_uri_normalized_with_leading_slash( - redfish_endpoint_collector, redfish_conn_mock -): - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/redfish/v1/Systems", - success=True, - data={"Members": []}, - status_code=200, - ) - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs(uris=["redfish/v1/Systems"]) - ) - assert result.status == ExecutionStatus.OK - assert data is not None - assert "/redfish/v1/Systems" in data.responses or "redfish/v1/Systems" in data.responses - - -def test_redfish_endpoint_collector_one_fail_no_success( - redfish_endpoint_collector, redfish_conn_mock -): - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/redfish/v1", - success=False, - error="Connection refused", - status_code=None, - ) - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs(uris=["/redfish/v1"]) - ) - assert result.status == ExecutionStatus.ERROR - assert result.message.startswith("No Redfish endpoints could be read") - assert data is None - assert len(result.events) >= 1 - assert any( - e.category == EventCategory.RUNTIME.value or "Redfish GET failed" in (e.description or "") - for e in result.events - ) - - -def test_redfish_endpoint_collector_mixed_success_fail( - redfish_endpoint_collector, redfish_conn_mock -): - def run_get_side_effect(path): - path_str = str(path) - if "Systems" in path_str: - return RedfishGetResult( - path=path_str if path_str.startswith("/") else "/" + path_str, - success=True, - data={"Id": "1"}, - status_code=200, - ) - return RedfishGetResult( - path=path_str if path_str.startswith("/") else "/" + path_str, - success=False, - error="Not Found", - status_code=404, - ) - - redfish_conn_mock.run_get.side_effect = run_get_side_effect - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs(uris=["/redfish/v1/Systems", "/redfish/v1/Bad"]) - ) - assert result.status == ExecutionStatus.OK - assert data is not None - assert len(data.responses) == 1 - keys = list(data.responses.keys()) - assert any("Systems" in k for k in keys) - assert list(data.responses.values())[0].get("Id") == "1" - - -def test_normalize_path_empty_or_invalid(): - api = "redfish/v1" - assert ec._normalize_path("", api) == "" - assert ec._normalize_path(None, api) == "" # type: ignore[arg-type] - assert ec._normalize_path(" ", api) == "" - - -def test_normalize_path_relative_path(): - api = "redfish/v1" - assert ec._normalize_path("/redfish/v1", api) == "/redfish/v1" - assert ec._normalize_path("/redfish/v1/", api) == "/redfish/v1" - assert ec._normalize_path("redfish/v1/Systems", api) == "/redfish/v1/Systems" - assert ec._normalize_path(" /redfish/v1/Chassis ", api) == "/redfish/v1/Chassis" - - -def test_normalize_path_full_url(): - api = "redfish/v1" - assert ec._normalize_path("https://host/redfish/v1/Systems", api) == "/redfish/v1/Systems" - assert ec._normalize_path("http://bmc/redfish/v1", api) == "/redfish/v1" - - -def test_normalize_path_outside_api_root(): - api = "redfish/v1" - assert ec._normalize_path("/other/root", api) == "" - assert ec._normalize_path("https://host/other/path", api) == "" - - -def test_extract_odata_ids_empty(): - assert ec._extract_odata_ids({}) == [] - assert ec._extract_odata_ids([]) == [] - assert ec._extract_odata_ids("x") == [] - - -def test_extract_odata_ids_single(): - assert ec._extract_odata_ids({"@odata.id": "/redfish/v1"}) == ["/redfish/v1"] - assert ec._extract_odata_ids({"@odata.id": "https://host/redfish/v1"}) == [ - "https://host/redfish/v1" - ] - - -def test_extract_odata_ids_members(): - body = { - "Members": [ - {"@odata.id": "/redfish/v1/Systems/1"}, - {"@odata.id": "/redfish/v1/Systems/2"}, - ] - } - assert set(ec._extract_odata_ids(body)) == {"/redfish/v1/Systems/1", "/redfish/v1/Systems/2"} - - -def test_extract_odata_ids_nested_and_members(): - body = { - "@odata.id": "/redfish/v1", - "Systems": {"@odata.id": "/redfish/v1/Systems", "Members": []}, - "Chassis": { - "Members": [{"@odata.id": "/redfish/v1/Chassis/1"}], - }, - } - ids = ec._extract_odata_ids(body) - assert "/redfish/v1" in ids - assert "/redfish/v1/Systems" in ids - assert "/redfish/v1/Chassis/1" in ids - - -def test_uris_from_args_none(): - assert ec._uris_from_args(None) == [] - - -def test_uris_from_args_empty(): - assert ec._uris_from_args(RedfishEndpointCollectorArgs(uris=[])) == [] - - -def test_uris_from_args_with_uris(): - args = RedfishEndpointCollectorArgs(uris=["/redfish/v1", "/redfish/v1/Systems"]) - assert ec._uris_from_args(args) == ["/redfish/v1", "/redfish/v1/Systems"] - - -def test_fetch_one_calls_run_get(): - conn = MagicMock() - conn.run_get.return_value = RedfishGetResult( - path="/redfish/v1", success=True, data={}, status_code=200 - ) - out = ec._fetch_one(conn, "/redfish/v1") - conn.run_get.assert_called_once_with("/redfish/v1") - assert out.success is True - assert out.path == "/redfish/v1" - - -def test_fetch_one_paged_calls_run_get_paged(): - conn = MagicMock() - conn.run_get_paged.return_value = RedfishGetResult( - path="/redfish/v1/Systems", - success=True, - data={"Members": []}, - status_code=200, - ) - out = ec._fetch_one_paged(conn, "/redfish/v1/Systems", max_pages=42) - conn.run_get_paged.assert_called_once_with("/redfish/v1/Systems", max_pages=42) - assert out.success is True - - -def test_run_redfish_get_paged_appends_artifact_when_enabled( - redfish_endpoint_collector, redfish_conn_mock -): - redfish_conn_mock.run_get_paged.return_value = RedfishGetResult( - path="/redfish/v1/Systems", - success=True, - data={"Members": []}, - status_code=200, - ) - redfish_endpoint_collector.result.artifacts.clear() - res = redfish_endpoint_collector._run_redfish_get_paged( - "/redfish/v1/Systems", max_pages=5, log_artifact=True - ) - redfish_conn_mock.run_get_paged.assert_called_once_with("/redfish/v1/Systems", max_pages=5) - assert res.success is True - assert len(redfish_endpoint_collector.result.artifacts) == 1 - - -def test_run_redfish_get_paged_skips_artifact_when_disabled( - redfish_endpoint_collector, redfish_conn_mock -): - redfish_conn_mock.run_get_paged.return_value = RedfishGetResult( - path="/x", - success=True, - data={"Members": []}, - status_code=200, - ) - redfish_endpoint_collector.result.artifacts.clear() - redfish_endpoint_collector._run_redfish_get_paged("/x", log_artifact=False) - assert len(redfish_endpoint_collector.result.artifacts) == 0 - - -def test_collect_follow_next_link_sequential_uses_run_get_paged( - redfish_endpoint_collector, redfish_conn_mock -): - redfish_conn_mock.run_get_paged.return_value = RedfishGetResult( - path="/redfish/v1/Systems", - success=True, - data={"Members": [{"@odata.id": "/redfish/v1/Systems/1"}], "Members@odata.count": 1}, - status_code=200, - ) - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs( - uris=["/redfish/v1/Systems"], - follow_next_link=True, - max_pages=50, - ) - ) - assert result.status == ExecutionStatus.OK - assert data is not None - assert data.responses["/redfish/v1/Systems"]["Members@odata.count"] == 1 - redfish_conn_mock.run_get_paged.assert_called_once_with("/redfish/v1/Systems", max_pages=50) - redfish_conn_mock.run_get.assert_not_called() - - -def test_collect_follow_next_link_concurrent_uses_connection_copy( - redfish_endpoint_collector, redfish_conn_mock -): - copy_a = MagicMock() - copy_b = MagicMock() - copies = [copy_a, copy_b] - - def next_copy(): - return copies.pop(0) if copies else MagicMock() - - redfish_conn_mock.copy.side_effect = next_copy - - copy_a.run_get_paged.return_value = RedfishGetResult( - path="/a", - success=True, - data={"Members": []}, - status_code=200, - ) - copy_b.run_get_paged.return_value = RedfishGetResult( - path="/b", - success=True, - data={"Members": []}, - status_code=200, - ) - - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs( - uris=["/a", "/b"], - max_workers=2, - follow_next_link=True, - max_pages=10, - ) - ) - assert result.status == ExecutionStatus.OK - assert data is not None - assert len(data.responses) == 2 - copy_a.run_get_paged.assert_called_once_with("/a", max_pages=10) - copy_b.run_get_paged.assert_called_once_with("/b", max_pages=10) - assert redfish_conn_mock.copy.call_count == 2 - assert len(result.artifacts) == 2 - - -def test_discover_tree_single_root(): - conn = MagicMock() - conn.run_get.return_value = RedfishGetResult( - path="/redfish/v1", - success=True, - data={"@odata.id": "/redfish/v1", "Name": "Root"}, - status_code=200, - ) - paths, responses, results = ec._discover_tree( - conn, api_root="redfish/v1", max_depth=2, max_endpoints=0 - ) - assert paths == ["/redfish/v1"] - assert list(responses.keys()) == ["/redfish/v1"] - assert responses["/redfish/v1"]["Name"] == "Root" - assert len(results) == 1 - conn.run_get.assert_called_once_with("/redfish/v1") - - -def test_discover_tree_follows_links(): - conn = MagicMock() - root_data = { - "@odata.id": "/redfish/v1", - "Systems": {"@odata.id": "/redfish/v1/Systems"}, - } - systems_data = { - "@odata.id": "/redfish/v1/Systems", - "Members": [{"@odata.id": "/redfish/v1/Systems/1"}], - } - system1_data = {"@odata.id": "/redfish/v1/Systems/1", "Id": "1"} - - def run_get(path): - if path == "/redfish/v1": - return RedfishGetResult(path=path, success=True, data=root_data, status_code=200) - if path == "/redfish/v1/Systems": - return RedfishGetResult(path=path, success=True, data=systems_data, status_code=200) - if path == "/redfish/v1/Systems/1": - return RedfishGetResult(path=path, success=True, data=system1_data, status_code=200) - return RedfishGetResult(path=path, success=False, error="Not Found", status_code=404) - - conn.run_get.side_effect = run_get - paths, responses, results = ec._discover_tree( - conn, api_root="redfish/v1", max_depth=3, max_endpoints=0 - ) - assert "/redfish/v1" in paths - assert "/redfish/v1/Systems" in paths - assert "/redfish/v1/Systems/1" in paths - assert responses["/redfish/v1"]["@odata.id"] == "/redfish/v1" - assert responses["/redfish/v1/Systems"]["@odata.id"] == "/redfish/v1/Systems" - assert responses["/redfish/v1/Systems/1"]["Id"] == "1" - assert len(results) >= 3 - - -def test_discover_tree_respects_max_depth(): - conn = MagicMock() - root_data = {"@odata.id": "/redfish/v1", "Systems": {"@odata.id": "/redfish/v1/Systems"}} - systems_data = { - "@odata.id": "/redfish/v1/Systems", - "Members": [{"@odata.id": "/redfish/v1/Systems/1"}], - } - - def run_get(path): - if path == "/redfish/v1": - return RedfishGetResult(path=path, success=True, data=root_data, status_code=200) - if path == "/redfish/v1/Systems": - return RedfishGetResult(path=path, success=True, data=systems_data, status_code=200) - return RedfishGetResult(path=path, success=True, data={}, status_code=200) - - conn.run_get.side_effect = run_get - paths, responses, results = ec._discover_tree( - conn, api_root="redfish/v1", max_depth=1, max_endpoints=0 - ) - assert "/redfish/v1" in paths - assert "/redfish/v1/Systems" not in paths - assert len(responses) == 1 - - -def test_discover_tree_respects_max_endpoints(): - conn = MagicMock() - conn.run_get.return_value = RedfishGetResult( - path="/redfish/v1", - success=True, - data={"@odata.id": "/redfish/v1", "Systems": {"@odata.id": "/redfish/v1/Systems"}}, - status_code=200, - ) - paths, responses, results = ec._discover_tree( - conn, api_root="redfish/v1", max_depth=5, max_endpoints=1 - ) - assert len(paths) == 1 - assert len(responses) == 1 - conn.run_get.assert_called_once() - - -def test_collect_data_discover_tree_success(redfish_endpoint_collector, redfish_conn_mock): - redfish_conn_mock.api_root = "redfish/v1" - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/redfish/v1", - success=True, - data={"@odata.id": "/redfish/v1", "Name": "Root"}, - status_code=200, - ) - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs(discover_tree=True) - ) - assert result.status == ExecutionStatus.OK - assert result.message == "Collected 1 Redfish endpoint(s) from tree" - assert data is not None - assert "/redfish/v1" in data.responses - assert data.responses["/redfish/v1"]["Name"] == "Root" - - -def test_collect_data_discover_tree_no_responses(redfish_endpoint_collector, redfish_conn_mock): - redfish_conn_mock.api_root = "redfish/v1" - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/redfish/v1", success=False, error="Connection refused", status_code=None - ) - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs(discover_tree=True) - ) - assert result.status == ExecutionStatus.ERROR - assert result.message.startswith("No Redfish endpoints discovered from tree") - assert data is None - - -def test_collect_data_concurrent_two_uris(redfish_endpoint_collector, redfish_conn_mock): - redfish_conn_mock.copy.return_value = redfish_conn_mock - call_count = 0 - - def run_get(path): - nonlocal call_count - call_count += 1 - return RedfishGetResult( - path=path if path.startswith("/") else "/" + path, - success=True, - data={"path": path}, - status_code=200, - ) - - redfish_conn_mock.run_get.side_effect = run_get - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs( - uris=["/redfish/v1", "/redfish/v1/Systems"], max_workers=2 - ) - ) - assert result.status == ExecutionStatus.OK - assert data is not None - assert len(data.responses) == 2 - assert "/redfish/v1" in data.responses or "redfish/v1" in data.responses - assert any("Systems" in k for k in data.responses) - assert redfish_conn_mock.copy.called - - -@MagicMock -def _ok_post_resp(): - resp = MagicMock() - resp.ok = True - resp.status_code = 200 - resp.content = b"{}" - resp.json.return_value = {} - return resp - - -def test_clear_log_uris_posted_after_successful_collection( - redfish_endpoint_collector, redfish_conn_mock -): - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/redfish/v1", success=True, data={"Name": "Root"}, status_code=200 - ) - resp = MagicMock() - resp.ok = True - resp.status_code = 200 - resp.content = b"{}" - resp.reason = "OK" - redfish_conn_mock.post.return_value = resp - - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs( - uris=["/redfish/v1"], - clear_log_uris=[ - "/redfish/v1/Systems/UBB/LogServices/DiagLogs/Actions/LogService.ClearLog" - ], - ) - ) - assert result.status == ExecutionStatus.OK - redfish_conn_mock.post.assert_called_once_with( - "/redfish/v1/Systems/UBB/LogServices/DiagLogs/Actions/LogService.ClearLog", - json={}, - ) - - -def test_clear_log_uris_failure_downgrades_to_warning( - redfish_endpoint_collector, redfish_conn_mock -): - from nodescraper.enums import EventPriority - - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/redfish/v1", success=True, data={"Name": "Root"}, status_code=200 - ) - resp = MagicMock() - resp.ok = False - resp.status_code = 500 - resp.reason = "Internal Server Error" - resp.content = b"" - redfish_conn_mock.post.return_value = resp - - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs( - uris=["/redfish/v1"], - clear_log_uris=["/some/ClearLog"], - ) - ) - assert result.status == ExecutionStatus.WARNING - assert "log store(s) failed to clear" in result.message - warning_events = [e for e in result.events if e.priority == EventPriority.WARNING] - assert any("Failed to clear" in e.description for e in warning_events) - - -def test_clear_log_uris_not_posted_when_passive(system_info, redfish_conn_mock): - from nodescraper.enums import SystemInteractionLevel - - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/redfish/v1", success=True, data={"Name": "Root"}, status_code=200 - ) - from nodescraper.plugins.ooband.redfish_endpoint import RedfishEndpointCollector - - collector = RedfishEndpointCollector( - system_info=system_info, - connection=redfish_conn_mock, - system_interaction_level=SystemInteractionLevel.PASSIVE, - ) - result, data = collector.collect_data( - args=RedfishEndpointCollectorArgs( - uris=["/redfish/v1"], - clear_log_uris=["/some/ClearLog"], - ) - ) - assert result.status == ExecutionStatus.OK - redfish_conn_mock.post.assert_not_called() - - -def test_clear_log_uris_empty_list_no_post(redfish_endpoint_collector, redfish_conn_mock): - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/redfish/v1", success=True, data={"Name": "Root"}, status_code=200 - ) - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs(uris=["/redfish/v1"], clear_log_uris=[]) - ) - assert result.status == ExecutionStatus.OK - redfish_conn_mock.post.assert_not_called() - - -def test_clear_log_uris_not_posted_when_collection_fails( - redfish_endpoint_collector, redfish_conn_mock -): - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/redfish/v1", success=False, error="Timeout", status_code=None - ) - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs( - uris=["/redfish/v1"], - clear_log_uris=["/some/ClearLog"], - ) - ) - assert result.status == ExecutionStatus.ERROR - redfish_conn_mock.post.assert_not_called() - - -def test_clear_log_uris_multiple_uris_all_posted(redfish_endpoint_collector, redfish_conn_mock): - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/redfish/v1", success=True, data={"Name": "Root"}, status_code=200 - ) - resp = MagicMock() - resp.ok = True - resp.status_code = 200 - resp.content = b"{}" - resp.reason = "OK" - redfish_conn_mock.post.return_value = resp - - clear_uris = ["/clear/a", "/clear/b"] - result, data = redfish_endpoint_collector.collect_data( - args=RedfishEndpointCollectorArgs(uris=["/redfish/v1"], clear_log_uris=clear_uris) - ) - assert result.status == ExecutionStatus.OK - assert redfish_conn_mock.post.call_count == 2 - posted_paths = {call.args[0] for call in redfish_conn_mock.post.call_args_list} - assert "/clear/a" in posted_paths - assert "/clear/b" in posted_paths +############################################################################### +# +# 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 unittest.mock import MagicMock + +import pytest + +from nodescraper.connection.redfish import RedfishGetResult +from nodescraper.enums import EventCategory, ExecutionStatus +from nodescraper.plugins.ooband.redfish_endpoint import ( + RedfishEndpointCollector, + RedfishEndpointCollectorArgs, +) +from nodescraper.plugins.ooband.redfish_endpoint import endpoint_collector as ec + + +@pytest.fixture +def redfish_endpoint_collector(system_info, redfish_conn_mock): + return RedfishEndpointCollector( + system_info=system_info, + connection=redfish_conn_mock, + ) + + +def test_redfish_endpoint_collector_no_uris(redfish_endpoint_collector): + result, data = redfish_endpoint_collector.collect_data() + assert result.status == ExecutionStatus.NOT_RAN + assert "No collection mode configured" in result.message + assert data is None + + +def test_redfish_endpoint_collector_no_uris_with_args(redfish_endpoint_collector): + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=[]) + ) + assert result.status == ExecutionStatus.NOT_RAN + assert "No collection mode configured" in result.message + assert data is None + + +def test_redfish_endpoint_collector_one_uri_success(redfish_endpoint_collector, redfish_conn_mock): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", + success=True, + data={"Name": "Root"}, + status_code=200, + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=["/redfish/v1"]) + ) + assert result.status == ExecutionStatus.OK + assert result.message == "Collected 1 Redfish endpoint(s)" + assert data is not None + assert data.responses["/redfish/v1"]["Name"] == "Root" + redfish_conn_mock.run_get.assert_called_once() + call_path = redfish_conn_mock.run_get.call_args[0][0] + assert call_path == "/redfish/v1" or call_path.strip("/") == "redfish/v1" + + +def test_redfish_endpoint_collector_uri_normalized_with_leading_slash( + redfish_endpoint_collector, redfish_conn_mock +): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1/Systems", + success=True, + data={"Members": []}, + status_code=200, + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=["redfish/v1/Systems"]) + ) + assert result.status == ExecutionStatus.OK + assert data is not None + assert "/redfish/v1/Systems" in data.responses or "redfish/v1/Systems" in data.responses + + +def test_redfish_endpoint_collector_one_fail_no_success( + redfish_endpoint_collector, redfish_conn_mock +): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", + success=False, + error="Connection refused", + status_code=None, + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=["/redfish/v1"]) + ) + assert result.status == ExecutionStatus.ERROR + assert result.message.startswith("No Redfish endpoints could be read") + assert data is None + assert len(result.events) >= 1 + assert any( + e.category == EventCategory.RUNTIME.value or "Redfish GET failed" in (e.description or "") + for e in result.events + ) + + +def test_redfish_endpoint_collector_mixed_success_fail( + redfish_endpoint_collector, redfish_conn_mock +): + def run_get_side_effect(path): + path_str = str(path) + if "Systems" in path_str: + return RedfishGetResult( + path=path_str if path_str.startswith("/") else "/" + path_str, + success=True, + data={"Id": "1"}, + status_code=200, + ) + return RedfishGetResult( + path=path_str if path_str.startswith("/") else "/" + path_str, + success=False, + error="Not Found", + status_code=404, + ) + + redfish_conn_mock.run_get.side_effect = run_get_side_effect + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=["/redfish/v1/Systems", "/redfish/v1/Bad"]) + ) + assert result.status == ExecutionStatus.OK + assert data is not None + assert len(data.responses) == 1 + keys = list(data.responses.keys()) + assert any("Systems" in k for k in keys) + assert list(data.responses.values())[0].get("Id") == "1" + + +def test_normalize_path_empty_or_invalid(): + api = "redfish/v1" + assert ec._normalize_path("", api) == "" + assert ec._normalize_path(None, api) == "" # type: ignore[arg-type] + assert ec._normalize_path(" ", api) == "" + + +def test_normalize_path_relative_path(): + api = "redfish/v1" + assert ec._normalize_path("/redfish/v1", api) == "/redfish/v1" + assert ec._normalize_path("/redfish/v1/", api) == "/redfish/v1" + assert ec._normalize_path("redfish/v1/Systems", api) == "/redfish/v1/Systems" + assert ec._normalize_path(" /redfish/v1/Chassis ", api) == "/redfish/v1/Chassis" + + +def test_normalize_path_full_url(): + api = "redfish/v1" + assert ec._normalize_path("https://host/redfish/v1/Systems", api) == "/redfish/v1/Systems" + assert ec._normalize_path("http://bmc/redfish/v1", api) == "/redfish/v1" + + +def test_normalize_path_outside_api_root(): + api = "redfish/v1" + assert ec._normalize_path("/other/root", api) == "" + assert ec._normalize_path("https://host/other/path", api) == "" + + +def test_extract_odata_ids_empty(): + assert ec._extract_odata_ids({}) == [] + assert ec._extract_odata_ids([]) == [] + assert ec._extract_odata_ids("x") == [] + + +def test_extract_odata_ids_single(): + assert ec._extract_odata_ids({"@odata.id": "/redfish/v1"}) == ["/redfish/v1"] + assert ec._extract_odata_ids({"@odata.id": "https://host/redfish/v1"}) == [ + "https://host/redfish/v1" + ] + + +def test_extract_odata_ids_members(): + body = { + "Members": [ + {"@odata.id": "/redfish/v1/Systems/1"}, + {"@odata.id": "/redfish/v1/Systems/2"}, + ] + } + assert set(ec._extract_odata_ids(body)) == {"/redfish/v1/Systems/1", "/redfish/v1/Systems/2"} + + +def test_extract_odata_ids_nested_and_members(): + body = { + "@odata.id": "/redfish/v1", + "Systems": {"@odata.id": "/redfish/v1/Systems", "Members": []}, + "Chassis": { + "Members": [{"@odata.id": "/redfish/v1/Chassis/1"}], + }, + } + ids = ec._extract_odata_ids(body) + assert "/redfish/v1" in ids + assert "/redfish/v1/Systems" in ids + assert "/redfish/v1/Chassis/1" in ids + + +def test_uris_from_args_none(): + assert ec._uris_from_args(None) == [] + + +def test_uris_from_args_empty(): + assert ec._uris_from_args(RedfishEndpointCollectorArgs(uris=[])) == [] + + +def test_uris_from_args_with_uris(): + args = RedfishEndpointCollectorArgs(uris=["/redfish/v1", "/redfish/v1/Systems"]) + assert ec._uris_from_args(args) == ["/redfish/v1", "/redfish/v1/Systems"] + + +def test_fetch_one_calls_run_get(): + conn = MagicMock() + conn.run_get.return_value = RedfishGetResult( + path="/redfish/v1", success=True, data={}, status_code=200 + ) + out = ec._fetch_one(conn, "/redfish/v1") + conn.run_get.assert_called_once_with("/redfish/v1") + assert out.success is True + assert out.path == "/redfish/v1" + + +def test_fetch_one_paged_calls_run_get_paged(): + conn = MagicMock() + conn.run_get_paged.return_value = RedfishGetResult( + path="/redfish/v1/Systems", + success=True, + data={"Members": []}, + status_code=200, + ) + out = ec._fetch_one_paged(conn, "/redfish/v1/Systems", max_pages=42) + conn.run_get_paged.assert_called_once_with("/redfish/v1/Systems", max_pages=42) + assert out.success is True + + +def test_run_redfish_get_paged_appends_artifact_when_enabled( + redfish_endpoint_collector, redfish_conn_mock +): + redfish_conn_mock.run_get_paged.return_value = RedfishGetResult( + path="/redfish/v1/Systems", + success=True, + data={"Members": []}, + status_code=200, + ) + redfish_endpoint_collector.result.artifacts.clear() + res = redfish_endpoint_collector._run_redfish_get_paged( + "/redfish/v1/Systems", max_pages=5, log_artifact=True + ) + redfish_conn_mock.run_get_paged.assert_called_once_with("/redfish/v1/Systems", max_pages=5) + assert res.success is True + assert len(redfish_endpoint_collector.result.artifacts) == 1 + + +def test_run_redfish_get_paged_skips_artifact_when_disabled( + redfish_endpoint_collector, redfish_conn_mock +): + redfish_conn_mock.run_get_paged.return_value = RedfishGetResult( + path="/x", + success=True, + data={"Members": []}, + status_code=200, + ) + redfish_endpoint_collector.result.artifacts.clear() + redfish_endpoint_collector._run_redfish_get_paged("/x", log_artifact=False) + assert len(redfish_endpoint_collector.result.artifacts) == 0 + + +def test_collect_follow_next_link_sequential_uses_run_get_paged( + redfish_endpoint_collector, redfish_conn_mock +): + redfish_conn_mock.run_get_paged.return_value = RedfishGetResult( + path="/redfish/v1/Systems", + success=True, + data={"Members": [{"@odata.id": "/redfish/v1/Systems/1"}], "Members@odata.count": 1}, + status_code=200, + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs( + uris=["/redfish/v1/Systems"], + follow_next_link=True, + max_pages=50, + ) + ) + assert result.status == ExecutionStatus.OK + assert data is not None + assert data.responses["/redfish/v1/Systems"]["Members@odata.count"] == 1 + redfish_conn_mock.run_get_paged.assert_called_once_with("/redfish/v1/Systems", max_pages=50) + redfish_conn_mock.run_get.assert_not_called() + + +def test_collect_follow_next_link_concurrent_uses_connection_copy( + redfish_endpoint_collector, redfish_conn_mock +): + copy_a = MagicMock() + copy_b = MagicMock() + copies = [copy_a, copy_b] + + def next_copy(): + return copies.pop(0) if copies else MagicMock() + + redfish_conn_mock.copy.side_effect = next_copy + + copy_a.run_get_paged.return_value = RedfishGetResult( + path="/a", + success=True, + data={"Members": []}, + status_code=200, + ) + copy_b.run_get_paged.return_value = RedfishGetResult( + path="/b", + success=True, + data={"Members": []}, + status_code=200, + ) + + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs( + uris=["/a", "/b"], + max_workers=2, + follow_next_link=True, + max_pages=10, + ) + ) + assert result.status == ExecutionStatus.OK + assert data is not None + assert len(data.responses) == 2 + copy_a.run_get_paged.assert_called_once_with("/a", max_pages=10) + copy_b.run_get_paged.assert_called_once_with("/b", max_pages=10) + assert redfish_conn_mock.copy.call_count == 2 + assert len(result.artifacts) == 2 + + +def test_discover_tree_single_root(): + conn = MagicMock() + conn.run_get.return_value = RedfishGetResult( + path="/redfish/v1", + success=True, + data={"@odata.id": "/redfish/v1", "Name": "Root"}, + status_code=200, + ) + paths, responses, results = ec._discover_tree( + conn, api_root="redfish/v1", max_depth=2, max_endpoints=0 + ) + assert paths == ["/redfish/v1"] + assert list(responses.keys()) == ["/redfish/v1"] + assert responses["/redfish/v1"]["Name"] == "Root" + assert len(results) == 1 + conn.run_get.assert_called_once_with("/redfish/v1") + + +def test_discover_tree_follows_links(): + conn = MagicMock() + root_data = { + "@odata.id": "/redfish/v1", + "Systems": {"@odata.id": "/redfish/v1/Systems"}, + } + systems_data = { + "@odata.id": "/redfish/v1/Systems", + "Members": [{"@odata.id": "/redfish/v1/Systems/1"}], + } + system1_data = {"@odata.id": "/redfish/v1/Systems/1", "Id": "1"} + + def run_get(path): + if path == "/redfish/v1": + return RedfishGetResult(path=path, success=True, data=root_data, status_code=200) + if path == "/redfish/v1/Systems": + return RedfishGetResult(path=path, success=True, data=systems_data, status_code=200) + if path == "/redfish/v1/Systems/1": + return RedfishGetResult(path=path, success=True, data=system1_data, status_code=200) + return RedfishGetResult(path=path, success=False, error="Not Found", status_code=404) + + conn.run_get.side_effect = run_get + paths, responses, results = ec._discover_tree( + conn, api_root="redfish/v1", max_depth=3, max_endpoints=0 + ) + assert "/redfish/v1" in paths + assert "/redfish/v1/Systems" in paths + assert "/redfish/v1/Systems/1" in paths + assert responses["/redfish/v1"]["@odata.id"] == "/redfish/v1" + assert responses["/redfish/v1/Systems"]["@odata.id"] == "/redfish/v1/Systems" + assert responses["/redfish/v1/Systems/1"]["Id"] == "1" + assert len(results) >= 3 + + +def test_discover_tree_respects_max_depth(): + conn = MagicMock() + root_data = {"@odata.id": "/redfish/v1", "Systems": {"@odata.id": "/redfish/v1/Systems"}} + systems_data = { + "@odata.id": "/redfish/v1/Systems", + "Members": [{"@odata.id": "/redfish/v1/Systems/1"}], + } + + def run_get(path): + if path == "/redfish/v1": + return RedfishGetResult(path=path, success=True, data=root_data, status_code=200) + if path == "/redfish/v1/Systems": + return RedfishGetResult(path=path, success=True, data=systems_data, status_code=200) + return RedfishGetResult(path=path, success=True, data={}, status_code=200) + + conn.run_get.side_effect = run_get + paths, responses, results = ec._discover_tree( + conn, api_root="redfish/v1", max_depth=1, max_endpoints=0 + ) + assert "/redfish/v1" in paths + assert "/redfish/v1/Systems" not in paths + assert len(responses) == 1 + + +def test_discover_tree_respects_max_endpoints(): + conn = MagicMock() + conn.run_get.return_value = RedfishGetResult( + path="/redfish/v1", + success=True, + data={"@odata.id": "/redfish/v1", "Systems": {"@odata.id": "/redfish/v1/Systems"}}, + status_code=200, + ) + paths, responses, results = ec._discover_tree( + conn, api_root="redfish/v1", max_depth=5, max_endpoints=1 + ) + assert len(paths) == 1 + assert len(responses) == 1 + conn.run_get.assert_called_once() + + +def test_collect_data_discover_tree_success(redfish_endpoint_collector, redfish_conn_mock): + redfish_conn_mock.api_root = "redfish/v1" + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", + success=True, + data={"@odata.id": "/redfish/v1", "Name": "Root"}, + status_code=200, + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(discover_tree=True) + ) + assert result.status == ExecutionStatus.OK + assert result.message == "Collected 1 Redfish endpoint(s) from tree" + assert data is not None + assert "/redfish/v1" in data.responses + assert data.responses["/redfish/v1"]["Name"] == "Root" + + +def test_collect_data_discover_tree_no_responses(redfish_endpoint_collector, redfish_conn_mock): + redfish_conn_mock.api_root = "redfish/v1" + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", success=False, error="Connection refused", status_code=None + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(discover_tree=True) + ) + assert result.status == ExecutionStatus.ERROR + assert result.message.startswith("No Redfish endpoints discovered from tree") + assert data is None + + +def test_collect_data_concurrent_two_uris(redfish_endpoint_collector, redfish_conn_mock): + redfish_conn_mock.copy.return_value = redfish_conn_mock + call_count = 0 + + def run_get(path): + nonlocal call_count + call_count += 1 + return RedfishGetResult( + path=path if path.startswith("/") else "/" + path, + success=True, + data={"path": path}, + status_code=200, + ) + + redfish_conn_mock.run_get.side_effect = run_get + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs( + uris=["/redfish/v1", "/redfish/v1/Systems"], max_workers=2 + ) + ) + assert result.status == ExecutionStatus.OK + assert data is not None + assert len(data.responses) == 2 + assert "/redfish/v1" in data.responses or "redfish/v1" in data.responses + assert any("Systems" in k for k in data.responses) + assert redfish_conn_mock.copy.called + + +@MagicMock +def _ok_post_resp(): + resp = MagicMock() + resp.ok = True + resp.status_code = 200 + resp.content = b"{}" + resp.json.return_value = {} + return resp + + +def test_clear_log_uris_posted_after_successful_collection( + redfish_endpoint_collector, redfish_conn_mock +): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", success=True, data={"Name": "Root"}, status_code=200 + ) + resp = MagicMock() + resp.ok = True + resp.status_code = 200 + resp.content = b"{}" + resp.reason = "OK" + redfish_conn_mock.post.return_value = resp + + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs( + uris=["/redfish/v1"], + clear_log_uris=[ + "/redfish/v1/Systems/UBB/LogServices/DiagLogs/Actions/LogService.ClearLog" + ], + ) + ) + assert result.status == ExecutionStatus.OK + redfish_conn_mock.post.assert_called_once_with( + "/redfish/v1/Systems/UBB/LogServices/DiagLogs/Actions/LogService.ClearLog", + json={}, + ) + + +def test_clear_log_uris_failure_downgrades_to_warning( + redfish_endpoint_collector, redfish_conn_mock +): + from nodescraper.enums import EventPriority + + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", success=True, data={"Name": "Root"}, status_code=200 + ) + resp = MagicMock() + resp.ok = False + resp.status_code = 500 + resp.reason = "Internal Server Error" + resp.content = b"" + redfish_conn_mock.post.return_value = resp + + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs( + uris=["/redfish/v1"], + clear_log_uris=["/some/ClearLog"], + ) + ) + assert result.status == ExecutionStatus.WARNING + assert "log store(s) failed to clear" in result.message + warning_events = [e for e in result.events if e.priority == EventPriority.WARNING] + assert any("Failed to clear" in e.description for e in warning_events) + + +def test_clear_log_uris_not_posted_when_passive(system_info, redfish_conn_mock): + from nodescraper.enums import SystemInteractionLevel + + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", success=True, data={"Name": "Root"}, status_code=200 + ) + from nodescraper.plugins.ooband.redfish_endpoint import RedfishEndpointCollector + + collector = RedfishEndpointCollector( + system_info=system_info, + connection=redfish_conn_mock, + system_interaction_level=SystemInteractionLevel.PASSIVE, + ) + result, data = collector.collect_data( + args=RedfishEndpointCollectorArgs( + uris=["/redfish/v1"], + clear_log_uris=["/some/ClearLog"], + ) + ) + assert result.status == ExecutionStatus.OK + redfish_conn_mock.post.assert_not_called() + + +def test_clear_log_uris_empty_list_no_post(redfish_endpoint_collector, redfish_conn_mock): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", success=True, data={"Name": "Root"}, status_code=200 + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=["/redfish/v1"], clear_log_uris=[]) + ) + assert result.status == ExecutionStatus.OK + redfish_conn_mock.post.assert_not_called() + + +def test_clear_log_uris_not_posted_when_collection_fails( + redfish_endpoint_collector, redfish_conn_mock +): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", success=False, error="Timeout", status_code=None + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs( + uris=["/redfish/v1"], + clear_log_uris=["/some/ClearLog"], + ) + ) + assert result.status == ExecutionStatus.ERROR + redfish_conn_mock.post.assert_not_called() + + +def test_clear_log_uris_multiple_uris_all_posted(redfish_endpoint_collector, redfish_conn_mock): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", success=True, data={"Name": "Root"}, status_code=200 + ) + resp = MagicMock() + resp.ok = True + resp.status_code = 200 + resp.content = b"{}" + resp.reason = "OK" + redfish_conn_mock.post.return_value = resp + + clear_uris = ["/clear/a", "/clear/b"] + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=["/redfish/v1"], clear_log_uris=clear_uris) + ) + assert result.status == ExecutionStatus.OK + assert redfish_conn_mock.post.call_count == 2 + posted_paths = {call.args[0] for call in redfish_conn_mock.post.call_args_list} + assert "/clear/a" in posted_paths + assert "/clear/b" in posted_paths diff --git a/test/unit/plugin/test_redfish_endpoint_plugin.py b/test/unit/plugin/test_redfish_endpoint_plugin.py index 6ca56f6b..5487137a 100644 --- a/test/unit/plugin/test_redfish_endpoint_plugin.py +++ b/test/unit/plugin/test_redfish_endpoint_plugin.py @@ -1,66 +1,66 @@ -############################################################################### -# -# MIT License -# -# Copyright (c) 2026 Advanced Micro Devices, Inc. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -############################################################################### -from nodescraper.plugins.ooband.redfish_endpoint import ( - RedfishEndpointAnalyzer, - RedfishEndpointAnalyzerArgs, - RedfishEndpointCollector, - RedfishEndpointCollectorArgs, - RedfishEndpointDataModel, - RedfishEndpointPlugin, -) - - -def test_redfish_endpoint_collector_args_default(): - args = RedfishEndpointCollectorArgs() - assert args.uris == [] - - -def test_redfish_endpoint_collector_args_uris_stripped(): - args = RedfishEndpointCollectorArgs(uris=[" /redfish/v1 ", "/Systems/1 ", " Chassis "]) - assert args.uris == ["/redfish/v1", "/Systems/1", "Chassis"] - - -def test_redfish_endpoint_collector_args_uris_empty_list(): - args = RedfishEndpointCollectorArgs(uris=[]) - assert args.uris == [] - - -def test_redfish_endpoint_data_model_default(): - model = RedfishEndpointDataModel() - assert model.responses == {} - - -def test_redfish_endpoint_data_model_responses(): - model = RedfishEndpointDataModel(responses={"/redfish/v1": {"Name": "Root"}}) - assert model.responses["/redfish/v1"]["Name"] == "Root" - - -def test_redfish_endpoint_plugin_class_attributes(): - assert RedfishEndpointPlugin.DATA_MODEL is RedfishEndpointDataModel - assert RedfishEndpointPlugin.COLLECTOR is RedfishEndpointCollector - assert RedfishEndpointPlugin.ANALYZER is RedfishEndpointAnalyzer - assert RedfishEndpointPlugin.COLLECTOR_ARGS is RedfishEndpointCollectorArgs - assert RedfishEndpointPlugin.ANALYZER_ARGS is RedfishEndpointAnalyzerArgs +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from nodescraper.plugins.ooband.redfish_endpoint import ( + RedfishEndpointAnalyzer, + RedfishEndpointAnalyzerArgs, + RedfishEndpointCollector, + RedfishEndpointCollectorArgs, + RedfishEndpointDataModel, + RedfishEndpointPlugin, +) + + +def test_redfish_endpoint_collector_args_default(): + args = RedfishEndpointCollectorArgs() + assert args.uris == [] + + +def test_redfish_endpoint_collector_args_uris_stripped(): + args = RedfishEndpointCollectorArgs(uris=[" /redfish/v1 ", "/Systems/1 ", " Chassis "]) + assert args.uris == ["/redfish/v1", "/Systems/1", "Chassis"] + + +def test_redfish_endpoint_collector_args_uris_empty_list(): + args = RedfishEndpointCollectorArgs(uris=[]) + assert args.uris == [] + + +def test_redfish_endpoint_data_model_default(): + model = RedfishEndpointDataModel() + assert model.responses == {} + + +def test_redfish_endpoint_data_model_responses(): + model = RedfishEndpointDataModel(responses={"/redfish/v1": {"Name": "Root"}}) + assert model.responses["/redfish/v1"]["Name"] == "Root" + + +def test_redfish_endpoint_plugin_class_attributes(): + assert RedfishEndpointPlugin.DATA_MODEL is RedfishEndpointDataModel + assert RedfishEndpointPlugin.COLLECTOR is RedfishEndpointCollector + assert RedfishEndpointPlugin.ANALYZER is RedfishEndpointAnalyzer + assert RedfishEndpointPlugin.COLLECTOR_ARGS is RedfishEndpointCollectorArgs + assert RedfishEndpointPlugin.ANALYZER_ARGS is RedfishEndpointAnalyzerArgs diff --git a/test/unit/plugin/test_redfish_oem_diag_analyzer.py b/test/unit/plugin/test_redfish_oem_diag_analyzer.py index a8d6f1f7..15de0a41 100644 --- a/test/unit/plugin/test_redfish_oem_diag_analyzer.py +++ b/test/unit/plugin/test_redfish_oem_diag_analyzer.py @@ -1,114 +1,114 @@ -############################################################################### -# -# MIT License -# -# Copyright (c) 2026 Advanced Micro Devices, Inc. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -############################################################################### -import pytest - -from nodescraper.enums import ExecutionStatus -from nodescraper.plugins.ooband.redfish_oem_diag import ( - RedfishOemDiagAnalyzer, - RedfishOemDiagAnalyzerArgs, - RedfishOemDiagDataModel, -) -from nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_data import OemDiagTypeResult - - -@pytest.fixture -def redfish_oem_diag_analyzer(system_info): - return RedfishOemDiagAnalyzer(system_info=system_info) - - -def test_redfish_oem_diag_analyzer_no_results(redfish_oem_diag_analyzer): - data = RedfishOemDiagDataModel(results={}) - result = redfish_oem_diag_analyzer.analyze_data(data) - assert result.status == ExecutionStatus.NOT_RAN - assert result.message == "No OEM diagnostic results to analyze" - - -def test_redfish_oem_diag_analyzer_all_success(redfish_oem_diag_analyzer): - data = RedfishOemDiagDataModel( - results={ - "JournalControl": OemDiagTypeResult(success=True, error=None, metadata={}), - "AllLogs": OemDiagTypeResult(success=True, error=None, metadata={}), - } - ) - result = redfish_oem_diag_analyzer.analyze_data(data) - assert result.status == ExecutionStatus.OK - assert "2/2 types collected" in result.message - - -def test_redfish_oem_diag_analyzer_some_failed_without_require_all(redfish_oem_diag_analyzer): - data = RedfishOemDiagDataModel( - results={ - "JournalControl": OemDiagTypeResult(success=True, error=None, metadata={}), - "AllLogs": OemDiagTypeResult(success=False, error="Timeout", metadata=None), - } - ) - result = redfish_oem_diag_analyzer.analyze_data(data) - assert result.status == ExecutionStatus.OK - assert "1/2 types collected" in result.message - - -def test_redfish_oem_diag_analyzer_some_failed_with_require_all_success(redfish_oem_diag_analyzer): - data = RedfishOemDiagDataModel( - results={ - "JournalControl": OemDiagTypeResult(success=True, error=None, metadata={}), - "AllLogs": OemDiagTypeResult(success=False, error="Task timeout", metadata=None), - } - ) - args = RedfishOemDiagAnalyzerArgs(require_all_success=True) - result = redfish_oem_diag_analyzer.analyze_data(data, args=args) - assert result.status == ExecutionStatus.ERROR - assert "1 type(s) failed" in result.message - assert "AllLogs" in result.message - assert len(result.events) >= 1 - assert any( - "AllLogs" in (e.description or "") or "failed" in (e.description or "").lower() - for e in result.events - ) - - -def test_redfish_oem_diag_analyzer_all_failed_require_all_success(redfish_oem_diag_analyzer): - data = RedfishOemDiagDataModel( - results={ - "JournalControl": OemDiagTypeResult(success=False, error="Err1", metadata=None), - "AllLogs": OemDiagTypeResult(success=False, error="Err2", metadata=None), - } - ) - args = RedfishOemDiagAnalyzerArgs(require_all_success=True) - result = redfish_oem_diag_analyzer.analyze_data(data, args=args) - assert result.status == ExecutionStatus.ERROR - assert "2 type(s) failed" in result.message - - -def test_redfish_oem_diag_analyzer_require_all_success_all_ok(redfish_oem_diag_analyzer): - data = RedfishOemDiagDataModel( - results={ - "JournalControl": OemDiagTypeResult(success=True, error=None, metadata={}), - } - ) - args = RedfishOemDiagAnalyzerArgs(require_all_success=True) - result = redfish_oem_diag_analyzer.analyze_data(data, args=args) - assert result.status == ExecutionStatus.OK - assert "1/1 types collected" in result.message +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +import pytest + +from nodescraper.enums import ExecutionStatus +from nodescraper.plugins.ooband.redfish_oem_diag import ( + RedfishOemDiagAnalyzer, + RedfishOemDiagAnalyzerArgs, + RedfishOemDiagDataModel, +) +from nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_data import OemDiagTypeResult + + +@pytest.fixture +def redfish_oem_diag_analyzer(system_info): + return RedfishOemDiagAnalyzer(system_info=system_info) + + +def test_redfish_oem_diag_analyzer_no_results(redfish_oem_diag_analyzer): + data = RedfishOemDiagDataModel(results={}) + result = redfish_oem_diag_analyzer.analyze_data(data) + assert result.status == ExecutionStatus.NOT_RAN + assert result.message == "No OEM diagnostic results to analyze" + + +def test_redfish_oem_diag_analyzer_all_success(redfish_oem_diag_analyzer): + data = RedfishOemDiagDataModel( + results={ + "JournalControl": OemDiagTypeResult(success=True, error=None, metadata={}), + "AllLogs": OemDiagTypeResult(success=True, error=None, metadata={}), + } + ) + result = redfish_oem_diag_analyzer.analyze_data(data) + assert result.status == ExecutionStatus.OK + assert "2/2 types collected" in result.message + + +def test_redfish_oem_diag_analyzer_some_failed_without_require_all(redfish_oem_diag_analyzer): + data = RedfishOemDiagDataModel( + results={ + "JournalControl": OemDiagTypeResult(success=True, error=None, metadata={}), + "AllLogs": OemDiagTypeResult(success=False, error="Timeout", metadata=None), + } + ) + result = redfish_oem_diag_analyzer.analyze_data(data) + assert result.status == ExecutionStatus.OK + assert "1/2 types collected" in result.message + + +def test_redfish_oem_diag_analyzer_some_failed_with_require_all_success(redfish_oem_diag_analyzer): + data = RedfishOemDiagDataModel( + results={ + "JournalControl": OemDiagTypeResult(success=True, error=None, metadata={}), + "AllLogs": OemDiagTypeResult(success=False, error="Task timeout", metadata=None), + } + ) + args = RedfishOemDiagAnalyzerArgs(require_all_success=True) + result = redfish_oem_diag_analyzer.analyze_data(data, args=args) + assert result.status == ExecutionStatus.ERROR + assert "1 type(s) failed" in result.message + assert "AllLogs" in result.message + assert len(result.events) >= 1 + assert any( + "AllLogs" in (e.description or "") or "failed" in (e.description or "").lower() + for e in result.events + ) + + +def test_redfish_oem_diag_analyzer_all_failed_require_all_success(redfish_oem_diag_analyzer): + data = RedfishOemDiagDataModel( + results={ + "JournalControl": OemDiagTypeResult(success=False, error="Err1", metadata=None), + "AllLogs": OemDiagTypeResult(success=False, error="Err2", metadata=None), + } + ) + args = RedfishOemDiagAnalyzerArgs(require_all_success=True) + result = redfish_oem_diag_analyzer.analyze_data(data, args=args) + assert result.status == ExecutionStatus.ERROR + assert "2 type(s) failed" in result.message + + +def test_redfish_oem_diag_analyzer_require_all_success_all_ok(redfish_oem_diag_analyzer): + data = RedfishOemDiagDataModel( + results={ + "JournalControl": OemDiagTypeResult(success=True, error=None, metadata={}), + } + ) + args = RedfishOemDiagAnalyzerArgs(require_all_success=True) + result = redfish_oem_diag_analyzer.analyze_data(data, args=args) + assert result.status == ExecutionStatus.OK + assert "1/1 types collected" in result.message diff --git a/test/unit/plugin/test_redfish_oem_diag_collector.py b/test/unit/plugin/test_redfish_oem_diag_collector.py index 669ae1c2..f5e70510 100644 --- a/test/unit/plugin/test_redfish_oem_diag_collector.py +++ b/test/unit/plugin/test_redfish_oem_diag_collector.py @@ -1,298 +1,298 @@ -############################################################################### -# -# 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 unittest.mock import patch - -import pytest - -from nodescraper.enums import ExecutionStatus -from nodescraper.plugins.ooband.redfish_oem_diag import ( - RedfishOemDiagCollector, - RedfishOemDiagCollectorArgs, - RedfishOemDiagDataModel, -) - - -@pytest.fixture -def redfish_oem_diag_collector(system_info, redfish_conn_mock): - return RedfishOemDiagCollector( - system_info=system_info, - connection=redfish_conn_mock, - ) - - -def test_redfish_oem_diag_collector_no_types_configured(redfish_oem_diag_collector): - result, data = redfish_oem_diag_collector.collect_data() - assert result.status == ExecutionStatus.NOT_RAN - assert result.message == "No OEM diagnostic types configured" - assert data is None - - -def test_redfish_oem_diag_collector_empty_types_with_args(redfish_oem_diag_collector): - result, data = redfish_oem_diag_collector.collect_data( - args=RedfishOemDiagCollectorArgs(oem_diagnostic_types=[]) - ) - assert result.status == ExecutionStatus.NOT_RAN - assert data is None - - -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") -def test_redfish_oem_diag_collector_one_type_success(mock_collect, redfish_oem_diag_collector): - mock_collect.return_value = (b"log bytes", {"Size": 123}, None) - result, data = redfish_oem_diag_collector.collect_data( - args=RedfishOemDiagCollectorArgs(oem_diagnostic_types=["JournalControl"]) - ) - assert result.status == ExecutionStatus.OK - assert "1/1 types collected" in result.message - assert data is not None - assert isinstance(data, RedfishOemDiagDataModel) - assert "JournalControl" in data.results - assert data.results["JournalControl"].success is True - assert data.results["JournalControl"].error is None - assert data.results["JournalControl"].metadata == {"Size": 123} - mock_collect.assert_called_once() - - -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") -def test_redfish_oem_diag_collector_one_type_failure(mock_collect, redfish_oem_diag_collector): - mock_collect.return_value = (None, None, "Task timeout") - result, data = redfish_oem_diag_collector.collect_data( - args=RedfishOemDiagCollectorArgs(oem_diagnostic_types=["AllLogs"]) - ) - assert result.status == ExecutionStatus.ERROR - assert "0/1 types collected" in result.message - assert data is not None - assert data.results["AllLogs"].success is False - assert data.results["AllLogs"].error == "Task timeout" - - -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") -def test_redfish_oem_diag_collector_mixed_success_fail(mock_collect, redfish_oem_diag_collector): - def side_effect(conn, log_service_path, oem_diagnostic_type, **kwargs): - if oem_diagnostic_type == "JournalControl": - return (b"data", {}, None) - return (None, None, "Not supported") - - mock_collect.side_effect = side_effect - result, data = redfish_oem_diag_collector.collect_data( - args=RedfishOemDiagCollectorArgs(oem_diagnostic_types=["JournalControl", "AllLogs"]) - ) - assert result.status == ExecutionStatus.OK - assert "1/2 types collected" in result.message - assert data.results["JournalControl"].success is True - assert data.results["AllLogs"].success is False - assert data.results["AllLogs"].error == "Not supported" - assert mock_collect.call_count == 2 - - -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") -def test_redfish_oem_diag_collector_passes_args_to_connection( - mock_collect, redfish_oem_diag_collector, redfish_conn_mock -): - mock_collect.return_value = (b"", {}, None) - args = RedfishOemDiagCollectorArgs( - log_service_path="redfish/v1/Systems/1/LogServices/DiagLogs", - oem_diagnostic_types=["JournalControl"], - task_timeout_s=300, - ) - redfish_oem_diag_collector.collect_data(args=args) - mock_collect.assert_called_once() - call_kw = mock_collect.call_args[1] - assert call_kw["log_service_path"] == "redfish/v1/Systems/1/LogServices/DiagLogs" - assert call_kw["oem_diagnostic_type"] == "JournalControl" - assert call_kw["task_timeout_s"] == 300 - assert call_kw["output_dir"] is None - - -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") -def test_redfish_oem_diag_collector_output_dir_is_diag_logs( - mock_collect, system_info, redfish_conn_mock, tmp_path -): - mock_collect.return_value = (b"log bytes", {}, None) - collector = RedfishOemDiagCollector( - system_info=system_info, - connection=redfish_conn_mock, - log_path=str(tmp_path), - ) - collector.collect_data(args=RedfishOemDiagCollectorArgs(oem_diagnostic_types=["AllLogs"])) - output_dir = mock_collect.call_args.kwargs["output_dir"] - assert output_dir == (tmp_path / "diag_logs").resolve() - assert not output_dir.exists() - - -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector._parse_clear_endpoint") -def test_clear_logs_called_after_successful_collection( - mock_parse, mock_clear, mock_collect, redfish_oem_diag_collector, redfish_conn_mock -): - from nodescraper.connection.redfish import RedfishGetResult - from nodescraper.connection.redfish.redfish_clear_log import ( - ClearLogEndpoint, - ClearLogResult, - ) - - mock_collect.return_value = (b"data", {}, None) - mock_parse.return_value = ClearLogEndpoint(path="/clear/path") - mock_clear.return_value = [ClearLogResult(path="/clear/path", success=True, status_code=200)] - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/svc", success=True, data={"Actions": {}}, status_code=200 - ) - - result, data = redfish_oem_diag_collector.collect_data( - args=RedfishOemDiagCollectorArgs( - oem_diagnostic_types=["AllLogs"], - clear_logs_after_collection=True, - ) - ) - assert result.status == ExecutionStatus.OK - mock_clear.assert_called_once() - - -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") -def test_clear_logs_not_called_when_collection_fails( - mock_clear, mock_collect, redfish_oem_diag_collector -): - mock_collect.return_value = (None, None, "Task timeout") - result, data = redfish_oem_diag_collector.collect_data( - args=RedfishOemDiagCollectorArgs( - oem_diagnostic_types=["AllLogs"], - clear_logs_after_collection=True, - ) - ) - assert result.status == ExecutionStatus.ERROR - mock_clear.assert_not_called() - - -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector._parse_clear_endpoint") -def test_clear_logs_called_on_partial_success( - mock_parse, mock_clear, mock_collect, redfish_oem_diag_collector, redfish_conn_mock -): - """Clear runs when at least one type succeeded — mirrors AMC: clear the log service - whose collections (even partial) are complete.""" - from nodescraper.connection.redfish import RedfishGetResult - from nodescraper.connection.redfish.redfish_clear_log import ( - ClearLogEndpoint, - ClearLogResult, - ) - - def side_effect(conn, log_service_path, oem_diagnostic_type, **kwargs): - if oem_diagnostic_type == "AllLogs": - return (b"data", {}, None) - return (None, None, "Not supported") - - mock_collect.side_effect = side_effect - mock_parse.return_value = ClearLogEndpoint(path="/clear/path") - mock_clear.return_value = [ClearLogResult(path="/clear/path", success=True, status_code=200)] - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/svc", success=True, data={"Actions": {}}, status_code=200 - ) - - result, data = redfish_oem_diag_collector.collect_data( - args=RedfishOemDiagCollectorArgs( - oem_diagnostic_types=["AllLogs", "JournalControl"], - clear_logs_after_collection=True, - ) - ) - assert result.status == ExecutionStatus.OK - assert data.results["AllLogs"].success is True - assert data.results["JournalControl"].success is False - mock_clear.assert_called_once() - - -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector._parse_clear_endpoint") -def test_clear_log_failure_downgrades_to_warning( - mock_parse, mock_clear, mock_collect, redfish_oem_diag_collector, redfish_conn_mock -): - from nodescraper.connection.redfish import RedfishGetResult - from nodescraper.connection.redfish.redfish_clear_log import ( - ClearLogEndpoint, - ClearLogResult, - ) - from nodescraper.enums import EventPriority - - mock_collect.return_value = (b"data", {}, None) - mock_parse.return_value = ClearLogEndpoint(path="/clear/path") - mock_clear.return_value = [ - ClearLogResult( - path="/clear/path", success=False, status_code=500, error="POST returned 500" - ) - ] - redfish_conn_mock.run_get.return_value = RedfishGetResult( - path="/svc", success=True, data={"Actions": {}}, status_code=200 - ) - - result, data = redfish_oem_diag_collector.collect_data( - args=RedfishOemDiagCollectorArgs( - oem_diagnostic_types=["AllLogs"], - clear_logs_after_collection=True, - ) - ) - assert result.status == ExecutionStatus.WARNING - assert "log clear failed" in result.message - warning_events = [e for e in result.events if e.priority == EventPriority.WARNING] - assert any("Failed to clear" in e.description for e in warning_events) - - -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") -def test_clear_logs_skipped_when_passive_interaction( - mock_clear, mock_collect, system_info, redfish_conn_mock -): - from nodescraper.enums import SystemInteractionLevel - - mock_collect.return_value = (b"data", {}, None) - collector = RedfishOemDiagCollector( - system_info=system_info, - connection=redfish_conn_mock, - system_interaction_level=SystemInteractionLevel.PASSIVE, - ) - result, data = collector.collect_data( - args=RedfishOemDiagCollectorArgs( - oem_diagnostic_types=["AllLogs"], - clear_logs_after_collection=True, - ) - ) - assert result.status == ExecutionStatus.OK - mock_clear.assert_not_called() - - -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") -@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") -def test_clear_logs_not_called_when_disabled(mock_clear, mock_collect, redfish_oem_diag_collector): - mock_collect.return_value = (b"data", {}, None) - result, data = redfish_oem_diag_collector.collect_data( - args=RedfishOemDiagCollectorArgs( - oem_diagnostic_types=["AllLogs"], - clear_logs_after_collection=False, - ) - ) - assert result.status == ExecutionStatus.OK - mock_clear.assert_not_called() +############################################################################### +# +# 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 unittest.mock import patch + +import pytest + +from nodescraper.enums import ExecutionStatus +from nodescraper.plugins.ooband.redfish_oem_diag import ( + RedfishOemDiagCollector, + RedfishOemDiagCollectorArgs, + RedfishOemDiagDataModel, +) + + +@pytest.fixture +def redfish_oem_diag_collector(system_info, redfish_conn_mock): + return RedfishOemDiagCollector( + system_info=system_info, + connection=redfish_conn_mock, + ) + + +def test_redfish_oem_diag_collector_no_types_configured(redfish_oem_diag_collector): + result, data = redfish_oem_diag_collector.collect_data() + assert result.status == ExecutionStatus.NOT_RAN + assert result.message == "No OEM diagnostic types configured" + assert data is None + + +def test_redfish_oem_diag_collector_empty_types_with_args(redfish_oem_diag_collector): + result, data = redfish_oem_diag_collector.collect_data( + args=RedfishOemDiagCollectorArgs(oem_diagnostic_types=[]) + ) + assert result.status == ExecutionStatus.NOT_RAN + assert data is None + + +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") +def test_redfish_oem_diag_collector_one_type_success(mock_collect, redfish_oem_diag_collector): + mock_collect.return_value = (b"log bytes", {"Size": 123}, None) + result, data = redfish_oem_diag_collector.collect_data( + args=RedfishOemDiagCollectorArgs(oem_diagnostic_types=["JournalControl"]) + ) + assert result.status == ExecutionStatus.OK + assert "1/1 types collected" in result.message + assert data is not None + assert isinstance(data, RedfishOemDiagDataModel) + assert "JournalControl" in data.results + assert data.results["JournalControl"].success is True + assert data.results["JournalControl"].error is None + assert data.results["JournalControl"].metadata == {"Size": 123} + mock_collect.assert_called_once() + + +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") +def test_redfish_oem_diag_collector_one_type_failure(mock_collect, redfish_oem_diag_collector): + mock_collect.return_value = (None, None, "Task timeout") + result, data = redfish_oem_diag_collector.collect_data( + args=RedfishOemDiagCollectorArgs(oem_diagnostic_types=["AllLogs"]) + ) + assert result.status == ExecutionStatus.ERROR + assert "0/1 types collected" in result.message + assert data is not None + assert data.results["AllLogs"].success is False + assert data.results["AllLogs"].error == "Task timeout" + + +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") +def test_redfish_oem_diag_collector_mixed_success_fail(mock_collect, redfish_oem_diag_collector): + def side_effect(conn, log_service_path, oem_diagnostic_type, **kwargs): + if oem_diagnostic_type == "JournalControl": + return (b"data", {}, None) + return (None, None, "Not supported") + + mock_collect.side_effect = side_effect + result, data = redfish_oem_diag_collector.collect_data( + args=RedfishOemDiagCollectorArgs(oem_diagnostic_types=["JournalControl", "AllLogs"]) + ) + assert result.status == ExecutionStatus.OK + assert "1/2 types collected" in result.message + assert data.results["JournalControl"].success is True + assert data.results["AllLogs"].success is False + assert data.results["AllLogs"].error == "Not supported" + assert mock_collect.call_count == 2 + + +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") +def test_redfish_oem_diag_collector_passes_args_to_connection( + mock_collect, redfish_oem_diag_collector, redfish_conn_mock +): + mock_collect.return_value = (b"", {}, None) + args = RedfishOemDiagCollectorArgs( + log_service_path="redfish/v1/Systems/1/LogServices/DiagLogs", + oem_diagnostic_types=["JournalControl"], + task_timeout_s=300, + ) + redfish_oem_diag_collector.collect_data(args=args) + mock_collect.assert_called_once() + call_kw = mock_collect.call_args[1] + assert call_kw["log_service_path"] == "redfish/v1/Systems/1/LogServices/DiagLogs" + assert call_kw["oem_diagnostic_type"] == "JournalControl" + assert call_kw["task_timeout_s"] == 300 + assert call_kw["output_dir"] is None + + +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") +def test_redfish_oem_diag_collector_output_dir_is_diag_logs( + mock_collect, system_info, redfish_conn_mock, tmp_path +): + mock_collect.return_value = (b"log bytes", {}, None) + collector = RedfishOemDiagCollector( + system_info=system_info, + connection=redfish_conn_mock, + log_path=str(tmp_path), + ) + collector.collect_data(args=RedfishOemDiagCollectorArgs(oem_diagnostic_types=["AllLogs"])) + output_dir = mock_collect.call_args.kwargs["output_dir"] + assert output_dir == (tmp_path / "diag_logs").resolve() + assert not output_dir.exists() + + +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector._parse_clear_endpoint") +def test_clear_logs_called_after_successful_collection( + mock_parse, mock_clear, mock_collect, redfish_oem_diag_collector, redfish_conn_mock +): + from nodescraper.connection.redfish import RedfishGetResult + from nodescraper.connection.redfish.redfish_clear_log import ( + ClearLogEndpoint, + ClearLogResult, + ) + + mock_collect.return_value = (b"data", {}, None) + mock_parse.return_value = ClearLogEndpoint(path="/clear/path") + mock_clear.return_value = [ClearLogResult(path="/clear/path", success=True, status_code=200)] + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/svc", success=True, data={"Actions": {}}, status_code=200 + ) + + result, data = redfish_oem_diag_collector.collect_data( + args=RedfishOemDiagCollectorArgs( + oem_diagnostic_types=["AllLogs"], + clear_logs_after_collection=True, + ) + ) + assert result.status == ExecutionStatus.OK + mock_clear.assert_called_once() + + +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") +def test_clear_logs_not_called_when_collection_fails( + mock_clear, mock_collect, redfish_oem_diag_collector +): + mock_collect.return_value = (None, None, "Task timeout") + result, data = redfish_oem_diag_collector.collect_data( + args=RedfishOemDiagCollectorArgs( + oem_diagnostic_types=["AllLogs"], + clear_logs_after_collection=True, + ) + ) + assert result.status == ExecutionStatus.ERROR + mock_clear.assert_not_called() + + +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector._parse_clear_endpoint") +def test_clear_logs_called_on_partial_success( + mock_parse, mock_clear, mock_collect, redfish_oem_diag_collector, redfish_conn_mock +): + """Clear runs when at least one type succeeded — mirrors AMC: clear the log service + whose collections (even partial) are complete.""" + from nodescraper.connection.redfish import RedfishGetResult + from nodescraper.connection.redfish.redfish_clear_log import ( + ClearLogEndpoint, + ClearLogResult, + ) + + def side_effect(conn, log_service_path, oem_diagnostic_type, **kwargs): + if oem_diagnostic_type == "AllLogs": + return (b"data", {}, None) + return (None, None, "Not supported") + + mock_collect.side_effect = side_effect + mock_parse.return_value = ClearLogEndpoint(path="/clear/path") + mock_clear.return_value = [ClearLogResult(path="/clear/path", success=True, status_code=200)] + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/svc", success=True, data={"Actions": {}}, status_code=200 + ) + + result, data = redfish_oem_diag_collector.collect_data( + args=RedfishOemDiagCollectorArgs( + oem_diagnostic_types=["AllLogs", "JournalControl"], + clear_logs_after_collection=True, + ) + ) + assert result.status == ExecutionStatus.OK + assert data.results["AllLogs"].success is True + assert data.results["JournalControl"].success is False + mock_clear.assert_called_once() + + +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector._parse_clear_endpoint") +def test_clear_log_failure_downgrades_to_warning( + mock_parse, mock_clear, mock_collect, redfish_oem_diag_collector, redfish_conn_mock +): + from nodescraper.connection.redfish import RedfishGetResult + from nodescraper.connection.redfish.redfish_clear_log import ( + ClearLogEndpoint, + ClearLogResult, + ) + from nodescraper.enums import EventPriority + + mock_collect.return_value = (b"data", {}, None) + mock_parse.return_value = ClearLogEndpoint(path="/clear/path") + mock_clear.return_value = [ + ClearLogResult( + path="/clear/path", success=False, status_code=500, error="POST returned 500" + ) + ] + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/svc", success=True, data={"Actions": {}}, status_code=200 + ) + + result, data = redfish_oem_diag_collector.collect_data( + args=RedfishOemDiagCollectorArgs( + oem_diagnostic_types=["AllLogs"], + clear_logs_after_collection=True, + ) + ) + assert result.status == ExecutionStatus.WARNING + assert "log clear failed" in result.message + warning_events = [e for e in result.events if e.priority == EventPriority.WARNING] + assert any("Failed to clear" in e.description for e in warning_events) + + +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") +def test_clear_logs_skipped_when_passive_interaction( + mock_clear, mock_collect, system_info, redfish_conn_mock +): + from nodescraper.enums import SystemInteractionLevel + + mock_collect.return_value = (b"data", {}, None) + collector = RedfishOemDiagCollector( + system_info=system_info, + connection=redfish_conn_mock, + system_interaction_level=SystemInteractionLevel.PASSIVE, + ) + result, data = collector.collect_data( + args=RedfishOemDiagCollectorArgs( + oem_diagnostic_types=["AllLogs"], + clear_logs_after_collection=True, + ) + ) + assert result.status == ExecutionStatus.OK + mock_clear.assert_not_called() + + +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") +def test_clear_logs_not_called_when_disabled(mock_clear, mock_collect, redfish_oem_diag_collector): + mock_collect.return_value = (b"data", {}, None) + result, data = redfish_oem_diag_collector.collect_data( + args=RedfishOemDiagCollectorArgs( + oem_diagnostic_types=["AllLogs"], + clear_logs_after_collection=False, + ) + ) + assert result.status == ExecutionStatus.OK + mock_clear.assert_not_called() diff --git a/test/unit/plugin/test_regex_search_analyzer.py b/test/unit/plugin/test_regex_search_analyzer.py index 66257fbb..c9ccb32c 100644 --- a/test/unit/plugin/test_regex_search_analyzer.py +++ b/test/unit/plugin/test_regex_search_analyzer.py @@ -1,197 +1,197 @@ -############################################################################### -# -# 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 logging -import os -import tempfile - -from nodescraper.enums.executionstatus import ExecutionStatus -from nodescraper.plugins.inband.regex_search.analyzer_args import ( - RegexSearchAnalyzerArgs, -) -from nodescraper.plugins.inband.regex_search.regex_search_analyzer import ( - RegexSearchAnalyzer, -) -from nodescraper.plugins.inband.regex_search.regex_search_data import RegexSearchData -from nodescraper.plugins.inband.regex_search.regex_search_plugin import ( - RegexSearchPlugin, -) - -EXPECTED_MISSING_ANALYSIS_MSG = "Analysis args need to be provided for the analyzer to run" - - -def test_regex_search_data_from_file(): - with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False, encoding="utf-8") as f: - f.write("alpha\nbeta ERROR gamma\n") - path = f.name - try: - data = RegexSearchData.import_model(path) - assert "ERROR" in data.content - assert os.path.basename(path) in data.files - assert data.data_root == os.path.dirname(path) - finally: - os.unlink(path) - - -def test_regex_search_data_from_directory(): - with tempfile.TemporaryDirectory() as tmp: - with open(f"{tmp}/a.txt", "w", encoding="utf-8") as f: - f.write("one") - with open(f"{tmp}/b.txt", "w", encoding="utf-8") as f: - f.write("two") - data = RegexSearchData.import_model(tmp) - assert data.data_root == os.path.abspath(tmp) - assert set(data.files.keys()) == {"a.txt", "b.txt"} - assert data.files["a.txt"] == "one" - assert data.files["b.txt"] == "two" - assert "===== a.txt =====" in data.content - assert "===== b.txt =====" in data.content - - -def test_regex_search_analyzer_match(system_info): - data = RegexSearchData(content="line1\nFATAL: boom\nline3") - analyzer = RegexSearchAnalyzer(system_info=system_info) - args = RegexSearchAnalyzerArgs( - error_regex=[{"regex": r"FATAL:.*", "message": "fatal seen"}], - ) - result = analyzer.analyze_data(data, args) - assert result.status == ExecutionStatus.ERROR - assert result.message == "task detected errors (1 errors: fatal seen)" - assert len(result.events) == 1 - assert result.events[0].description == "fatal seen" - - -def test_regex_search_analyzer_missing_args(system_info): - data = RegexSearchData(content="x") - analyzer = RegexSearchAnalyzer(system_info=system_info) - result = analyzer.analyze_data(data, None) - assert result.status == ExecutionStatus.NOT_RAN - assert result.message == EXPECTED_MISSING_ANALYSIS_MSG - - result = analyzer.analyze_data(data, RegexSearchAnalyzerArgs(error_regex=None)) - assert result.status == ExecutionStatus.NOT_RAN - assert result.message == EXPECTED_MISSING_ANALYSIS_MSG - - result = analyzer.analyze_data(data, RegexSearchAnalyzerArgs(error_regex=[])) - assert result.status == ExecutionStatus.NOT_RAN - assert result.message == EXPECTED_MISSING_ANALYSIS_MSG - - -def test_regex_search_plugin_missing_error_regex_not_ran_and_warning( - system_info, logger, caplog, tmp_path -): - log_file = tmp_path / "sample.log" - log_file.write_text("line\n", encoding="utf-8") - plugin = RegexSearchPlugin(system_info=system_info, logger=logger) - with caplog.at_level(logging.WARNING, logger=logger.name): - out = plugin.run( - collection=False, - analysis=True, - data=str(log_file), - analysis_args=None, - ) - assert out.result_data.analysis_result.status == ExecutionStatus.NOT_RAN - assert out.result_data.analysis_result.message == EXPECTED_MISSING_ANALYSIS_MSG - assert any( - "analysis args need to be provided" in r.getMessage().lower() for r in caplog.records - ) - - -def test_regex_search_plugin_empty_analysis_args_dict_not_ran(system_info, logger, tmp_path): - log_file = tmp_path / "sample.log" - log_file.write_text("line\n", encoding="utf-8") - plugin = RegexSearchPlugin(system_info=system_info, logger=logger) - out = plugin.run( - collection=False, - analysis=True, - data=str(log_file), - analysis_args={}, - ) - assert out.result_data.analysis_result.status == ExecutionStatus.NOT_RAN - assert out.result_data.analysis_result.message == EXPECTED_MISSING_ANALYSIS_MSG - - -def test_regex_search_plugin_no_data_warns_and_data_message(system_info, logger, caplog): - plugin = RegexSearchPlugin(system_info=system_info, logger=logger) - with caplog.at_level(logging.WARNING, logger=logger.name): - out = plugin.run( - collection=False, - analysis=True, - data=None, - analysis_args=None, - ) - assert out.result_data.analysis_result.status == ExecutionStatus.NOT_RAN - assert "No data available to analyze for RegexSearchPlugin" in ( - out.result_data.analysis_result.message - ) - assert any( - "analysis args need to be provided" in r.getMessage().lower() for r in caplog.records - ) - - -def test_regex_search_plugin_analyzer_only(system_info, logger): - with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False, encoding="utf-8") as f: - f.write("match_me_here\n") - path = f.name - try: - plugin = RegexSearchPlugin(system_info=system_info, logger=logger) - out = plugin.run( - collection=False, - analysis=True, - data=path, - analysis_args={ - "error_regex": [{"regex": r"match_me_here", "message": "found"}], - }, - ) - assert out.status == ExecutionStatus.ERROR - assert out.message.startswith( - "Analysis error: task detected errors (1 errors: found [file: " - ) - assert out.message.endswith("])") - assert out.result_data.analysis_result.status == ExecutionStatus.ERROR - assert len(out.result_data.analysis_result.events) == 1 - desc = out.result_data.analysis_result.events[0].description - assert "found" in desc - assert "[file:" in desc - assert path.replace("\\", "/") in desc.replace("\\", "/") - finally: - os.unlink(path) - - -def test_regex_search_multi_file_event_paths(system_info): - with tempfile.TemporaryDirectory() as tmp: - open(os.path.join(tmp, "clean.log"), "w", encoding="utf-8").write("ok\n") - open(os.path.join(tmp, "bad.log"), "w", encoding="utf-8").write("ERROR: boom\n") - data = RegexSearchData.import_model(tmp) - analyzer = RegexSearchAnalyzer(system_info=system_info) - args = RegexSearchAnalyzerArgs( - error_regex=[{"regex": r"ERROR[: ].*", "message": "err line"}], - ) - result = analyzer.analyze_data(data, args) - assert result.status == ExecutionStatus.ERROR - assert len(result.events) == 1 - assert "err line" in result.events[0].description - assert "[file:" in result.events[0].description - assert "bad.log" in result.events[0].description +############################################################################### +# +# 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 logging +import os +import tempfile + +from nodescraper.enums.executionstatus import ExecutionStatus +from nodescraper.plugins.inband.regex_search.analyzer_args import ( + RegexSearchAnalyzerArgs, +) +from nodescraper.plugins.inband.regex_search.regex_search_analyzer import ( + RegexSearchAnalyzer, +) +from nodescraper.plugins.inband.regex_search.regex_search_data import RegexSearchData +from nodescraper.plugins.inband.regex_search.regex_search_plugin import ( + RegexSearchPlugin, +) + +EXPECTED_MISSING_ANALYSIS_MSG = "Analysis args need to be provided for the analyzer to run" + + +def test_regex_search_data_from_file(): + with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False, encoding="utf-8") as f: + f.write("alpha\nbeta ERROR gamma\n") + path = f.name + try: + data = RegexSearchData.import_model(path) + assert "ERROR" in data.content + assert os.path.basename(path) in data.files + assert data.data_root == os.path.dirname(path) + finally: + os.unlink(path) + + +def test_regex_search_data_from_directory(): + with tempfile.TemporaryDirectory() as tmp: + with open(f"{tmp}/a.txt", "w", encoding="utf-8") as f: + f.write("one") + with open(f"{tmp}/b.txt", "w", encoding="utf-8") as f: + f.write("two") + data = RegexSearchData.import_model(tmp) + assert data.data_root == os.path.abspath(tmp) + assert set(data.files.keys()) == {"a.txt", "b.txt"} + assert data.files["a.txt"] == "one" + assert data.files["b.txt"] == "two" + assert "===== a.txt =====" in data.content + assert "===== b.txt =====" in data.content + + +def test_regex_search_analyzer_match(system_info): + data = RegexSearchData(content="line1\nFATAL: boom\nline3") + analyzer = RegexSearchAnalyzer(system_info=system_info) + args = RegexSearchAnalyzerArgs( + error_regex=[{"regex": r"FATAL:.*", "message": "fatal seen"}], + ) + result = analyzer.analyze_data(data, args) + assert result.status == ExecutionStatus.ERROR + assert result.message == "task detected errors (1 errors: fatal seen)" + assert len(result.events) == 1 + assert result.events[0].description == "fatal seen" + + +def test_regex_search_analyzer_missing_args(system_info): + data = RegexSearchData(content="x") + analyzer = RegexSearchAnalyzer(system_info=system_info) + result = analyzer.analyze_data(data, None) + assert result.status == ExecutionStatus.NOT_RAN + assert result.message == EXPECTED_MISSING_ANALYSIS_MSG + + result = analyzer.analyze_data(data, RegexSearchAnalyzerArgs(error_regex=None)) + assert result.status == ExecutionStatus.NOT_RAN + assert result.message == EXPECTED_MISSING_ANALYSIS_MSG + + result = analyzer.analyze_data(data, RegexSearchAnalyzerArgs(error_regex=[])) + assert result.status == ExecutionStatus.NOT_RAN + assert result.message == EXPECTED_MISSING_ANALYSIS_MSG + + +def test_regex_search_plugin_missing_error_regex_not_ran_and_warning( + system_info, logger, caplog, tmp_path +): + log_file = tmp_path / "sample.log" + log_file.write_text("line\n", encoding="utf-8") + plugin = RegexSearchPlugin(system_info=system_info, logger=logger) + with caplog.at_level(logging.WARNING, logger=logger.name): + out = plugin.run( + collection=False, + analysis=True, + data=str(log_file), + analysis_args=None, + ) + assert out.result_data.analysis_result.status == ExecutionStatus.NOT_RAN + assert out.result_data.analysis_result.message == EXPECTED_MISSING_ANALYSIS_MSG + assert any( + "analysis args need to be provided" in r.getMessage().lower() for r in caplog.records + ) + + +def test_regex_search_plugin_empty_analysis_args_dict_not_ran(system_info, logger, tmp_path): + log_file = tmp_path / "sample.log" + log_file.write_text("line\n", encoding="utf-8") + plugin = RegexSearchPlugin(system_info=system_info, logger=logger) + out = plugin.run( + collection=False, + analysis=True, + data=str(log_file), + analysis_args={}, + ) + assert out.result_data.analysis_result.status == ExecutionStatus.NOT_RAN + assert out.result_data.analysis_result.message == EXPECTED_MISSING_ANALYSIS_MSG + + +def test_regex_search_plugin_no_data_warns_and_data_message(system_info, logger, caplog): + plugin = RegexSearchPlugin(system_info=system_info, logger=logger) + with caplog.at_level(logging.WARNING, logger=logger.name): + out = plugin.run( + collection=False, + analysis=True, + data=None, + analysis_args=None, + ) + assert out.result_data.analysis_result.status == ExecutionStatus.NOT_RAN + assert "No data available to analyze for RegexSearchPlugin" in ( + out.result_data.analysis_result.message + ) + assert any( + "analysis args need to be provided" in r.getMessage().lower() for r in caplog.records + ) + + +def test_regex_search_plugin_analyzer_only(system_info, logger): + with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False, encoding="utf-8") as f: + f.write("match_me_here\n") + path = f.name + try: + plugin = RegexSearchPlugin(system_info=system_info, logger=logger) + out = plugin.run( + collection=False, + analysis=True, + data=path, + analysis_args={ + "error_regex": [{"regex": r"match_me_here", "message": "found"}], + }, + ) + assert out.status == ExecutionStatus.ERROR + assert out.message.startswith( + "Analysis error: task detected errors (1 errors: found [file: " + ) + assert out.message.endswith("])") + assert out.result_data.analysis_result.status == ExecutionStatus.ERROR + assert len(out.result_data.analysis_result.events) == 1 + desc = out.result_data.analysis_result.events[0].description + assert "found" in desc + assert "[file:" in desc + assert path.replace("\\", "/") in desc.replace("\\", "/") + finally: + os.unlink(path) + + +def test_regex_search_multi_file_event_paths(system_info): + with tempfile.TemporaryDirectory() as tmp: + open(os.path.join(tmp, "clean.log"), "w", encoding="utf-8").write("ok\n") + open(os.path.join(tmp, "bad.log"), "w", encoding="utf-8").write("ERROR: boom\n") + data = RegexSearchData.import_model(tmp) + analyzer = RegexSearchAnalyzer(system_info=system_info) + args = RegexSearchAnalyzerArgs( + error_regex=[{"regex": r"ERROR[: ].*", "message": "err line"}], + ) + result = analyzer.analyze_data(data, args) + assert result.status == ExecutionStatus.ERROR + assert len(result.events) == 1 + assert "err line" in result.events[0].description + assert "[file:" in result.events[0].description + assert "bad.log" in result.events[0].description diff --git a/test/unit/plugin/test_serviceability_api.py b/test/unit/plugin/test_serviceability_api.py index cb6f15a3..e11ebc5d 100644 --- a/test/unit/plugin/test_serviceability_api.py +++ b/test/unit/plugin/test_serviceability_api.py @@ -1,80 +1,80 @@ -############################################################################### -# -# 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 serviceability_dummy_data import DUMMY_AFID_A, DUMMY_UNIT_A - -from nodescraper.plugins.serviceability.se_models import ( - AfidEvent, - HubTriageResult, - ServiceabilityBlock, -) -from nodescraper.plugins.serviceability.serviceability_api import ( - export_serviceability_json, -) - - -def test_export_serviceability_json_keeps_raw_hub_and_drops_duplicate_rows(): - block = ServiceabilityBlock( - afid_events=[ - AfidEvent( - afid=DUMMY_AFID_A, serviceable_unit="Instinct_EAM_0", time="2025-01-01T00:00:00Z" - ) - ], - hub_analyze_response={ - "schema_version": "1.0", - "status": "ok", - "triage": { - "top": [{"afid": DUMMY_AFID_A, "location": DUMMY_UNIT_A, "se_sort_priority": 1000}], - "results": [ - {"afid": DUMMY_AFID_A, "location": DUMMY_UNIT_A, "se_sort_priority": 1000} - ], - "multi_afid_summary": [], - }, - "pid": "SAG-00000", - "revision": "1.0.0", - }, - hub_top_results=[ - HubTriageResult( - afid=DUMMY_AFID_A, - location=DUMMY_UNIT_A, - service_action_num=199, - hub_sort_priority=1000, - ) - ], - hub_triage_results=[ - HubTriageResult( - afid=DUMMY_AFID_A, - location=DUMMY_UNIT_A, - service_action_num=199, - hub_sort_priority=1000, - ) - ], - ) - - payload = export_serviceability_json(block) - - assert payload["hub_analyze_response"]["triage"]["top"][0]["se_sort_priority"] == 1000 - assert "hub_top_results" not in payload - assert "hub_triage_results" not in payload +############################################################################### +# +# 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 serviceability_dummy_data import DUMMY_AFID_A, DUMMY_UNIT_A + +from nodescraper.plugins.serviceability.se_models import ( + AfidEvent, + HubTriageResult, + ServiceabilityBlock, +) +from nodescraper.plugins.serviceability.serviceability_api import ( + export_serviceability_json, +) + + +def test_export_serviceability_json_keeps_raw_hub_and_drops_duplicate_rows(): + block = ServiceabilityBlock( + afid_events=[ + AfidEvent( + afid=DUMMY_AFID_A, serviceable_unit="Instinct_EAM_0", time="2025-01-01T00:00:00Z" + ) + ], + hub_analyze_response={ + "schema_version": "1.0", + "status": "ok", + "triage": { + "top": [{"afid": DUMMY_AFID_A, "location": DUMMY_UNIT_A, "se_sort_priority": 1000}], + "results": [ + {"afid": DUMMY_AFID_A, "location": DUMMY_UNIT_A, "se_sort_priority": 1000} + ], + "multi_afid_summary": [], + }, + "pid": "SAG-00000", + "revision": "1.0.0", + }, + hub_top_results=[ + HubTriageResult( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + service_action_num=199, + hub_sort_priority=1000, + ) + ], + hub_triage_results=[ + HubTriageResult( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + service_action_num=199, + hub_sort_priority=1000, + ) + ], + ) + + payload = export_serviceability_json(block) + + assert payload["hub_analyze_response"]["triage"]["top"][0]["se_sort_priority"] == 1000 + assert "hub_top_results" not in payload + assert "hub_triage_results" not in payload diff --git a/test/unit/plugin/test_serviceability_recommendations_table.py b/test/unit/plugin/test_serviceability_recommendations_table.py index f867a66c..12ad35b6 100644 --- a/test/unit/plugin/test_serviceability_recommendations_table.py +++ b/test/unit/plugin/test_serviceability_recommendations_table.py @@ -1,258 +1,258 @@ -############################################################################### -# -# 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 serviceability_dummy_data import ( - DUMMY_AFID_A, - DUMMY_AFID_B, - DUMMY_SERVICE_ACTION_NUM, - DUMMY_TIER_CRITICAL, - DUMMY_TIER_LABEL, - DUMMY_UNIT_A, - DUMMY_UNIT_B, -) - -from nodescraper.enums import ExecutionStatus -from nodescraper.models import DataPluginResult, PluginResult -from nodescraper.plugins.serviceability.se_models import ( - HubTriageResult, - ServiceabilityBlock, -) -from nodescraper.plugins.serviceability.serviceability_api import ( - build_top_service_actions, - split_recommendation_actions, -) -from nodescraper.plugins.serviceability.serviceability_data import ( - ServiceabilityDataModel, -) -from nodescraper.plugins.serviceability.serviceability_recommendations_table import ( - emit_serviceability_recommendation_tables, - render_serviceability_recommendation_tables, - render_serviceability_recommendation_tables_for_plugin_results, -) - - -def _row( - *, - afid: int, - location: str, - sort_priority: int, - priority: int, - title: str, -) -> HubTriageResult: - return HubTriageResult( - afid=afid, - location=location, - service_action_num=DUMMY_SERVICE_ACTION_NUM, - service_action_title=title, - priority=priority, - sa_severity=20, - tier_label=DUMMY_TIER_CRITICAL if priority == 1 else DUMMY_TIER_LABEL, - hub_sort_priority=sort_priority, - ) - - -def test_split_recommendation_rows_uses_hub_top(): - block = ServiceabilityBlock( - hub_top_results=[ - _row( - afid=DUMMY_AFID_A, - location=DUMMY_UNIT_A, - sort_priority=1000, - priority=1, - title="Contact Support", - ) - ], - hub_triage_results=[ - _row( - afid=DUMMY_AFID_A, - location=DUMMY_UNIT_A, - sort_priority=1000, - priority=1, - title="Contact Support", - ), - _row( - afid=DUMMY_AFID_B, - location=DUMMY_UNIT_B, - sort_priority=2000, - priority=20, - title="Update FW", - ), - ], - ) - - top, additional = split_recommendation_actions(block) - - assert len(top) == 1 - assert top[0].afid == DUMMY_AFID_A - assert len(additional) == 1 - assert additional[0].afid == DUMMY_AFID_B - - -def test_render_serviceability_recommendation_tables_splits_sections(): - block = ServiceabilityBlock( - hub_top_results=[ - _row( - afid=DUMMY_AFID_A, - location=DUMMY_UNIT_A, - sort_priority=1000, - priority=1, - title="Contact Support", - ) - ], - hub_triage_results=[ - _row( - afid=DUMMY_AFID_A, - location=DUMMY_UNIT_A, - sort_priority=1000, - priority=1, - title="Contact Support", - ), - _row( - afid=DUMMY_AFID_B, - location=DUMMY_UNIT_B, - sort_priority=2000, - priority=20, - title="Update FW", - ), - ], - ) - - output = render_serviceability_recommendation_tables(block) - - assert "Top Hub service action (rank 1)" in output - assert "Additional Hub service actions (rank 2)" in output - assert f"{DUMMY_AFID_A}: Contact Support" in output - assert f"{DUMMY_AFID_B}: Update FW" in output - assert output.index("Top Hub service action") < output.index("Additional Hub service actions") - - -def test_build_top_service_actions_includes_tied_entries(): - block = ServiceabilityBlock( - hub_top_results=[ - _row( - afid=DUMMY_AFID_A, - location=DUMMY_UNIT_A, - sort_priority=1000, - priority=1, - title="Replace Unit", - ), - _row( - afid=DUMMY_AFID_B, - location=DUMMY_UNIT_B, - sort_priority=1000, - priority=1, - title="Replace Unit", - ), - ], - hub_triage_results=[ - _row( - afid=DUMMY_AFID_A, - location=DUMMY_UNIT_A, - sort_priority=1000, - priority=1, - title="Replace Unit", - ), - _row( - afid=DUMMY_AFID_B, - location=DUMMY_UNIT_B, - sort_priority=1000, - priority=1, - title="Replace Unit", - ), - ], - ) - - top = build_top_service_actions(block) - - assert len(top) == 2 - assert {action.afid for action in top} == {DUMMY_AFID_A, DUMMY_AFID_B} - - -def test_render_serviceability_recommendation_tables_for_plugin_results(): - block = ServiceabilityBlock( - hub_top_results=[ - _row( - afid=DUMMY_AFID_A, - location=DUMMY_UNIT_A, - sort_priority=1000, - priority=1, - title="Contact Support", - ) - ], - hub_triage_results=[ - _row( - afid=DUMMY_AFID_A, - location=DUMMY_UNIT_A, - sort_priority=1000, - priority=1, - title="Contact Support", - ), - ], - ) - plugin_results = [ - PluginResult( - status=ExecutionStatus.OK, - source="Mi4xxServiceabilityPlugin", - message="ok", - result_data=DataPluginResult( - system_data=ServiceabilityDataModel(serviceability=block), - ), - ) - ] - - output = render_serviceability_recommendation_tables_for_plugin_results(plugin_results) - - assert "Top Hub service action" in output - assert "Contact Support" in output - - -def test_emit_serviceability_recommendation_tables_writes_stdout(capsys): - block = ServiceabilityBlock( - hub_top_results=[ - _row( - afid=DUMMY_AFID_A, - location=DUMMY_UNIT_A, - sort_priority=1000, - priority=1, - title="Contact Support", - ) - ], - hub_triage_results=[ - _row( - afid=DUMMY_AFID_A, - location=DUMMY_UNIT_A, - sort_priority=1000, - priority=1, - title="Contact Support", - ), - ], - ) - - emit_serviceability_recommendation_tables(block) - captured = capsys.readouterr() - - assert "Top Hub service action" in captured.out - assert "Rank" in captured.out - assert "Contact Support" in captured.out +############################################################################### +# +# 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 serviceability_dummy_data import ( + DUMMY_AFID_A, + DUMMY_AFID_B, + DUMMY_SERVICE_ACTION_NUM, + DUMMY_TIER_CRITICAL, + DUMMY_TIER_LABEL, + DUMMY_UNIT_A, + DUMMY_UNIT_B, +) + +from nodescraper.enums import ExecutionStatus +from nodescraper.models import DataPluginResult, PluginResult +from nodescraper.plugins.serviceability.se_models import ( + HubTriageResult, + ServiceabilityBlock, +) +from nodescraper.plugins.serviceability.serviceability_api import ( + build_top_service_actions, + split_recommendation_actions, +) +from nodescraper.plugins.serviceability.serviceability_data import ( + ServiceabilityDataModel, +) +from nodescraper.plugins.serviceability.serviceability_recommendations_table import ( + emit_serviceability_recommendation_tables, + render_serviceability_recommendation_tables, + render_serviceability_recommendation_tables_for_plugin_results, +) + + +def _row( + *, + afid: int, + location: str, + sort_priority: int, + priority: int, + title: str, +) -> HubTriageResult: + return HubTriageResult( + afid=afid, + location=location, + service_action_num=DUMMY_SERVICE_ACTION_NUM, + service_action_title=title, + priority=priority, + sa_severity=20, + tier_label=DUMMY_TIER_CRITICAL if priority == 1 else DUMMY_TIER_LABEL, + hub_sort_priority=sort_priority, + ) + + +def test_split_recommendation_rows_uses_hub_top(): + block = ServiceabilityBlock( + hub_top_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Contact Support", + ) + ], + hub_triage_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Contact Support", + ), + _row( + afid=DUMMY_AFID_B, + location=DUMMY_UNIT_B, + sort_priority=2000, + priority=20, + title="Update FW", + ), + ], + ) + + top, additional = split_recommendation_actions(block) + + assert len(top) == 1 + assert top[0].afid == DUMMY_AFID_A + assert len(additional) == 1 + assert additional[0].afid == DUMMY_AFID_B + + +def test_render_serviceability_recommendation_tables_splits_sections(): + block = ServiceabilityBlock( + hub_top_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Contact Support", + ) + ], + hub_triage_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Contact Support", + ), + _row( + afid=DUMMY_AFID_B, + location=DUMMY_UNIT_B, + sort_priority=2000, + priority=20, + title="Update FW", + ), + ], + ) + + output = render_serviceability_recommendation_tables(block) + + assert "Top Hub service action (rank 1)" in output + assert "Additional Hub service actions (rank 2)" in output + assert f"{DUMMY_AFID_A}: Contact Support" in output + assert f"{DUMMY_AFID_B}: Update FW" in output + assert output.index("Top Hub service action") < output.index("Additional Hub service actions") + + +def test_build_top_service_actions_includes_tied_entries(): + block = ServiceabilityBlock( + hub_top_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Replace Unit", + ), + _row( + afid=DUMMY_AFID_B, + location=DUMMY_UNIT_B, + sort_priority=1000, + priority=1, + title="Replace Unit", + ), + ], + hub_triage_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Replace Unit", + ), + _row( + afid=DUMMY_AFID_B, + location=DUMMY_UNIT_B, + sort_priority=1000, + priority=1, + title="Replace Unit", + ), + ], + ) + + top = build_top_service_actions(block) + + assert len(top) == 2 + assert {action.afid for action in top} == {DUMMY_AFID_A, DUMMY_AFID_B} + + +def test_render_serviceability_recommendation_tables_for_plugin_results(): + block = ServiceabilityBlock( + hub_top_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Contact Support", + ) + ], + hub_triage_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Contact Support", + ), + ], + ) + plugin_results = [ + PluginResult( + status=ExecutionStatus.OK, + source="Mi4xxServiceabilityPlugin", + message="ok", + result_data=DataPluginResult( + system_data=ServiceabilityDataModel(serviceability=block), + ), + ) + ] + + output = render_serviceability_recommendation_tables_for_plugin_results(plugin_results) + + assert "Top Hub service action" in output + assert "Contact Support" in output + + +def test_emit_serviceability_recommendation_tables_writes_stdout(capsys): + block = ServiceabilityBlock( + hub_top_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Contact Support", + ) + ], + hub_triage_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Contact Support", + ), + ], + ) + + emit_serviceability_recommendation_tables(block) + captured = capsys.readouterr() + + assert "Top Hub service action" in captured.out + assert "Rank" in captured.out + assert "Contact Support" in captured.out diff --git a/test/unit/test_version.py b/test/unit/test_version.py index a58d6345..b6bcc334 100644 --- a/test/unit/test_version.py +++ b/test/unit/test_version.py @@ -1,73 +1,73 @@ -############################################################################### -# -# MIT License -# -# Copyright (c) 2026 Advanced Micro Devices, Inc. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -############################################################################### - -import importlib -import re -from pathlib import Path -from unittest.mock import patch - -import pytest - - -def _get_project_name_from_pyproject() -> str: - repo_root = Path(__file__).resolve().parent.parent.parent - pyproject = repo_root / "pyproject.toml" - content = pyproject.read_text() - in_project = False - for line in content.splitlines(): - if line.strip() == "[project]": - in_project = True - continue - if in_project and line.strip().startswith("["): - break - if in_project: - match = re.match(r'^\s*name\s*=\s*["\']([^"\']+)["\']', line) - if match: - return match.group(1) - pytest.fail("Could not find name in [project] section of pyproject.toml") - - -def test_version_resolved_using_package_name_from_pyproject(): - expected_name = _get_project_name_from_pyproject() - with patch("importlib.metadata.version", return_value="1.2.3") as mock_version: - import nodescraper - - importlib.reload(nodescraper) - assert nodescraper.__version__ == "1.2.3" - mock_version.assert_called_once_with( - expected_name - ), "nodescraper.__init__ must call version() with the same name as pyproject.toml [project] name" - - -def test_version_unknown_when_package_not_found(): - from importlib.metadata import PackageNotFoundError - - expected_name = _get_project_name_from_pyproject() - with patch("importlib.metadata.version", side_effect=PackageNotFoundError(expected_name)): - import nodescraper - - importlib.reload(nodescraper) - assert nodescraper.__version__ == "unknown" +############################################################################### +# +# 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 importlib +import re +from pathlib import Path +from unittest.mock import patch + +import pytest + + +def _get_project_name_from_pyproject() -> str: + repo_root = Path(__file__).resolve().parent.parent.parent + pyproject = repo_root / "pyproject.toml" + content = pyproject.read_text() + in_project = False + for line in content.splitlines(): + if line.strip() == "[project]": + in_project = True + continue + if in_project and line.strip().startswith("["): + break + if in_project: + match = re.match(r'^\s*name\s*=\s*["\']([^"\']+)["\']', line) + if match: + return match.group(1) + pytest.fail("Could not find name in [project] section of pyproject.toml") + + +def test_version_resolved_using_package_name_from_pyproject(): + expected_name = _get_project_name_from_pyproject() + with patch("importlib.metadata.version", return_value="1.2.3") as mock_version: + import nodescraper + + importlib.reload(nodescraper) + assert nodescraper.__version__ == "1.2.3" + mock_version.assert_called_once_with( + expected_name + ), "nodescraper.__init__ must call version() with the same name as pyproject.toml [project] name" + + +def test_version_unknown_when_package_not_found(): + from importlib.metadata import PackageNotFoundError + + expected_name = _get_project_name_from_pyproject() + with patch("importlib.metadata.version", side_effect=PackageNotFoundError(expected_name)): + import nodescraper + + importlib.reload(nodescraper) + assert nodescraper.__version__ == "unknown"