diff --git a/.codespellignore b/.codespellignore new file mode 100644 index 000000000..a6d3a93ce --- /dev/null +++ b/.codespellignore @@ -0,0 +1 @@ +toi \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0c13d2c02..7f8844da8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -21,7 +21,17 @@ "ms-python.pylint", "LittleFoxTeam.vscode-python-test-adapter", "njqdev.vscode-python-typehint", - "hbenl.vscode-test-explorer" + "hbenl.vscode-test-explorer", + "codezombiech.gitignore", + "ms-python.isort", + "eriklynd.json-tools", + "ms-python.vscode-pylance", + "tuxtina.json2yaml", + "christian-kohler.path-intellisense", + "ms-python.vscode-pylance", + "njqdev.vscode-python-typehint", + "LittleFoxTeam.vscode-python-test-adapter", + "donjayamanne.python-environment-manager" ] } }, diff --git a/.devcontainer/startup.sh b/.devcontainer/startup.sh index fb9f6f13d..ec424c401 100644 --- a/.devcontainer/startup.sh +++ b/.devcontainer/startup.sh @@ -9,5 +9,8 @@ pip install --upgrade pip echo "Installing ANTA package from git" pip install -e . +echo "Installing ANTA CLI package from git" +pip install -e ".[cli]" + echo "Installing development tools" pip install -e ".[dev]" diff --git a/.github/generate_release.py b/.github/generate_release.py index 97f139b7f..8cd4337fc 100644 --- a/.github/generate_release.py +++ b/.github/generate_release.py @@ -30,7 +30,7 @@ class SafeDumper(yaml.SafeDumper): https://github.com/yaml/pyyaml/issues/234#issuecomment-765894586. """ - # pylint: disable=R0901,W0613,W1113 + # pylint: disable=R0901 def increase_indent(self, flow=False, *args, **kwargs): return super().increase_indent(flow=flow, indentless=False) diff --git a/.github/markdownlint.yaml b/.github/markdownlint.yaml new file mode 100644 index 000000000..5b1b12ea7 --- /dev/null +++ b/.github/markdownlint.yaml @@ -0,0 +1,93 @@ +# markdownlint configuration +# the definitive list of rules for markdownlint can be found: +# https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md +# +# only deviations from the defaults are noted here or where there's an opinion +# being expressed. + +# default state for all rules +default: true + +# heading style +MD003: + style: "atx" + +# unordered list style +MD004: + style: "dash" + +# unorderd list indentation (2-spaces) +# keep it tight yo! +MD007: + indent: 2 + +# line length +MD013: + false + # a lot of debate whether to wrap or not wrap + +# multiple headings with the same content +# siblings_only is set here to allow for common header values in structured +# documents +MD024: + siblings_only: true + +# Multiple top-level headings in the same document +MD025: + front_matter_title: "" + +# MD029/ol-prefix - Ordered list item prefix +MD029: + # List style + style: "ordered" + +# fenced code should be surrounded by blank lines default: true +MD031: true + +# lists should be surrounded by blank lines default: true +MD032: true + +# MD033/no-inline-html - Inline HTML +MD033: false + +# bare URL - bare URLs should be wrapped in angle brackets +# +MD034: false + +# horizontal rule style default: consistent +MD035: + style: "---" + +# first line in a file to be a top-level heading +# since we're using front-matter, this +MD041: false + +# proper-names - proper names to have the correct capitalization +# probably not entirely helpful in a technical writing environment. +MD044: false + +# block style - disabled to allow for admonitions +MD046: false + +# MD048/code-fence-style - Code fence style +MD048: + # Code fence style + style: "backtick" + +# MD049/Emphasis style should be consistent +MD049: + # Emphasis style should be consistent + style: "asterisk" + +# MD050/Strong style should be consistent +MD050: + # Strong style should be consistent + style: "asterisk" + +# MD037/no-space-in-emphasis - Spaces inside emphasis markers +# This incorrectly catches stars used in table contents, so *foo | *bar is triggered to remove the space between | and *bar. +MD037: false + +# MD059/descriptive-link-text +# Link text should be descriptive - by default this rule flags when a link text is "here" +MD059: false diff --git a/.github/markdownlintignore b/.github/markdownlintignore new file mode 100644 index 000000000..e69de29bb diff --git a/.github/release.md b/.github/release.md index 15db22694..4a9e2e950 100644 --- a/.github/release.md +++ b/.github/release.md @@ -14,11 +14,12 @@ Also, [Github CLI](https://cli.github.com/) can be helpful and is recommended In a branch specific for this, use the `bumpver` tool. It is configured to update: -* pyproject.toml -* docs/contribution.md -* docs/requirements-and-installation.md +- pyproject.toml +- docs/contribution.md +- docs/requirements-and-installation.md For instance to bump a patch version: + ``` bumpver update --patch ``` @@ -54,36 +55,42 @@ This is to be executed at the top of the repo ```bash git switch -c rel/vx.x.x ``` + 3. [Optional] Clean dist if required 4. Build the package locally ```bash python -m build ``` + 5. Check the package with `twine` (replace with your vesion) ```bash twine check dist/* ``` + 6. Upload the package to test.pypi ```bash twine upload -r testpypi dist/anta-x.x.x.* ``` + 7. Verify the package by installing it in a local venv and checking it installs and run correctly (run the tests) ```bash # In a brand new venv - pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple --no-cache anta + pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple --no-cache anta[cli] ``` + 8. Push to anta repository and create a Pull Request ```bash git push origin HEAD gh pr create --title 'bump: ANTA vx.x.x' ``` -9. Merge PR after review and wait for [workflow](https://github.com/arista-netdevops-community/anta/actions/workflows/release.yml) to be executed. + +9. Merge PR after review and wait for [workflow](https://github.com/aristanetworks/anta/actions/workflows/release.yml) to be executed. ```bash gh pr merge --squash @@ -100,4 +107,5 @@ This is to be executed at the top of the repo ```bash anta --version - ``` \ No newline at end of file + ``` + diff --git a/.github/workflows/code-testing.yml b/.github/workflows/code-testing.yml index 5c06d4552..1c20477c3 100644 --- a/.github/workflows/code-testing.yml +++ b/.github/workflows/code-testing.yml @@ -1,5 +1,5 @@ --- -name: Linting and Testing Anta +name: Linting and Testing ANTA on: push: branches: @@ -43,10 +43,10 @@ jobs: - 'docs/**' - 'README.md' check-requirements: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] needs: file-changes steps: - uses: actions/checkout@v4 @@ -59,35 +59,10 @@ jobs: pip install . - name: install dev requirements run: pip install .[dev] - missing-documentation: - name: "Warning documentation is missing" - runs-on: ubuntu-20.04 - needs: [file-changes] - if: needs.file-changes.outputs.cli == 'true' && needs.file-changes.outputs.docs == 'false' - steps: - - name: Documentation is missing - uses: GrantBirki/comment@v2.0.10 - with: - body: | - Please consider that documentation is missing under `docs/` folder. - You should update documentation to reflect your change, or maybe not :) - lint-yaml: - name: Run linting for yaml files - runs-on: ubuntu-20.04 - needs: [file-changes, check-requirements] - if: needs.file-changes.outputs.code == 'true' - steps: - - uses: actions/checkout@v4 - - name: yaml-lint - uses: ibiqlik/action-yamllint@v3 - with: - config_file: .yamllint.yml - file_or_dir: . lint-python: name: Check the code style - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest needs: file-changes - if: needs.file-changes.outputs.code == 'true' steps: - uses: actions/checkout@v4 - name: Setup Python @@ -100,9 +75,8 @@ jobs: run: tox -e lint type-python: name: Check typing - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest needs: file-changes - if: needs.file-changes.outputs.code == 'true' steps: - uses: actions/checkout@v4 - name: Setup Python @@ -115,11 +89,11 @@ jobs: run: tox -e type test-python: name: Pytest across all supported python versions - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest needs: [lint-python, type-python] strategy: matrix: - python: ["3.9", "3.10", "3.11", "3.12"] + python: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Setup Python @@ -130,10 +104,37 @@ jobs: run: pip install tox tox-gh-actions - name: "Run pytest via tox for ${{ matrix.python }}" run: tox + - name: Upload coverage from pytest + # Coverage only runs as part of 3.11. + if: | + matrix.python == '3.11' + uses: actions/upload-artifact@v4 + with: + name: pytest-coverage + include-hidden-files: true + path: .coverage.xml + test-python-windows: + name: Pytest on 3.12 for windows + runs-on: windows-2022 + needs: [lint-python, type-python] + if: needs.file-changes.outputs.code == 'true' + env: + # Required to prevent asyncssh to fail. + USERNAME: WindowsUser + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: 3.12 + - name: Install dependencies + run: pip install tox tox-gh-actions + - name: Run pytest via tox for 3.12 on Windows + run: tox test-documentation: name: Build offline documentation for testing - runs-on: ubuntu-20.04 - needs: [lint-python, type-python, test-python] + runs-on: ubuntu-latest + needs: [test-python] steps: - uses: actions/checkout@v4 - name: Setup Python @@ -144,3 +145,21 @@ jobs: run: pip install .[doc] - name: "Build mkdocs documentation offline" run: mkdocs build + benchmarks: + name: Benchmark ANTA for Python 3.12 + runs-on: ubuntu-latest + needs: [test-python] + if: needs.file-changes.outputs.code == 'true' + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install .[dev] + - name: Run benchmarks + uses: CodSpeedHQ/action@v3 + with: + token: ${{ secrets.CODSPEED_TOKEN }} + run: pytest --codspeed --no-cov --log-cli-level INFO tests/benchmark diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml new file mode 100644 index 000000000..c9c232306 --- /dev/null +++ b/.github/workflows/codspeed.yml @@ -0,0 +1,22 @@ +--- +name: Run benchmarks manually +on: + workflow_dispatch: + +jobs: + benchmarks: + name: Benchmark ANTA for Python 3.12 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install .[dev] + - name: Run benchmarks + uses: CodSpeedHQ/action@v3 + with: + token: ${{ secrets.CODSPEED_TOKEN }} + run: pytest --codspeed --no-cov --log-cli-level INFO tests/benchmark diff --git a/.github/workflows/on-demand.yml b/.github/workflows/on-demand.yml index 85e7c416a..695a0c642 100644 --- a/.github/workflows/on-demand.yml +++ b/.github/workflows/on-demand.yml @@ -39,7 +39,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . file: Dockerfile diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index d60937d6b..cdc2bca28 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -13,7 +13,7 @@ jobs: # https://github.com/marketplace/actions/auto-author-assign runs-on: ubuntu-latest steps: - - uses: toshimaru/auto-author-assign@v2.1.0 + - uses: toshimaru/auto-author-assign@v2.1.1 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" @@ -22,7 +22,7 @@ jobs: steps: # Please look up the latest version from # https://github.com/amannn/action-semantic-pull-request/releases - - uses: amannn/action-semantic-pull-request@v5.4.0 + - uses: amannn/action-semantic-pull-request@v5.5.3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b9088f0c..c0a538ff1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,8 +7,13 @@ on: jobs: pypi: - name: Publish version to Pypi servers + name: Publish Python 🐍 distribution 📦 to PyPI runs-on: ubuntu-latest + environment: + name: production + url: https://pypi.org/p/anta + permissions: + id-token: write steps: - name: Checkout code uses: actions/checkout@v4 @@ -19,37 +24,12 @@ jobs: - name: Build package run: | python -m build - - name: Publish package to Pypi + - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} - release-coverage: - name: Updated ANTA release coverage badge - runs-on: ubuntu-20.04 - needs: [pypi] - steps: - - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - name: Install dependencies - run: pip install genbadge[coverage] tox tox-gh-actions - - name: "Run pytest via tox for ${{ matrix.python }}" - run: tox - - name: Generate coverage badge - run: genbadge coverage -i .coverage.xml -o badge/latest-release-coverage.svg - - name: Publish coverage badge to gh-pages branch - uses: JamesIves/github-pages-deploy-action@v4 - with: - branch: coverage-badge - folder: badge release-doc: name: "Publish documentation for release ${{github.ref_name}}" runs-on: ubuntu-latest - needs: [release-coverage] steps: - uses: actions/checkout@v4 with: @@ -100,7 +80,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . file: Dockerfile diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml new file mode 100644 index 000000000..15d81aeed --- /dev/null +++ b/.github/workflows/sonar.yml @@ -0,0 +1,62 @@ +--- +name: Analysis with Sonarlint and publish to SonarCloud +on: + workflow_run: + workflows: ["Linting and Testing ANTA"] + types: [completed] + +jobs: + sonarcloud: + name: Run Sonarlint analysis and upload to SonarCloud. + if: github.repository == 'aristanetworks/anta' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: Download coverage from unit tests + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: pytest-coverage + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + merge-multiple: true + + - name: Get PR context + # Source: https://github.com/orgs/community/discussions/25220#discussioncomment-11316244 + id: pr-context + if: github.event.workflow_run.event == 'pull_request' + env: + # Token required for GH CLI: + GH_TOKEN: ${{ github.token }} + # Best practice for scripts is to reference via ENV at runtime. Avoid using the expression syntax in the script content directly: + PR_TARGET_REPO: ${{ github.repository }} + # If the PR is from a fork, prefix it with `:`, otherwise only the PR branch name is relevant: + PR_BRANCH: |- + ${{ + (github.event.workflow_run.head_repository.owner.login != github.event.workflow_run.repository.owner.login) + && format('{0}:{1}', github.event.workflow_run.head_repository.owner.login, github.event.workflow_run.head_branch) + || github.event.workflow_run.head_branch + }} + # Query the PR number by repo + branch, then assign to step output: + run: | + gh pr view --repo "${PR_TARGET_REPO}" "${PR_BRANCH}" \ + --json 'number,baseRefName' --jq '"number=\(.number)\nbase_ref=\(.baseRefName)"' \ + >> "${GITHUB_OUTPUT}" + echo "pr_branch=${PR_BRANCH}" >> "${GITHUB_OUTPUT}" + + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v5.2.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + # Using ACTION_STEP_DEBUG to trigger verbose when debugging in Github Action + args: > + -Dsonar.scm.revision=${{ github.event.workflow_run.head_sha }} + -Dsonar.pullrequest.key=${{ steps.pr-context.outputs.number }} + -Dsonar.pullrequest.branch=${{ steps.pr-context.outputs.pr_branch }} + -Dsonar.pullrequest.base=${{ steps.pr-context.outputs.base_ref }} + -Dsonar.verbose=${{ secrets.ACTIONS_STEP_DEBUG }} diff --git a/.gitignore b/.gitignore index a62de025c..29e00d517 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ __pycache__ *.pyc .pages -.coverage .pytest_cache +.mypy_cache +.ruff_cache +.cache build dist *.egg-info @@ -46,14 +48,13 @@ htmlcov/ .tox/ .nox/ .coverage +coverage_html_report .coverage.* -.cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ -.pytest_cache/ cover/ report.html @@ -97,17 +98,4 @@ venv.bak/ /site # VScode settings -.vscode -test.env -tech-support/ -tech-support/* -2* - -**/report.html -.*report.html - -# direnv file -.envrc - -clab-atd-anta/* -clab-atd-anta/ +.vscode \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e933a9d99..82af795d5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,19 +1,25 @@ --- # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks -files: ^(anta|docs|scripts|tests)/ +ci: + autoupdate_commit_msg: "ci: pre-commit autoupdate" + skip: [mypy] + +files: ^(anta|docs|scripts|tests|asynceapi)/ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v5.0.0 hooks: - id: trailing-whitespace + exclude: docs/.*.svg - id: end-of-file-fixer - id: check-added-large-files + exclude: tests/data/.*$ - id: check-merge-conflict - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.5.4 + rev: v1.5.5 hooks: - name: Check and insert license on Python files id: insert-license @@ -30,7 +36,7 @@ repos: - name: Check and insert license on Markdown files id: insert-license files: .*\.md$ - # exclude: + exclude: ^tests/data/.*\.md$ args: - --license-filepath - .github/license-short.txt @@ -38,59 +44,98 @@ repos: - --allow-past-years - --fuzzy-match-generates-todo - --comment-style - - '' + - "" - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.5 + rev: v0.11.11 hooks: - - id: ruff - name: Run Ruff linter - args: [ --fix ] - - id: ruff-format - name: Run Ruff formatter + - id: ruff + name: Run Ruff linter + args: [--fix] + - id: ruff-format + name: Run Ruff formatter - - repo: local # as per https://pylint.pycqa.org/en/latest/user_guide/installation/pre-commit-integration.html + - repo: https://github.com/pycqa/pylint + rev: "v3.3.7" hooks: - id: pylint - entry: pylint - language: python name: Check code style with pylint description: This hook runs pylint. types: [python] args: - - -rn # Only display messages - - -sn # Don't display the score - - --rcfile=pyproject.toml # Link to config file + - -rn # Only display messages + - -sn # Don't display the score + - --rcfile=pyproject.toml # Link to config file + additional_dependencies: + - anta[cli] + - types-PyYAML + - types-requests + - types-pyOpenSSL + - pylint_pydantic + - pytest + - pytest-codspeed + - respx + - pydantic-settings - repo: https://github.com/codespell-project/codespell - rev: v2.2.6 + rev: v2.4.1 hooks: - id: codespell name: Checks for common misspellings in text files. entry: codespell language: python types: [text] + args: ["--ignore-words", ".codespellignore"] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.9.0 + rev: v1.15.0 hooks: - id: mypy name: Check typing with mypy args: - --config-file=pyproject.toml additional_dependencies: - - "aio-eapi==0.3.0" - - "click==8.1.3" - - "click-help-colors==0.9.1" - - "cvprac~=1.3" - - "netaddr==0.8.0" - - "pydantic~=2.0" - - "PyYAML==6.0" - - "requests>=2.27" - - "rich~=13.4" - - "asyncssh==2.13.1" - - "Jinja2==3.1.2" + - anta[cli] - types-PyYAML - - types-paramiko - types-requests - files: ^(anta|tests)/ + - types-pyOpenSSL + - pytest + files: ^(anta|tests|asynceapi)/ + + - repo: https://github.com/igorshubovych/markdownlint-cli + # Keep v0.44.0 because pre-commit.ci is failing + rev: v0.45.0 + hooks: + - id: markdownlint + name: Check Markdown files style. + args: + - --config=.github/markdownlint.yaml + - --ignore-path=.github/markdownlintignore + - --fix + + - repo: local + hooks: + - id: examples-test + name: Generate examples/tests.yaml + entry: >- + sh -c "docs/scripts/generate_examples_tests.py" + language: python + types: [python] + files: anta/ + verbose: true + pass_filenames: false + additional_dependencies: + - anta[cli] + - pydantic-settings + - id: doc-snippets + name: Generate doc snippets + entry: >- + sh -c "docs/scripts/generate_doc_snippets.py" + language: python + types: [python] + files: anta/cli/ + verbose: true + pass_filenames: false + additional_dependencies: + - anta[cli] + - pydantic-settings diff --git a/.vscode/settings.json b/.vscode/settings.json index 8428c00f7..d50caa1dc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,20 +1,14 @@ { "ruff.enable": true, - "python.testing.unittestEnabled": false, + "ruff.configuration": "pyproject.toml", "python.testing.pytestEnabled": true, - "pylint.importStrategy": "fromEnvironment", - "mypy-type-checker.importStrategy": "fromEnvironment", - "mypy-type-checker.args": [ - "--config-file=pyproject.toml" - ], - "pylint.severity": { - "refactor": "Warning" - }, - "pylint.args": [ - "--load-plugins", "pylint_pydantic", - "--rcfile=pylintrc" - ], "python.testing.pytestArgs": [ "tests" ], + "githubIssues.issueBranchTitle": "issues/${issueNumber}-${issueTitle}", + "pylint.importStrategy": "fromEnvironment", + "pylint.args": [ + "--rcfile=pyproject.toml" + ], + } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2a0ef53f3..0435d6eb1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,23 +3,30 @@ ARG IMG_OPTION=alpine ### BUILDER -FROM python:${PYTHON_VER}-${IMG_OPTION} as BUILDER +FROM python:${PYTHON_VER}-${IMG_OPTION} AS BUILDER RUN pip install --upgrade pip WORKDIR /local COPY . /local -ENV PYTHONPATH=/local -ENV PATH=$PATH:/root/.local/bin +RUN python -m venv /opt/venv -RUN pip --no-cache-dir install --user . + +ENV PATH="/opt/venv/bin:$PATH" + +RUN apk add --no-cache build-base # Add build-base package +RUN pip --no-cache-dir install "." &&\ + pip --no-cache-dir install ".[cli]" # ----------------------------------- # ### BASE -FROM python:${PYTHON_VER}-${IMG_OPTION} as BASE +FROM python:${PYTHON_VER}-${IMG_OPTION} AS BASE + +# Add a system user +RUN adduser --system anta # Opencontainer labels # Labels version and revision will be updating @@ -30,17 +37,22 @@ FROM python:${PYTHON_VER}-${IMG_OPTION} as BASE LABEL "org.opencontainers.image.title"="anta" \ "org.opencontainers.artifact.description"="network-test-automation in a Python package and Python scripts to test Arista devices." \ "org.opencontainers.image.description"="network-test-automation in a Python package and Python scripts to test Arista devices." \ - "org.opencontainers.image.source"="https://github.com/arista-netdevops-community/anta" \ - "org.opencontainers.image.url"="https://www.anta.ninja" \ - "org.opencontainers.image.documentation"="https://www.anta.ninja" \ + "org.opencontainers.image.source"="https://github.com/aristanetworks/anta" \ + "org.opencontainers.image.url"="https://anta.arista.com" \ + "org.opencontainers.image.documentation"="https://anta.arista.com" \ "org.opencontainers.image.licenses"="Apache-2.0" \ - "org.opencontainers.image.vendor"="The anta contributors." \ + "org.opencontainers.image.vendor"="Arista Networks" \ "org.opencontainers.image.authors"="Khelil Sator, Angélique Phillipps, Colin MacGiollaEáin, Matthieu Tache, Onur Gashi, Paul Lavelle, Guillaume Mulocher, Thomas Grimonet" \ "org.opencontainers.image.base.name"="python" \ "org.opencontainers.image.revision"="dev" \ "org.opencontainers.image.version"="dev" -COPY --from=BUILDER /root/.local/ /root/.local -ENV PATH=$PATH:/root/.local/bin +# Copy artifacts from builder +COPY --from=BUILDER /opt/venv /opt/venv + +# Define PATH and default user +ENV PATH="/opt/venv/bin:$PATH" + +USER anta -ENTRYPOINT [ "/root/.local/bin/anta" ] +ENTRYPOINT [ "/opt/venv/bin/anta" ] diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..fbbee8d43 --- /dev/null +++ b/NOTICE @@ -0,0 +1,28 @@ +ANTA Project + +Copyright 2024 Arista Networks + +This product includes software developed at Arista Networks. + +------------------------------------------------------------------------ + +This product includes software developed by contributors from the +following projects, which are also licensed under the Apache License, Version 2.0: + +1. aio-eapi + - Copyright 2024 Jeremy Schulman + - URL: https://github.com/jeremyschulman/aio-eapi + +------------------------------------------------------------------------ + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/anta/__init__.py b/anta/__init__.py index 695e10b3b..339a7d309 100644 --- a/anta/__init__.py +++ b/anta/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Arista Network Test Automation (ANTA) Framework.""" @@ -17,10 +17,13 @@ "Guillaume Mulocher", "Thomas Grimonet", ] -__copyright__ = "Copyright 2022, Arista EMEA AS" +__copyright__ = "Copyright 2022-2024, Arista Networks, Inc." # ANTA Debug Mode environment variable -__DEBUG__ = bool(os.environ.get("ANTA_DEBUG", "").lower() == "true") +__DEBUG__ = os.environ.get("ANTA_DEBUG", "").lower() == "true" +if __DEBUG__: + # enable asyncio DEBUG mode when __DEBUG__ is enabled + os.environ["PYTHONASYNCIODEBUG"] = "1" # Source: https://rich.readthedocs.io/en/stable/appendix/colors.html @@ -45,4 +48,4 @@ class RICH_COLOR_PALETTE: "unset": RICH_COLOR_PALETTE.UNSET, } -GITHUB_SUGGESTION = "Please reach out to the maintainer team or open an issue on Github: https://github.com/arista-netdevops-community/anta." +GITHUB_SUGGESTION = "Please reach out to the maintainer team or open an issue on Github: https://github.com/aristanetworks/anta." diff --git a/anta/_runner.py b/anta/_runner.py new file mode 100644 index 000000000..eaa1cf9a3 --- /dev/null +++ b/anta/_runner.py @@ -0,0 +1,477 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""ANTA runner classes.""" + +from __future__ import annotations + +import logging +from asyncio import Semaphore, gather +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from inspect import getcoroutinelocals +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, ConfigDict + +from anta import GITHUB_SUGGESTION +from anta.inventory import AntaInventory +from anta.logger import anta_log_exception +from anta.models import AntaTest +from anta.result_manager import ResultManager +from anta.settings import AntaRunnerSettings +from anta.tools import Catchtime + +if TYPE_CHECKING: + from collections.abc import Coroutine + + from anta.catalog import AntaCatalog, AntaTestDefinition + from anta.device import AntaDevice + from anta.result_manager.models import TestResult + +logger = logging.getLogger(__name__) + + +class AntaRunFilters(BaseModel): + """Define filters for an ANTA run. + + Filters determine which devices and tests to include in a run, and how to + filter them with tags. This class is used by the `AntaRunner.run()` method. + + Attributes + ---------- + devices : set[str] | None, optional + Set of device names to run tests on. If `None`, includes all devices in + the inventory. Commonly set via the NRFU CLI `--device/-d` option. + tests : set[str] | None, optional + Set of test names to run. If `None`, runs all available tests in the + catalog. Commonly set via the NRFU CLI `--test/-t` option. + tags : set[str] | None, optional + Set of tags used to filter both devices and tests. A device or test + must match any of the provided tags to be included. Commonly set via + the NRFU CLI `--tags` option. + established_only : bool, default=True + When `True`, only includes devices with established connections in the + test run. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + devices: set[str] | None = None + tests: set[str] | None = None + tags: set[str] | None = None + established_only: bool = True + + +@dataclass +class AntaRunContext: + """Store the complete context and results of an ANTA run. + + A unique context is created and returned per ANTA run. + + Attributes + ---------- + inventory: AntaInventory + Initial inventory of devices provided to the run. + catalog: AntaCatalog + Initial catalog of tests provided to the run. + manager: ResultManager + Manager with the final test results. + filters: AntaRunFilters + Provided filters to the run. + selected_inventory: AntaInventory + The final inventory of devices selected for testing. + selected_tests: defaultdict[AntaDevice, set[AntaTestDefinition]] + A mapping containing the final tests to be run per device. + devices_filtered_at_setup: list[str] + List of device names that were filtered during the inventory setup phase. + devices_unreachable_at_setup: list[str] + List of device names that were found unreachable during the inventory setup phase. + warnings_at_setup: list[str] + List of warnings caught during the setup phase. + start_time: datetime | None + Start time of the run. None if not set yet. + end_time: datetime | None + End time of the run. None if not set yet. + """ + + inventory: AntaInventory + catalog: AntaCatalog + manager: ResultManager + filters: AntaRunFilters + dry_run: bool = False + + # State populated during the run + selected_inventory: AntaInventory = field(default_factory=AntaInventory) + selected_tests: defaultdict[AntaDevice, set[AntaTestDefinition]] = field(default_factory=lambda: defaultdict(set)) + devices_filtered_at_setup: list[str] = field(default_factory=list) + devices_unreachable_at_setup: list[str] = field(default_factory=list) + warnings_at_setup: list[str] = field(default_factory=list) + start_time: datetime | None = None + end_time: datetime | None = None + + @property + def total_devices_in_inventory(self) -> int: + """Total devices in the initial inventory provided to the run.""" + return len(self.inventory) + + @property + def total_devices_filtered_by_tags(self) -> int: + """Total devices filtered by tags at inventory setup.""" + return len(self.devices_filtered_at_setup) + + @property + def total_devices_unreachable(self) -> int: + """Total devices unreachable at inventory setup.""" + return len(self.devices_unreachable_at_setup) + + @property + def total_devices_selected_for_testing(self) -> int: + """Total devices selected for testing.""" + return len(self.selected_inventory) + + @property + def total_tests_scheduled(self) -> int: + """Total tests scheduled to run across all selected devices.""" + return sum(len(tests) for tests in self.selected_tests.values()) + + @property + def duration(self) -> timedelta | None: + """Calculate the duration of the run. Returns None if start or end time is not set.""" + if self.start_time and self.end_time: + return self.end_time - self.start_time + return None + + +# pylint: disable=too-few-public-methods +class AntaRunner: + """Run and manage ANTA test execution. + + This class orchestrates the execution of ANTA tests across network devices. It handles + inventory filtering, test selection, concurrent test execution, and result collection. + An `AntaRunner` instance is stateless between runs. All necessary inputs like inventory + and catalog are provided to the `run()` method. + + Attributes + ---------- + _settings : AntaRunnerSettings + Settings container for the runner. This can be provided during initialization; + otherwise, it is loaded from environment variables by default. See the + `AntaRunnerSettings` class definition in the `anta.settings` module for details. + + Notes + ----- + After initializing an `AntaRunner` instance, tests should only be executed through + the `run()` method. This method manages the complete test lifecycle including setup, + execution, and cleanup. + + Examples + -------- + ```python + import asyncio + + from anta._runner import AntaRunner, AntaRunFilters + from anta.catalog import AntaCatalog + from anta.inventory import AntaInventory + + inventory = AntaInventory.parse( + filename="anta_inventory.yml", + username="arista", + password="arista", + ) + catalog = AntaCatalog.parse(filename="anta_catalog.yml") + + # Create an ANTA runner + runner = AntaRunner() + + # Run all tests + first_run_results = asyncio.run(runner.run(inventory, catalog)) + + # Run with filters + second_run_results = asyncio.run(runner.run(inventory, catalog, filters=AntaRunFilters(tags={"leaf"}))) + ``` + """ + + def __init__(self, settings: AntaRunnerSettings | None = None) -> None: + """Initialize AntaRunner.""" + self._settings = settings if settings is not None else AntaRunnerSettings() + logger.debug("AntaRunner initialized with settings: %s", self._settings.model_dump()) + + async def run( + self, + inventory: AntaInventory, + catalog: AntaCatalog, + result_manager: ResultManager | None = None, + filters: AntaRunFilters | None = None, + *, + dry_run: bool = False, + ) -> AntaRunContext: + """Run ANTA. + + Run workflow: + + 1. Build the context object for the run. + 2. Set up the selected inventory, removing filtered/unreachable devices. + 3. Set up the selected tests, removing filtered tests. + 4. Prepare the `AntaTest` coroutines from the selected inventory and tests. + 5. Run the test coroutines if it is not a dry run. + + Parameters + ---------- + inventory + Inventory of network devices to test. + catalog + Catalog of tests to run. + result_manager + Manager for collecting and storing test results. If `None`, a new manager + is returned for each run, otherwise the provided manager is used + and results from subsequent runs are appended to it. + filters + Filters for the ANTA run. If `None`, run all tests on all devices. + dry_run + Dry-run mode flag. If `True`, run all setup steps but do not execute tests. + + Returns + ------- + AntaRunContext + The complete context and results of this ANTA run. + """ + start_time = datetime.now(tz=timezone.utc) + logger.info("ANTA run starting ...") + + ctx = AntaRunContext( + inventory=inventory, + catalog=catalog, + manager=result_manager if result_manager is not None else ResultManager(), + filters=filters if filters is not None else AntaRunFilters(), + dry_run=dry_run, + start_time=start_time, + ) + + if len(ctx.manager) > 0: + msg = ( + f"Appending new results to the provided ResultManager which already holds {len(ctx.manager)} results. " + "Statistics in this run context are for the current execution only." + ) + self._log_warning_msg(msg=msg, ctx=ctx) + + if not ctx.catalog.tests: + self._log_warning_msg(msg="The list of tests is empty. Exiting ...", ctx=ctx) + ctx.end_time = datetime.now(tz=timezone.utc) + return ctx + + with Catchtime(logger=logger, message="Preparing ANTA NRFU Run"): + # Set up inventory + setup_inventory_ok = await self._setup_inventory(ctx) + if not setup_inventory_ok: + ctx.end_time = datetime.now(tz=timezone.utc) + return ctx + + # Set up tests + with Catchtime(logger=logger, message="Preparing Tests"): + setup_tests_ok = self._setup_tests(ctx) + if not setup_tests_ok: + ctx.end_time = datetime.now(tz=timezone.utc) + return ctx + + # Get test coroutines + test_coroutines = self._get_test_coroutines(ctx) + + self._log_run_information(ctx) + + if ctx.dry_run: + logger.info("Dry-run mode, exiting before running the tests.") + self._close_test_coroutines(test_coroutines, ctx) + ctx.end_time = datetime.now(tz=timezone.utc) + return ctx + + if AntaTest.progress is not None: + AntaTest.nrfu_task = AntaTest.progress.add_task("Running NRFU Tests ...", total=ctx.total_tests_scheduled) + + with Catchtime(logger=logger, message="Running Tests"): + sem = Semaphore(self._settings.max_concurrency) + + async def run_with_sem(test_coro: Coroutine[Any, Any, TestResult]) -> TestResult: + """Wrap the test coroutine with semaphore control.""" + async with sem: + return await test_coro + + results = await gather(*[run_with_sem(coro) for coro in test_coroutines]) + for res in results: + ctx.manager.add(res) + + self._log_cache_statistics(ctx) + + ctx.end_time = datetime.now(tz=timezone.utc) + return ctx + + async def _setup_inventory(self, ctx: AntaRunContext) -> bool: + """Set up the inventory for the ANTA run. + + Returns True if the inventory setup was successful, otherwise False. + """ + initial_device_names = set(ctx.inventory.keys()) + + if not initial_device_names: + self._log_warning_msg(msg="The initial inventory is empty. Exiting ...", ctx=ctx) + return False + + # Filter the inventory based on the provided filters if any + filtered_inventory = ( + ctx.inventory.get_inventory(tags=ctx.filters.tags, devices=ctx.filters.devices) if ctx.filters.tags or ctx.filters.devices else ctx.inventory + ) + filtered_device_names = set(filtered_inventory.keys()) + ctx.devices_filtered_at_setup = sorted(initial_device_names - filtered_device_names) + + if not filtered_device_names: + msg_parts = ["The inventory is empty after filtering by tags/devices."] + if ctx.filters.devices: + msg_parts.append(f"Devices filter: {', '.join(sorted(ctx.filters.devices))}.") + if ctx.filters.tags: + msg_parts.append(f"Tags filter: {', '.join(sorted(ctx.filters.tags))}.") + msg_parts.append("Exiting ...") + self._log_warning_msg(msg=" ".join(msg_parts), ctx=ctx) + return False + + # In dry-run mode, set the selected inventory to the filtered inventory + if ctx.dry_run: + ctx.selected_inventory = filtered_inventory + return True + + # Attempt to connect to devices that passed filters + with Catchtime(logger=logger, message="Connecting to devices"): + await filtered_inventory.connect_inventory() + + # Remove devices that are unreachable if required + ctx.selected_inventory = filtered_inventory.get_inventory(established_only=True) if ctx.filters.established_only else filtered_inventory + selected_device_names = set(ctx.selected_inventory.keys()) + ctx.devices_unreachable_at_setup = sorted(filtered_device_names - selected_device_names) + + if not selected_device_names: + msg = "No reachable devices found for testing after connectivity checks. Exiting ..." + self._log_warning_msg(msg=msg, ctx=ctx) + return False + + return True + + def _setup_tests(self, ctx: AntaRunContext) -> bool: + """Set up tests for the ANTA run. + + Returns True if the test setup was successful, otherwise False. + """ + # Build indexes for the catalog. If `ctx.filters.tests` is set, filter the indexes based on these tests + ctx.catalog.build_indexes(filtered_tests=ctx.filters.tests) + + # Create the device to tests mapping from the tags + for device in ctx.selected_inventory.devices: + if ctx.filters.tags: + # If there are CLI tags, execute tests with matching tags for this device + if not (matching_tags := ctx.filters.tags.intersection(device.tags)): + # The device does not have any selected tag, skipping + # This should not never happen because the device will already be filtered by `_setup_inventory` + continue + ctx.selected_tests[device].update(ctx.catalog.get_tests_by_tags(matching_tags)) + else: + # If there is no CLI tags, execute all tests that do not have any tags + ctx.selected_tests[device].update(ctx.catalog.tag_to_tests[None]) + + # Then add the tests with matching tags from device tags + ctx.selected_tests[device].update(ctx.catalog.get_tests_by_tags(device.tags)) + + if ctx.total_tests_scheduled == 0: + msg_parts = ["No tests scheduled to run after filtering by tags/tests."] + if ctx.filters.tests: + msg_parts.append(f"Tests filter: {', '.join(sorted(ctx.filters.tests))}.") + if ctx.filters.tags: + msg_parts.append(f"Tags filter: {', '.join(sorted(ctx.filters.tags))}.") + msg_parts.append("Exiting ...") + self._log_warning_msg(msg=" ".join(msg_parts), ctx=ctx) + return False + + return True + + def _get_test_coroutines(self, ctx: AntaRunContext) -> list[Coroutine[Any, Any, TestResult]]: + """Get the test coroutines for the ANTA run.""" + coros = [] + for device, test_definitions in ctx.selected_tests.items(): + for test_def in test_definitions: + try: + coros.append(test_def.test(device=device, inputs=test_def.inputs).test()) + except Exception as exc: # noqa: BLE001, PERF203 + # An AntaTest instance is potentially user-defined code. + # We need to catch everything and exit gracefully with an error message. + msg = "\n".join( + [ + f"There is an error when creating test {test_def.test.__module__}.{test_def.test.__name__}.", + f"If this is not a custom test implementation: {GITHUB_SUGGESTION}", + ], + ) + anta_log_exception(exc, msg, logger) + return coros + + def _close_test_coroutines(self, coros: list[Coroutine[Any, Any, TestResult]], ctx: AntaRunContext) -> None: + """Close the test coroutines. Used in dry-run.""" + for coro in coros: + # Get the AntaTest instance from the coroutine locals, can be in `args` when decorated + coro_locals = getcoroutinelocals(coro) + test = coro_locals.get("self") or coro_locals.get("args") + if isinstance(test, AntaTest): + ctx.manager.add(test.result) + elif test and isinstance(test, tuple) and isinstance(test[0], AntaTest): + ctx.manager.add(test[0].result) + else: + logger.error("Coroutine %s does not have an AntaTest instance.", coro) + coro.close() + + def _log_run_information(self, ctx: AntaRunContext) -> None: + """Log ANTA run information and potential resource limit warnings.""" + logger.info("Initial inventory contains %s devices", ctx.total_devices_in_inventory) + + if ctx.total_devices_filtered_by_tags > 0: + device_list_str = ", ".join(sorted(ctx.devices_filtered_at_setup)) + logger.info("%d devices excluded by name/tag filters: %s", ctx.total_devices_filtered_by_tags, device_list_str) + + if ctx.total_devices_unreachable > 0: + device_list_str = ", ".join(sorted(ctx.devices_unreachable_at_setup)) + logger.info("%d devices found unreachable after connection attempts: %s", ctx.total_devices_unreachable, device_list_str) + + logger.info("%d devices selected for testing", ctx.total_devices_selected_for_testing) + logger.info("%d total tests scheduled across all selected devices", ctx.total_tests_scheduled) + + # Log debugs for runner settings + logger.debug("Max concurrent tests configured: %d", self._settings.max_concurrency) + if (potential_connections := ctx.selected_inventory.max_potential_connections) is not None: + logger.debug("Potential device connections estimated for this run: %d", potential_connections) + logger.debug("System file descriptor limit configured: %d", self._settings.file_descriptor_limit) + + # Log warnings for potential resource limits + if ctx.total_tests_scheduled > self._settings.max_concurrency: + msg = ( + f"Tests count ({ctx.total_tests_scheduled}) exceeds concurrent limit ({self._settings.max_concurrency}). " + "Tests will be throttled. Please consult the ANTA FAQ." + ) + self._log_warning_msg(msg=msg, ctx=ctx) + if potential_connections is not None and potential_connections > self._settings.file_descriptor_limit: + msg = ( + f"Potential connections ({potential_connections}) exceeds file descriptor limit ({self._settings.file_descriptor_limit}). " + "Connection errors may occur. Please consult the ANTA FAQ." + ) + self._log_warning_msg(msg=msg, ctx=ctx) + + def _log_cache_statistics(self, ctx: AntaRunContext) -> None: + """Log cache statistics for each device in the inventory.""" + for device in ctx.selected_inventory.devices: + if device.cache_statistics is not None: + msg = ( + f"Cache statistics for '{device.name}': " + f"{device.cache_statistics['cache_hits']} hits / {device.cache_statistics['total_commands_sent']} " + f"command(s) ({device.cache_statistics['cache_hit_ratio']})" + ) + logger.debug(msg) + else: + logger.debug("Caching is not enabled on %s", device.name) + + def _log_warning_msg(self, msg: str, ctx: AntaRunContext) -> None: + """Log the provided message at WARNING level and add it to the context warnings_at_setup list.""" + logger.warning(msg) + ctx.warnings_at_setup.append(msg) diff --git a/anta/aioeapi.py b/anta/aioeapi.py deleted file mode 100644 index f99ea1851..000000000 --- a/anta/aioeapi.py +++ /dev/null @@ -1,106 +0,0 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. -# Use of this source code is governed by the Apache License 2.0 -# that can be found in the LICENSE file. -"""Patch for aioeapi waiting for https://github.com/jeremyschulman/aio-eapi/pull/13.""" -from __future__ import annotations - -from typing import Any, AnyStr - -import aioeapi - -Device = aioeapi.Device - - -class EapiCommandError(RuntimeError): - """Exception class for EAPI command errors. - - Attributes - ---------- - failed: str - the failed command - errmsg: str - a description of the failure reason - errors: list[str] - the command failure details - passed: list[dict] - a list of command results of the commands that passed - not_exec: list[str] - a list of commands that were not executed - """ - - # pylint: disable=too-many-arguments - def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]) -> None: - """Initializer for the EapiCommandError exception.""" - self.failed = failed - self.errmsg = errmsg - self.errors = errors - self.passed = passed - self.not_exec = not_exec - super().__init__() - - def __str__(self) -> str: - """Returns the error message associated with the exception.""" - return self.errmsg - - -aioeapi.EapiCommandError = EapiCommandError - - -async def jsonrpc_exec(self, jsonrpc: dict) -> list[dict | AnyStr]: # type: ignore - """Execute the JSON-RPC dictionary object. - - Parameters - ---------- - jsonrpc: dict - The JSON-RPC as created by the `meth`:jsonrpc_command(). - - Raises - ------ - EapiCommandError - In the event that a command resulted in an error response. - - Returns - ------- - The list of command results; either dict or text depending on the - JSON-RPC format pameter. - """ - res = await self.post("/command-api", json=jsonrpc) - res.raise_for_status() - body = res.json() - - commands = jsonrpc["params"]["cmds"] - ofmt = jsonrpc["params"]["format"] - - get_output = (lambda _r: _r["output"]) if ofmt == "text" else (lambda _r: _r) - - # if there are no errors then return the list of command results. - if (err_data := body.get("error")) is None: - return [get_output(cmd_res) for cmd_res in body["result"]] - - # --------------------------------------------------------------------- - # if we are here, then there were some command errors. Raise a - # EapiCommandError exception with args (commands that failed, passed, - # not-executed). - # --------------------------------------------------------------------- - - # -------------------------- eAPI specification ---------------------- - # On an error, no result object is present, only an error object, which - # is guaranteed to have the following attributes: code, messages, and - # data. Similar to the result object in the successful response, the - # data object is a list of objects corresponding to the results of all - # commands up to, and including, the failed command. If there was a an - # error before any commands were executed (e.g. bad credentials), data - # will be empty. The last object in the data array will always - # correspond to the failed command. The command failure details are - # always stored in the errors array. - - cmd_data = err_data["data"] - len_data = len(cmd_data) - err_at = len_data - 1 - err_msg = err_data["message"] - - raise EapiCommandError( - passed=[get_output(cmd_data[cmd_i]) for cmd_i, cmd in enumerate(commands[:err_at])], - failed=commands[err_at]["cmd"], - errors=cmd_data[err_at]["errors"], - errmsg=err_msg, - not_exec=commands[err_at + 1 :], - ) - - -aioeapi.Device.jsonrpc_exec = jsonrpc_exec diff --git a/anta/catalog.py b/anta/catalog.py index a04e1591b..5239255ac 100644 --- a/anta/catalog.py +++ b/anta/catalog.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Catalog related functions.""" @@ -7,21 +7,32 @@ import importlib import logging +import math +from collections import defaultdict from inspect import isclass +from itertools import chain +from json import load as json_load from pathlib import Path -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import TYPE_CHECKING, Any, Literal, Optional, Union -from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_serializer, model_validator from pydantic.types import ImportString from pydantic_core import PydanticCustomError -from yaml import YAMLError, safe_load +from typing_extensions import deprecated +from yaml import YAMLError, safe_dump, safe_load from anta.logger import anta_log_exception from anta.models import AntaTest if TYPE_CHECKING: + import sys from types import ModuleType + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + logger = logging.getLogger(__name__) # { : [ { : }, ... ] } @@ -34,8 +45,12 @@ class AntaTestDefinition(BaseModel): """Define a test with its associated inputs. - test: An AntaTest concrete subclass - inputs: The associated AntaTest.Input subclass instance + Attributes + ---------- + test + An AntaTest concrete subclass. + inputs + The associated AntaTest.Input subclass instance. """ model_config = ConfigDict(frozen=True) @@ -43,6 +58,23 @@ class AntaTestDefinition(BaseModel): test: type[AntaTest] inputs: AntaTest.Input + @model_serializer() + def serialize_model(self) -> dict[str, AntaTest.Input]: + """Serialize the AntaTestDefinition model. + + The dictionary representing the model will be look like: + ``` + : + + ``` + + Returns + ------- + dict + A dictionary representing the model. + """ + return {self.test.__name__: self.inputs} + def __init__(self, **data: type[AntaTest] | AntaTest.Input | dict[str, Any] | None) -> None: """Inject test in the context to allow to instantiate Input in the BeforeValidator. @@ -97,7 +129,7 @@ def instantiate_inputs( raise ValueError(msg) @model_validator(mode="after") - def check_inputs(self) -> AntaTestDefinition: + def check_inputs(self) -> Self: """Check the `inputs` field typing. The `inputs` class attribute needs to be an instance of the AntaTest.Input subclass defined in the class `test`. @@ -111,14 +143,14 @@ def check_inputs(self) -> AntaTestDefinition: class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods """Represents an ANTA Test Catalog File. - Example: + Example ------- - A valid test catalog file must have the following structure: - ``` - : - - : - - ``` + A valid test catalog file must have the following structure: + ``` + : + - : + + ``` """ @@ -128,16 +160,16 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition] def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[ModuleType, list[Any]]: """Allow the user to provide a data structure with nested Python modules. - Example: + Example ------- - ``` - anta.tests.routing: - generic: - - - bgp: - - - ``` - `anta.tests.routing.generic` and `anta.tests.routing.bgp` are importable Python modules. + ``` + anta.tests.routing: + generic: + - + bgp: + - + ``` + `anta.tests.routing.generic` and `anta.tests.routing.bgp` are importable Python modules. """ modules: dict[ModuleType, list[Any]] = {} @@ -147,22 +179,22 @@ def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[Mo module_name = f".{module_name}" # noqa: PLW2901 try: module: ModuleType = importlib.import_module(name=module_name, package=package) - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: # A test module is potentially user-defined code. # We need to catch everything if we want to have meaningful logs - module_str = f"{module_name[1:] if module_name.startswith('.') else module_name}{f' from package {package}' if package else ''}" + module_str = f"{module_name.removeprefix('.')}{f' from package {package}' if package else ''}" message = f"Module named {module_str} cannot be imported. Verify that the module exists and there is no Python syntax issues." anta_log_exception(e, message, logger) raise ValueError(message) from e if isinstance(tests, dict): # This is an inner Python module modules.update(AntaCatalogFile.flatten_modules(data=tests, package=module.__name__)) - else: - if not isinstance(tests, list): - msg = f"Syntax error when parsing: {tests}\nIt must be a list of ANTA tests. Check the test catalog." - raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError + elif isinstance(tests, list): # This is a list of AntaTestDefinition modules[module] = tests + else: + msg = f"Syntax error when parsing: {tests}\nIt must be a list of ANTA tests. Check the test catalog." + raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError return modules # ANN401 - Any ok for this validator as we are validating the received data @@ -177,30 +209,58 @@ def check_tests(cls: type[AntaCatalogFile], data: Any) -> Any: # noqa: ANN401 with provided value to validate test inputs. """ if isinstance(data, dict): + if not data: + return data typed_data: dict[ModuleType, list[Any]] = AntaCatalogFile.flatten_modules(data) for module, tests in typed_data.items(): test_definitions: list[AntaTestDefinition] = [] for test_definition in tests: + if isinstance(test_definition, AntaTestDefinition): + test_definitions.append(test_definition) + continue if not isinstance(test_definition, dict): msg = f"Syntax error when parsing: {test_definition}\nIt must be a dictionary. Check the test catalog." raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError if len(test_definition) != 1: msg = ( - f"Syntax error when parsing: {test_definition}\n" - "It must be a dictionary with a single entry. Check the indentation in the test catalog." + f"Syntax error when parsing: {test_definition}\nIt must be a dictionary with a single entry. Check the indentation in the test catalog." ) raise ValueError(msg) for test_name, test_inputs in test_definition.copy().items(): test: type[AntaTest] | None = getattr(module, test_name, None) if test is None: msg = ( - f"{test_name} is not defined in Python module {module.__name__}" - f"{f' (from {module.__file__})' if module.__file__ is not None else ''}" + f"{test_name} is not defined in Python module {module.__name__}{f' (from {module.__file__})' if module.__file__ is not None else ''}" ) raise ValueError(msg) test_definitions.append(AntaTestDefinition(test=test, inputs=test_inputs)) typed_data[module] = test_definitions - return typed_data + return typed_data + return data + + def yaml(self) -> str: + """Return a YAML representation string of this model. + + Returns + ------- + str + The YAML representation string of this model. + """ + # TODO: Pydantic and YAML serialization/deserialization is not supported natively. + # This could be improved. + # https://github.com/pydantic/pydantic/issues/1043 + # Explore if this worth using this: https://github.com/NowanIlfideme/pydantic-yaml + return safe_dump(safe_load(self.model_dump_json(serialize_as_any=True, exclude_unset=True)), width=math.inf) + + def to_json(self) -> str: + """Return a JSON representation string of this model. + + Returns + ------- + str + The JSON representation string of this model. + """ + return self.model_dump_json(serialize_as_any=True, exclude_unset=True, indent=2) class AntaCatalog: @@ -216,10 +276,12 @@ def __init__( ) -> None: """Instantiate an AntaCatalog instance. - Args: - ---- - tests: A list of AntaTestDefinition instances. - filename: The path from which the catalog is loaded. + Parameters + ---------- + tests + A list of AntaTestDefinition instances. + filename + The path from which the catalog is loaded. """ self._tests: list[AntaTestDefinition] = [] @@ -227,10 +289,15 @@ def __init__( self._tests = tests self._filename: Path | None = None if filename is not None: - if isinstance(filename, Path): - self._filename = filename - else: - self._filename = Path(filename) + self._filename = filename if isinstance(filename, Path) else Path(filename) + self.indexes_built: bool + self.tag_to_tests: defaultdict[str | None, set[AntaTestDefinition]] + self._init_indexes() + + def _init_indexes(self) -> None: + """Init indexes related variables.""" + self.tag_to_tests = defaultdict(set) + self.indexes_built = False @property def filename(self) -> Path | None: @@ -252,21 +319,34 @@ def tests(self, value: list[AntaTestDefinition]) -> None: msg = "A test in the catalog must be an AntaTestDefinition instance" raise TypeError(msg) self._tests = value + # Tests were modified so indexes need to be rebuilt. + self.clear_indexes() @staticmethod - def parse(filename: str | Path) -> AntaCatalog: + def parse(filename: str | Path, file_format: Literal["yaml", "json"] = "yaml") -> AntaCatalog: """Create an AntaCatalog instance from a test catalog file. - Args: - ---- - filename: Path to test catalog YAML file + Parameters + ---------- + filename + Path to test catalog YAML or JSON file. + file_format + Format of the file, either 'yaml' or 'json'. + Returns + ------- + AntaCatalog + An AntaCatalog populated with the file content. """ + if file_format not in ["yaml", "json"]: + message = f"'{file_format}' is not a valid format for an AntaCatalog file. Only 'yaml' and 'json' are supported." + raise ValueError(message) + try: file: Path = filename if isinstance(filename, Path) else Path(filename) with file.open(encoding="UTF-8") as f: - data = safe_load(f) - except (TypeError, YAMLError, OSError) as e: + data = safe_load(f) if file_format == "yaml" else json_load(f) + except (TypeError, YAMLError, OSError, ValueError) as e: message = f"Unable to parse ANTA Test Catalog file '{filename}'" anta_log_exception(e, message, logger) raise @@ -281,11 +361,17 @@ def from_dict(data: RawCatalogInput, filename: str | Path | None = None) -> Anta It is the data structure returned by `yaml.load()` function of a valid YAML Test Catalog file. - Args: - ---- - data: Python dictionary used to instantiate the AntaCatalog instance - filename: value to be set as AntaCatalog instance attribute + Parameters + ---------- + data + Python dictionary used to instantiate the AntaCatalog instance. + filename + value to be set as AntaCatalog instance attribute + Returns + ------- + AntaCatalog + An AntaCatalog populated with the 'data' dictionary content. """ tests: list[AntaTestDefinition] = [] if data is None: @@ -297,7 +383,7 @@ def from_dict(data: RawCatalogInput, filename: str | Path | None = None) -> Anta raise TypeError(msg) try: - catalog_data = AntaCatalogFile(**data) # type: ignore[arg-type] + catalog_data = AntaCatalogFile(data) # type: ignore[arg-type] except ValidationError as e: anta_log_exception( e, @@ -315,10 +401,15 @@ def from_list(data: ListAntaTestTuples) -> AntaCatalog: See ListAntaTestTuples type alias for details. - Args: - ---- - data: Python list used to instantiate the AntaCatalog instance + Parameters + ---------- + data + Python list used to instantiate the AntaCatalog instance. + Returns + ------- + AntaCatalog + An AntaCatalog populated with the 'data' list content. """ tests: list[AntaTestDefinition] = [] try: @@ -328,40 +419,113 @@ def from_list(data: ListAntaTestTuples) -> AntaCatalog: raise return AntaCatalog(tests) - def get_tests_by_tags(self, tags: set[str], *, strict: bool = False) -> list[AntaTestDefinition]: - """Return all the tests that have matching tags in their input filters. + @classmethod + def merge_catalogs(cls, catalogs: list[AntaCatalog]) -> AntaCatalog: + """Merge multiple AntaCatalog instances. + + Parameters + ---------- + catalogs + A list of AntaCatalog instances to merge. - If strict=True, return only tests that match all the tags provided as input. - If strict=False, return all the tests that match at least one tag provided as input. + Returns + ------- + AntaCatalog + A new AntaCatalog instance containing the tests of all the input catalogs. + """ + combined_tests = list(chain(*(catalog.tests for catalog in catalogs))) + return cls(tests=combined_tests) + + @deprecated( + "This method is deprecated, use `AntaCatalogs.merge_catalogs` class method instead. This will be removed in ANTA v2.0.0.", category=DeprecationWarning + ) + def merge(self, catalog: AntaCatalog) -> AntaCatalog: + """Merge two AntaCatalog instances. + + Parameters + ---------- + catalog + AntaCatalog instance to merge to this instance. + + Returns + ------- + AntaCatalog + A new AntaCatalog instance containing the tests of the two instances. + """ + return self.merge_catalogs([self, catalog]) - Args: - ---- - tags: Tags of the tests to get. - strict: Specify if the returned tests must match all the tags provided. + def dump(self) -> AntaCatalogFile: + """Return an AntaCatalogFile instance from this AntaCatalog instance. Returns ------- - List of AntaTestDefinition that match the tags + AntaCatalogFile + An AntaCatalogFile instance containing tests of this AntaCatalog instance. """ - result: list[AntaTestDefinition] = [] + root: dict[ImportString[Any], list[AntaTestDefinition]] = {} for test in self.tests: - if test.inputs.filters and (f := test.inputs.filters.tags): - if strict: - if all(t in tags for t in f): - result.append(test) - elif any(t in tags for t in f): - result.append(test) - return result + # Cannot use AntaTest.module property as the class is not instantiated + root.setdefault(test.test.__module__, []).append(test) + return AntaCatalogFile(root=root) + + def build_indexes(self, filtered_tests: set[str] | None = None) -> None: + """Indexes tests by their tags for quick access during filtering operations. + + If a `filtered_tests` set is provided, only the tests in this set will be indexed. - def get_tests_by_names(self, names: set[str]) -> list[AntaTestDefinition]: - """Return all the tests that have matching a list of tests names. + This method populates the tag_to_tests attribute, which is a dictionary mapping tags to sets of tests. + + Once the indexes are built, the `indexes_built` attribute is set to True. + """ + for test in self.tests: + # Skip tests that are not in the specified filtered_tests set + if filtered_tests and test.test.name not in filtered_tests: + continue + + # Indexing by tag + if test.inputs.filters and (test_tags := test.inputs.filters.tags): + for tag in test_tags: + self.tag_to_tests[tag].add(test) + else: + self.tag_to_tests[None].add(test) - Args: - ---- - names: Names of the tests to get. + self.indexes_built = True + + def clear_indexes(self) -> None: + """Clear this AntaCatalog instance indexes.""" + self._init_indexes() + + def get_tests_by_tags(self, tags: set[str], *, strict: bool = False) -> set[AntaTestDefinition]: + """Return all tests that match a given set of tags, according to the specified strictness. + + Parameters + ---------- + tags + The tags to filter tests by. If empty, return all tests without tags. + strict + If True, returns only tests that contain all specified tags (intersection). + If False, returns tests that contain any of the specified tags (union). Returns ------- - List of AntaTestDefinition that match the names + set[AntaTestDefinition] + A set of tests that match the given tags. + + Raises + ------ + ValueError + If the indexes have not been built prior to method call. """ - return [test for test in self.tests if test.test.name in names] + if not self.indexes_built: + msg = "Indexes have not been built yet. Call build_indexes() first." + raise ValueError(msg) + if not tags: + return self.tag_to_tests[None] + + filtered_sets = [self.tag_to_tests[tag] for tag in tags if tag in self.tag_to_tests] + if not filtered_sets: + return set() + + if strict: + return set.intersection(*filtered_sets) + return set.union(*filtered_sets) diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py index 759194db1..689c42757 100644 --- a/anta/cli/__init__.py +++ b/anta/cli/__init__.py @@ -1,74 +1,44 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """ANTA CLI.""" from __future__ import annotations -import logging -import pathlib import sys +from typing import Callable -import click +from anta import __DEBUG__ -from anta import GITHUB_SUGGESTION, __version__ -from anta.cli.check import check as check_command -from anta.cli.debug import debug as debug_command -from anta.cli.exec import _exec as exec_command -from anta.cli.get import get as get_command -from anta.cli.nrfu import nrfu as nrfu_command -from anta.cli.utils import AliasedGroup, ExitCode -from anta.logger import Log, LogLevel, anta_log_exception, setup_logging +# Note: need to separate this file from _main to be able to fail on the import. +try: + from ._main import anta, cli -logger = logging.getLogger(__name__) +except ImportError as exc: + def build_cli(exception: ImportError) -> Callable[[], None]: + """Build CLI function using the caught exception.""" -@click.group(cls=AliasedGroup) -@click.pass_context -@click.version_option(__version__) -@click.option( - "--log-file", - help="Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout.", - show_envvar=True, - type=click.Path(file_okay=True, dir_okay=False, writable=True, path_type=pathlib.Path), -) -@click.option( - "--log-level", - "-l", - help="ANTA logging level", - default=logging.getLevelName(logging.INFO), - show_envvar=True, - show_default=True, - type=click.Choice( - [Log.CRITICAL, Log.ERROR, Log.WARNING, Log.INFO, Log.DEBUG], - case_sensitive=False, - ), -) -def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> None: - """Arista Network Test Automation (ANTA) CLI.""" - ctx.ensure_object(dict) - setup_logging(log_level, log_file) + def wrap() -> None: + """Error message if any CLI dependency is missing.""" + if not exception.name or "click" not in exception.name: + raise exception + print( + "The ANTA command line client could not run because the required " + "dependencies were not installed.\nMake sure you've installed " + "everything with: pip install 'anta[cli]'" + ) + if __DEBUG__: + print(f"The caught exception was: {exception}") -anta.add_command(nrfu_command) -anta.add_command(check_command) -anta.add_command(exec_command) -anta.add_command(get_command) -anta.add_command(debug_command) + sys.exit(1) + return wrap -def cli() -> None: - """Entrypoint for pyproject.toml.""" - try: - anta(obj={}, auto_envvar_prefix="ANTA") - except Exception as exc: # pylint: disable=broad-exception-caught - anta_log_exception( - exc, - f"Uncaught Exception when running ANTA CLI\n{GITHUB_SUGGESTION}", - logger, - ) - sys.exit(ExitCode.INTERNAL_ERROR) + cli = build_cli(exc) +__all__ = ["anta", "cli"] if __name__ == "__main__": cli() diff --git a/anta/cli/_main.py b/anta/cli/_main.py new file mode 100644 index 000000000..1dc62240d --- /dev/null +++ b/anta/cli/_main.py @@ -0,0 +1,71 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""ANTA CLI.""" + +from __future__ import annotations + +import logging +import pathlib +import sys + +import click + +from anta import GITHUB_SUGGESTION, __version__ +from anta.cli.check import check as check_command +from anta.cli.debug import debug as debug_command +from anta.cli.exec import _exec as exec_command +from anta.cli.get import get as get_command +from anta.cli.nrfu import nrfu as nrfu_command +from anta.cli.utils import AliasedGroup, ExitCode +from anta.logger import Log, LogLevel, anta_log_exception, setup_logging + +logger = logging.getLogger(__name__) + + +@click.group(cls=AliasedGroup) +@click.pass_context +@click.help_option(allow_from_autoenv=False) +@click.version_option(__version__, allow_from_autoenv=False) +@click.option( + "--log-file", + help="Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout.", + show_envvar=True, + type=click.Path(file_okay=True, dir_okay=False, writable=True, path_type=pathlib.Path), +) +@click.option( + "--log-level", + "-l", + help="ANTA logging level", + default=logging.getLevelName(logging.INFO), + show_envvar=True, + show_default=True, + type=click.Choice( + [Log.CRITICAL, Log.ERROR, Log.WARNING, Log.INFO, Log.DEBUG], + case_sensitive=False, + ), +) +def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> None: + """Arista Network Test Automation (ANTA) CLI.""" + ctx.ensure_object(dict) + setup_logging(log_level, log_file) + + +anta.add_command(nrfu_command) +anta.add_command(check_command) +anta.add_command(exec_command) +anta.add_command(get_command) +anta.add_command(debug_command) + + +def cli() -> None: + """Entrypoint for pyproject.toml.""" + try: + anta(obj={}, auto_envvar_prefix="ANTA") + except Exception as exc: # noqa: BLE001 + anta_log_exception( + exc, + f"Uncaught Exception when running ANTA CLI\n{GITHUB_SUGGESTION}", + logger, + ) + sys.exit(ExitCode.INTERNAL_ERROR) diff --git a/anta/cli/check/__init__.py b/anta/cli/check/__init__.py index bbc5a7e9d..ab1b08eb8 100644 --- a/anta/cli/check/__init__.py +++ b/anta/cli/check/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Click commands to validate configuration files.""" diff --git a/anta/cli/check/commands.py b/anta/cli/check/commands.py index 23895d73c..b810cc0bc 100644 --- a/anta/cli/check/commands.py +++ b/anta/cli/check/commands.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. # pylint: disable = redefined-outer-name @@ -22,7 +22,7 @@ @click.command -@catalog_options +@catalog_options() def catalog(catalog: AntaCatalog) -> None: """Check that the catalog is valid.""" console.print(f"[bold][green]Catalog is valid: {catalog.filename}") diff --git a/anta/cli/console.py b/anta/cli/console.py index 9c57d6d64..068e6768d 100644 --- a/anta/cli/console.py +++ b/anta/cli/console.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """ANTA Top-level Console. diff --git a/anta/cli/debug/__init__.py b/anta/cli/debug/__init__.py index 18d577fe8..d3ff5bf60 100644 --- a/anta/cli/debug/__init__.py +++ b/anta/cli/debug/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Click commands to execute EOS commands on remote devices.""" diff --git a/anta/cli/debug/commands.py b/anta/cli/debug/commands.py index 14f168ba4..54f580aaa 100644 --- a/anta/cli/debug/commands.py +++ b/anta/cli/debug/commands.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. # pylint: disable = redefined-outer-name @@ -35,7 +35,6 @@ def run_cmd( version: Literal["1", "latest"], revision: int, ) -> None: - # pylint: disable=too-many-arguments """Run arbitrary command to an ANTA device.""" console.print(f"Run command [green]{command}[/green] on [red]{device.name}[/red]") # I do not assume the following line, but click make me do it @@ -71,14 +70,16 @@ def run_template( version: Literal["1", "latest"], revision: int, ) -> None: - # pylint: disable=too-many-arguments + # Using \b for click + # ruff: noqa: D301 """Run arbitrary templated command to an ANTA device. Takes a list of arguments (keys followed by a value) to build a dictionary used as template parameters. - Example: + \b + Example ------- - anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1 + anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1 """ template_params = dict(zip(params[::2], params[1::2])) diff --git a/anta/cli/debug/utils.py b/anta/cli/debug/utils.py index a4bb5d9b2..c8ead5a5c 100644 --- a/anta/cli/debug/utils.py +++ b/anta/cli/debug/utils.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Utils functions to use with anta.cli.debug module.""" @@ -11,7 +11,7 @@ import click -from anta.cli.utils import ExitCode, inventory_options +from anta.cli.utils import ExitCode, core_options if TYPE_CHECKING: from anta.inventory import AntaInventory @@ -22,7 +22,7 @@ def debug_options(f: Callable[..., Any]) -> Callable[..., Any]: """Click common options required to execute a command on a specific device.""" - @inventory_options + @core_options @click.option( "--ofmt", type=click.Choice(["json", "text"]), @@ -44,18 +44,13 @@ def wrapper( ctx: click.Context, *args: tuple[Any], inventory: AntaInventory, - tags: set[str] | None, device: str, **kwargs: Any, ) -> Any: - # TODO: @gmuloc - tags come from context https://github.com/arista-netdevops-community/anta/issues/584 - # pylint: disable=unused-argument + # TODO: @gmuloc - tags come from context https://github.com/aristanetworks/anta/issues/584 # ruff: noqa: ARG001 - try: - d = inventory[device] - except KeyError as e: - message = f"Device {device} does not exist in Inventory" - logger.error(e, message) + if (d := inventory.get(device)) is None: + logger.error("Device '%s' does not exist in Inventory", device) ctx.exit(ExitCode.USAGE_ERROR) return f(*args, device=d, **kwargs) diff --git a/anta/cli/exec/__init__.py b/anta/cli/exec/__init__.py index 7f9b4c2b6..bcec37c54 100644 --- a/anta/cli/exec/__init__.py +++ b/anta/cli/exec/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Click commands to execute various scripts on EOS devices.""" @@ -9,7 +9,7 @@ @click.group("exec") -def _exec() -> None: # pylint: disable=redefined-builtin +def _exec() -> None: """Commands to execute various scripts on EOS devices.""" diff --git a/anta/cli/exec/commands.py b/anta/cli/exec/commands.py index 9842cc882..a29939344 100644 --- a/anta/cli/exec/commands.py +++ b/anta/cli/exec/commands.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Click commands to execute various scripts on EOS devices.""" @@ -16,7 +16,7 @@ from yaml import safe_load from anta.cli.console import console -from anta.cli.exec.utils import clear_counters_utils, collect_commands, collect_scheduled_show_tech +from anta.cli.exec import utils from anta.cli.utils import inventory_options if TYPE_CHECKING: @@ -29,7 +29,7 @@ @inventory_options def clear_counters(inventory: AntaInventory, tags: set[str] | None) -> None: """Clear counter statistics on EOS devices.""" - asyncio.run(clear_counters_utils(inventory, tags=tags)) + asyncio.run(utils.clear_counters(inventory, tags=tags)) @click.command() @@ -62,7 +62,7 @@ def snapshot(inventory: AntaInventory, tags: set[str] | None, commands_list: Pat except FileNotFoundError: logger.error("Error reading %s", commands_list) sys.exit(1) - asyncio.run(collect_commands(inventory, eos_commands, output, tags=tags)) + asyncio.run(utils.collect_commands(inventory, eos_commands, output, tags=tags)) @click.command() @@ -84,7 +84,10 @@ def snapshot(inventory: AntaInventory, tags: set[str] | None, commands_list: Pat ) @click.option( "--configure", - help="Ensure devices have 'aaa authorization exec default local' configured (required for SCP on EOS). THIS WILL CHANGE THE CONFIGURATION OF YOUR NETWORK.", + help=( + "[DEPRECATED] Ensure devices have 'aaa authorization exec default local' configured (required for SCP on EOS). " + "THIS WILL CHANGE THE CONFIGURATION OF YOUR NETWORK." + ), default=False, is_flag=True, show_default=True, @@ -98,4 +101,4 @@ def collect_tech_support( configure: bool, ) -> None: """Collect scheduled tech-support from EOS devices.""" - asyncio.run(collect_scheduled_show_tech(inventory, output, configure=configure, tags=tags, latest=latest)) + asyncio.run(utils.collect_show_tech(inventory, output, configure=configure, tags=tags, latest=latest)) diff --git a/anta/cli/exec/utils.py b/anta/cli/exec/utils.py index 758072c7e..3258d0be8 100644 --- a/anta/cli/exec/utils.py +++ b/anta/cli/exec/utils.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. @@ -10,26 +10,28 @@ import itertools import json import logging -import re from pathlib import Path from typing import TYPE_CHECKING, Literal -from aioeapi import EapiCommandError +from asyncssh.misc import HostKeyNotVerifiable from click.exceptions import UsageError from httpx import ConnectError, HTTPError from anta.device import AntaDevice, AsyncEOSDevice from anta.models import AntaCommand +from anta.tools import safe_command +from asynceapi import EapiCommandError if TYPE_CHECKING: from anta.inventory import AntaInventory + from asynceapi._types import EapiComplexCommand, EapiSimpleCommand EOS_SCHEDULED_TECH_SUPPORT = "/mnt/flash/schedule/tech-support" INVALID_CHAR = "`~!@#$/" logger = logging.getLogger(__name__) -async def clear_counters_utils(anta_inventory: AntaInventory, tags: set[str] | None = None) -> None: +async def clear_counters(anta_inventory: AntaInventory, tags: set[str] | None = None) -> None: """Clear counters.""" async def clear(dev: AntaDevice) -> None: @@ -51,7 +53,7 @@ async def clear(dev: AntaDevice) -> None: async def collect_commands( inv: AntaInventory, - commands: dict[str, str], + commands: dict[str, list[str]], root_dir: Path, tags: set[str] | None = None, ) -> None: @@ -60,18 +62,20 @@ async def collect_commands( async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "text"]) -> None: outdir = Path() / root_dir / dev.name / outformat outdir.mkdir(parents=True, exist_ok=True) - safe_command = re.sub(r"(/|\|$)", "_", command) c = AntaCommand(command=command, ofmt=outformat) await dev.collect(c) if not c.collected: logger.error("Could not collect commands on device %s: %s", dev.name, c.errors) return if c.ofmt == "json": - outfile = outdir / f"{safe_command}.json" + outfile = outdir / f"{safe_command(command)}.json" content = json.dumps(c.json_output, indent=2) elif c.ofmt == "text": - outfile = outdir / f"{safe_command}.log" + outfile = outdir / f"{safe_command(command)}.log" content = c.text_output + else: + logger.error("Command outformat is not in ['json', 'text'] for command '%s'", command) + return with outfile.open(mode="w", encoding="UTF-8") as f: f.write(content) logger.info("Collected command '%s' from device %s (%s)", command, dev.name, dev.hw_model) @@ -79,6 +83,9 @@ async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "tex logger.info("Connecting to devices...") await inv.connect_inventory() devices = inv.get_inventory(established_only=True, tags=tags).devices + if not devices: + logger.info("No online device found. Exiting") + return logger.info("Collecting commands from remote devices") coros = [] if "json_format" in commands: @@ -91,7 +98,7 @@ async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "tex logger.error("Error when collecting commands: %s", str(r)) -async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bool, tags: set[str] | None = None, latest: int | None = None) -> None: +async def collect_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bool, tags: set[str] | None = None, latest: int | None = None) -> None: # noqa: C901 """Collect scheduled show-tech on devices.""" async def collect(device: AntaDevice) -> None: @@ -103,12 +110,12 @@ async def collect(device: AntaDevice) -> None: cmd += f" | head -{latest}" command = AntaCommand(command=cmd, ofmt="text") await device.collect(command=command) - if command.collected and command.text_output: - filenames = [Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}") for f in command.text_output.splitlines()] - else: + if not (command.collected and command.text_output): logger.error("Unable to get tech-support filenames on %s: verify that %s is not empty", device.name, EOS_SCHEDULED_TECH_SUPPORT) return + filenames = [Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}") for f in command.text_output.splitlines()] + # Create directories outdir = Path() / root_dir / f"{device.name.lower()}" outdir.mkdir(parents=True, exist_ok=True) @@ -119,36 +126,49 @@ async def collect(device: AntaDevice) -> None: if command.collected and not command.text_output: logger.debug("'aaa authorization exec default local' is not configured on device %s", device.name) - if configure: - commands = [] - # TODO: @mtache - add `config` field to `AntaCommand` object to handle this use case. - # Otherwise mypy complains about enable as it is only implemented for AsyncEOSDevice - # TODO: Should enable be also included in AntaDevice? - if not isinstance(device, AsyncEOSDevice): - msg = "anta exec collect-tech-support is only supported with AsyncEOSDevice for now." - raise UsageError(msg) - if device.enable and device._enable_password is not None: # pylint: disable=protected-access - commands.append({"cmd": "enable", "input": device._enable_password}) # pylint: disable=protected-access - elif device.enable: - commands.append({"cmd": "enable"}) - commands.extend( - [ - {"cmd": "configure terminal"}, - {"cmd": "aaa authorization exec default local"}, - ], - ) - logger.warning("Configuring 'aaa authorization exec default local' on device %s", device.name) - command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text") - await device._session.cli(commands=commands) # pylint: disable=protected-access - logger.info("Configured 'aaa authorization exec default local' on device %s", device.name) - else: + if not configure: logger.error("Unable to collect tech-support on %s: configuration 'aaa authorization exec default local' is not present", device.name) return + + # TODO: ANTA 2.0.0 + msg = ( + "[DEPRECATED] Using '--configure' for collecting show-techs is deprecated and will be removed in ANTA 2.0.0. " + "Please add the required configuration on your devices before running this command from ANTA." + ) + logger.warning(msg) + + # TODO: @mtache - add `config` field to `AntaCommand` object to handle this use case. + # Otherwise mypy complains about enable as it is only implemented for AsyncEOSDevice + # TODO: Should enable be also included in AntaDevice? + if not isinstance(device, AsyncEOSDevice): + msg = "anta exec collect-tech-support is only supported with AsyncEOSDevice for now." + raise UsageError(msg) + commands: list[EapiSimpleCommand | EapiComplexCommand] = [] + if device.enable and device._enable_password is not None: + commands.append({"cmd": "enable", "input": device._enable_password}) + elif device.enable: + commands.append({"cmd": "enable"}) + commands.extend( + [ + {"cmd": "configure terminal"}, + {"cmd": "aaa authorization exec default local"}, + ], + ) + logger.warning("Configuring 'aaa authorization exec default local' on device %s", device.name) + command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text") + await device._session.cli(commands=commands) + logger.info("Configured 'aaa authorization exec default local' on device %s", device.name) + logger.debug("'aaa authorization exec default local' is already configured on device %s", device.name) await device.copy(sources=filenames, destination=outdir, direction="from") logger.info("Collected %s scheduled tech-support from %s", len(filenames), device.name) + except HostKeyNotVerifiable: + logger.error( + "Unable to collect tech-support on %s. The host SSH key could not be verified. Make sure it is part of the `known_hosts` file on your machine.", + device.name, + ) except (EapiCommandError, HTTPError, ConnectError) as e: logger.error("Unable to collect tech-support on %s: %s", device.name, str(e)) diff --git a/anta/cli/get/__init__.py b/anta/cli/get/__init__.py index abc7b3893..468dae7f6 100644 --- a/anta/cli/get/__init__.py +++ b/anta/cli/get/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Click commands to get information from or generate inventories.""" @@ -17,3 +17,5 @@ def get() -> None: get.add_command(commands.from_ansible) get.add_command(commands.inventory) get.add_command(commands.tags) +get.add_command(commands.tests) +get.add_command(commands.commands) diff --git a/anta/cli/get/commands.py b/anta/cli/get/commands.py index 2f686fa7e..6ce3c1a73 100644 --- a/anta/cli/get/commands.py +++ b/anta/cli/get/commands.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. # pylint: disable = redefined-outer-name @@ -10,20 +10,31 @@ import json import logging from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal import click +import requests from cvprac.cvp_client import CvpClient from cvprac.cvp_client_errors import CvpApiError from rich.pretty import pretty_repr from anta.cli.console import console from anta.cli.get.utils import inventory_output_options -from anta.cli.utils import ExitCode, inventory_options - -from .utils import create_inventory_from_ansible, create_inventory_from_cvp, get_cv_token +from anta.cli.utils import ExitCode, catalog_options, inventory_options + +from .utils import ( + _explore_package, + _filter_tests_via_catalog, + _get_unique_commands, + _print_commands, + create_inventory_from_ansible, + create_inventory_from_cvp, + get_cv_token, + print_tests, +) if TYPE_CHECKING: + from anta.catalog import AntaCatalog from anta.inventory import AntaInventory logger = logging.getLogger(__name__) @@ -36,14 +47,26 @@ @click.option("--username", "-u", help="CloudVision username", type=str, required=True) @click.option("--password", "-p", help="CloudVision password", type=str, required=True) @click.option("--container", "-c", help="CloudVision container where devices are configured", type=str) -def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str | None) -> None: - # pylint: disable=too-many-arguments - """Build ANTA inventory from Cloudvision. +@click.option( + "--ignore-cert", + help="Ignore verifying the SSL certificate when connecting to CloudVision", + show_envvar=True, + is_flag=True, + default=False, +) +def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str | None, *, ignore_cert: bool) -> None: + """Build ANTA inventory from CloudVision. - TODO - handle get_inventory and get_devices_in_container failure + NOTE: Only username/password authentication is supported for on-premises CloudVision instances. + Token authentication for both on-premises and CloudVision as a Service (CVaaS) is not supported. """ + # TODO: - Handle get_cv_token, get_inventory and get_devices_in_container failures. logger.info("Getting authentication token for user '%s' from CloudVision instance '%s'", username, host) - token = get_cv_token(cvp_ip=host, cvp_username=username, cvp_password=password) + try: + token = get_cv_token(cvp_ip=host, cvp_username=username, cvp_password=password, verify_cert=not ignore_cert) + except requests.exceptions.SSLError as error: + logger.error("Authentication to CloudVison failed: %s.", error) + ctx.exit(ExitCode.USAGE_ERROR) clnt = CvpClient() try: @@ -62,7 +85,11 @@ def from_cvp(ctx: click.Context, output: Path, host: str, username: str, passwor # Get devices under a container logger.info("Getting inventory for container %s from CloudVision instance '%s'", container, host) cvp_inventory = clnt.api.get_devices_in_container(container) - create_inventory_from_cvp(cvp_inventory, output) + try: + create_inventory_from_cvp(cvp_inventory, output) + except OSError as e: + logger.error(str(e)) + ctx.exit(ExitCode.USAGE_ERROR) @click.command @@ -76,7 +103,11 @@ def from_cvp(ctx: click.Context, output: Path, host: str, username: str, passwor required=True, ) def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_inventory: Path) -> None: - """Build ANTA inventory from an ansible inventory YAML file.""" + """Build ANTA inventory from an ansible inventory YAML file. + + NOTE: This command does not support inline vaulted variables. Make sure to comment them out. + + """ logger.info("Building inventory from ansible file '%s'", ansible_inventory) try: create_inventory_from_ansible( @@ -84,7 +115,7 @@ def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_i output=output, ansible_group=ansible_group, ) - except ValueError as e: + except (ValueError, OSError) as e: logger.error(str(e)) ctx.exit(ExitCode.USAGE_ERROR) @@ -109,10 +140,70 @@ def inventory(inventory: AntaInventory, tags: set[str] | None, *, connected: boo @click.command @inventory_options def tags(inventory: AntaInventory, **kwargs: Any) -> None: - # pylint: disable=unused-argument """Get list of configured tags in user inventory.""" tags: set[str] = set() for device in inventory.values(): tags.update(device.tags) console.print("Tags found:") console.print_json(json.dumps(sorted(tags), indent=2)) + + +@click.command +@click.pass_context +@click.option("--module", help="Filter tests by module name.", default="anta.tests", show_default=True) +@click.option("--test", help="Filter by specific test name. If module is specified, searches only within that module.", type=str) +@click.option("--short", help="Display test names without their inputs.", is_flag=True, default=False) +@click.option("--count", help="Print only the number of tests found.", is_flag=True, default=False) +def tests(ctx: click.Context, module: str, test: str | None, *, short: bool, count: bool) -> None: + """Show all builtin ANTA tests with an example output retrieved from each test documentation.""" + try: + tests_found = _explore_package(module, test_name=test, short=short, count=count) + if len(tests_found) == 0: + console.print(f"""No test {f"'{test}' " if test else ""}found in '{module}'.""") + elif count: + if len(tests_found) == 1: + console.print(f"There is 1 test available in '{module}'.") + else: + console.print(f"There are {len(tests_found)} tests available in '{module}'.") + else: + print_tests(tests_found, short=short) + except ValueError as e: + logger.error(str(e)) + ctx.exit(ExitCode.USAGE_ERROR) + + +@click.command +@click.pass_context +@click.option("--module", help="Filter commands by module name.", default="anta.tests", show_default=True) +@click.option("--test", help="Filter by specific test name. If module is specified, searches only within that module.", type=str) +@catalog_options(required=False) +@click.option("--unique", help="Print only the unique commands.", is_flag=True, default=False) +def commands( + ctx: click.Context, + module: str, + test: str | None, + catalog: AntaCatalog, + catalog_format: Literal["yaml", "json"] = "yaml", + *, + unique: bool, +) -> None: + """Print all EOS commands used by the selected ANTA tests. + + It can be filtered by module, test or using a catalog. + If no filter is given, all built-in ANTA tests commands are retrieved. + """ + # TODO: implement catalog and catalog format + try: + tests_found = _explore_package(module, test_name=test) + if catalog: + tests_found = _filter_tests_via_catalog(tests_found, catalog) + if len(tests_found) == 0: + console.print(f"""No test {f"'{test}' " if test else ""}found in '{module}'{f" for catalog '{catalog.filename}'" if catalog else ""}.""") + if unique: + for command in _get_unique_commands(tests_found): + console.print(command) + else: + _print_commands(tests_found) + except ValueError as e: + logger.error(str(e)) + ctx.exit(ExitCode.USAGE_ERROR) diff --git a/anta/cli/get/utils.py b/anta/cli/get/utils.py index af9b99d8c..7ce5ba4ce 100644 --- a/anta/cli/get/utils.py +++ b/anta/cli/get/utils.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Utils functions to use with anta.cli.get.commands module.""" @@ -6,23 +6,35 @@ from __future__ import annotations import functools +import importlib +import inspect import json import logging +import pkgutil +import re +import sys +import textwrap from pathlib import Path from sys import stdin -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, Callable import click import requests import urllib3 import yaml +from typing_extensions import deprecated +from anta.cli.console import console from anta.cli.utils import ExitCode from anta.inventory import AntaInventory from anta.inventory.models import AntaInventoryHost, AntaInventoryInput +from anta.models import AntaCommand, AntaTest urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +if TYPE_CHECKING: + from anta.catalog import AntaCatalog + logger = logging.getLogger(__name__) @@ -77,25 +89,65 @@ def wrapper( return wrapper -def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str) -> str: - """Generate AUTH token from CVP using password.""" - # TODO: need to handle requests error +def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str, *, verify_cert: bool) -> str: + """Generate the authentication token from CloudVision using username and password. + + TODO: need to handle requests error + + Parameters + ---------- + cvp_ip + IP address of CloudVision. + cvp_username + Username to connect to CloudVision. + cvp_password + Password to connect to CloudVision. + verify_cert + Enable or disable certificate verification when connecting to CloudVision. + Returns + ------- + str + The token to use in further API calls to CloudVision. + + Raises + ------ + requests.ssl.SSLError + If the certificate verification fails. + + """ # use CVP REST API to generate a token url = f"https://{cvp_ip}/cvpservice/login/authenticate.do" payload = json.dumps({"userId": cvp_username, "password": cvp_password}) headers = {"Content-Type": "application/json", "Accept": "application/json"} - response = requests.request("POST", url, headers=headers, data=payload, verify=False, timeout=10) + response = requests.request("POST", url, headers=headers, data=payload, verify=verify_cert, timeout=10) return response.json()["sessionId"] def write_inventory_to_file(hosts: list[AntaInventoryHost], output: Path) -> None: - """Write a file inventory from pydantic models.""" + """Write a file inventory from pydantic models. + + Parameters + ---------- + hosts: + the list of AntaInventoryHost to write to an inventory file + output: + the Path where the inventory should be written. + + Raises + ------ + OSError + When anything goes wrong while writing the file. + """ i = AntaInventoryInput(hosts=hosts) - with output.open(mode="w", encoding="UTF-8") as out_fd: - out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: i.model_dump(exclude_unset=True)})) - logger.info("ANTA inventory file has been created: '%s'", output) + try: + with output.open(mode="w", encoding="UTF-8") as out_fd: + out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: yaml.safe_load(i.yaml())})) + logger.info("ANTA inventory file has been created: '%s'", output) + except OSError as exc: + msg = f"Could not write inventory to path '{output}'." + raise OSError(msg) from exc def create_inventory_from_cvp(inv: list[dict[str, Any]], output: Path) -> None: @@ -144,16 +196,28 @@ def deep_yaml_parsing(data: dict[str, Any], hosts: list[AntaInventoryHost] | Non def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group: str = "all") -> None: """Create an ANTA inventory from an Ansible inventory YAML file. - Args: - ---- - inventory: Ansible Inventory file to read - output: ANTA inventory file to generate. - ansible_group: Ansible group from where to extract data. + Parameters + ---------- + inventory + Ansible Inventory file to read. + output + ANTA inventory file to generate. + ansible_group + Ansible group from where to extract data. """ try: with inventory.open(encoding="utf-8") as inv: ansible_inventory = yaml.safe_load(inv) + except yaml.constructor.ConstructorError as exc: + if exc.problem and "!vault" in exc.problem: + logger.error( + "`anta get from-ansible` does not support inline vaulted variables, comment them out to generate your inventory. " + "If the vaulted variable is necessary to build the inventory (e.g. `ansible_host`), it needs to be unvaulted for " + "`from-ansible` command to work." + ) + msg = f"Could not parse {inventory}." + raise ValueError(msg) from exc except OSError as exc: msg = f"Could not parse {inventory}." raise ValueError(msg) from exc @@ -169,3 +233,339 @@ def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group: raise ValueError(msg) ansible_hosts = deep_yaml_parsing(ansible_inventory) write_inventory_to_file(ansible_hosts, output) + + +def _explore_package(module_name: str, test_name: str | None = None, *, short: bool = False, count: bool = False) -> list[type[AntaTest]]: + """Parse ANTA test submodules recursively and return a list of the found AntaTest. + + Parameters + ---------- + module_name + Name of the module to explore (e.g., 'anta.tests.routing.bgp'). + test_name + If provided, only show tests starting with this name. + short + If True, only print test names without their inputs. + count + If True, only count the tests. + + Returns + ------- + list[type[AntaTest]]: + A list of the AntaTest found. + """ + result: list[type[AntaTest]] = [] + try: + module_spec = importlib.util.find_spec(module_name) + except ModuleNotFoundError: + # Relying on module_spec check below. + module_spec = None + except ImportError as e: + msg = "`--module ` option does not support relative imports" + raise ValueError(msg) from e + + # Giving a second chance adding CWD to PYTHONPATH + if module_spec is None: + try: + logger.info("Could not find module `%s`, injecting CWD in PYTHONPATH and retrying...", module_name) + sys.path = [str(Path.cwd()), *sys.path] + module_spec = importlib.util.find_spec(module_name) + except ImportError: + module_spec = None + + if module_spec is None or module_spec.origin is None: + msg = f"Module `{module_name}` was not found!" + raise ValueError(msg) + + if module_spec.submodule_search_locations: + for _, sub_module_name, ispkg in pkgutil.walk_packages(module_spec.submodule_search_locations): + qname = f"{module_name}.{sub_module_name}" + if ispkg: + result.extend(_explore_package(qname, test_name=test_name, short=short, count=count)) + continue + result.extend(find_tests_in_module(qname, test_name)) + + else: + result.extend(find_tests_in_module(module_spec.name, test_name)) + + return result + + +def find_tests_in_module(qname: str, test_name: str | None) -> list[type[AntaTest]]: + """Return the list of AntaTest in the passed module qname, potentially filtering on test_name. + + Parameters + ---------- + qname + Name of the module to explore (e.g., 'anta.tests.routing.bgp'). + test_name + If provided, only show tests starting with this name. + + Returns + ------- + list[type[AntaTest]]: + A list of the AntaTest found in the module. + """ + results: list[type[AntaTest]] = [] + try: + qname_module = importlib.import_module(qname) + except (AssertionError, ImportError) as e: + msg = f"Error when importing `{qname}` using importlib!" + raise ValueError(msg) from e + + for _name, obj in inspect.getmembers(qname_module): + # Only retrieves the subclasses of AntaTest + if not inspect.isclass(obj) or not issubclass(obj, AntaTest) or obj == AntaTest: + continue + if test_name and not obj.name.startswith(test_name): + continue + results.append(obj) + + return results + + +def _filter_tests_via_catalog(tests: list[type[AntaTest]], catalog: AntaCatalog) -> list[type[AntaTest]]: + """Return the filtered list of tests present in the catalog. + + Parameters + ---------- + tests: + List of tests. + catalog: + The AntaCatalog to use as filtering. + + Returns + ------- + list[type[AntaTest]]: + The filtered list of tests containing uniquely the tests found in the catalog. + """ + catalog_test_names = {test.test.name for test in catalog.tests} + return [test for test in tests if test.name in catalog_test_names] + + +def print_tests(tests: list[type[AntaTest]], *, short: bool = False) -> None: + """Print a list of AntaTest. + + Parameters + ---------- + tests + A list of AntaTest subclasses. + short + If True, only print test names without their inputs. + """ + + def module_name(test: type[AntaTest]) -> str: + """Return the module name for the input test. + + Used to group the test by module. + """ + return test.__module__ + + from itertools import groupby + + for module, module_tests in groupby(tests, module_name): + console.print(f"{module}:") + for test in module_tests: + print_test(test, short=short) + + +def print_test(test: type[AntaTest], *, short: bool = False) -> None: + """Print a single test. + + Parameters + ---------- + test + the representation of the AntaTest as returned by inspect.getmembers + short + If True, only print test names without their inputs. + """ + if not test.__doc__ or (example := extract_examples(test.__doc__)) is None: + msg = f"Test {test.name} in module {test.__module__} is missing an Example" + raise LookupError(msg) + # Picking up only the inputs in the examples + # Need to handle the fact that we nest the routing modules in Examples. + # This is a bit fragile. + inputs = example.split("\n") + test_name_lines = [i for i, input_entry in enumerate(inputs) if test.name in input_entry] + if not test_name_lines: + msg = f"Could not find the name of the test '{test.name}' in the Example section in the docstring." + raise ValueError(msg) + for list_index, line_index in enumerate(test_name_lines): + end = test_name_lines[list_index + 1] if list_index + 1 < len(test_name_lines) else -1 + console.print(f" {inputs[line_index].strip()}") + # Injecting the description for the first example + if list_index == 0: + console.print(f" # {test.description}", soft_wrap=True) + if not short and len(inputs) > line_index + 2: # There are params + console.print(textwrap.indent(textwrap.dedent("\n".join(inputs[line_index + 1 : end])), " " * 6)) + + +def extract_examples(docstring: str) -> str | None: + """Extract the content of the Example section in a Numpy docstring. + + Returns + ------- + str | None + The content of the section if present, None if the section is absent or empty. + """ + pattern = r"Examples\s*--------\s*(.*)(?:\n\s*\n|\Z)" + match = re.search(pattern, docstring, flags=re.DOTALL) + return match[1].strip() if match and match[1].strip() != "" else None + + +def _print_commands(tests: list[type[AntaTest]]) -> None: + """Print a list of commands per module and per test. + + Parameters + ---------- + tests + A list of AntaTest subclasses. + """ + + def module_name(test: type[AntaTest]) -> str: + """Return the module name for the input test. + + Used to group the test by module. + """ + return test.__module__ + + from itertools import groupby + + for module, module_tests in groupby(tests, module_name): + console.print(f"{module}:") + for test in module_tests: + console.print(f" - {test.name}:") + for command in test.commands: + if isinstance(command, AntaCommand): + console.print(f" - {command.command}") + else: # isinstance(command, AntaTemplate): + console.print(f" - {command.template}") + + +def _get_unique_commands(tests: list[type[AntaTest]]) -> set[str]: + """Return a set of unique commands used by the tests. + + Parameters + ---------- + tests + A list of AntaTest subclasses. + + Returns + ------- + set[str] + A set of commands or templates used by each test. + """ + result: set[str] = set() + + for test in tests: + for command in test.commands: + if isinstance(command, AntaCommand): + result.add(command.command) + else: # isinstance(command, AntaTemplate): + result.add(command.template) + + return result + + +@deprecated("This function is deprecated, use `_explore_package`. This will be removed in ANTA v2.0.0.", category=DeprecationWarning) +def explore_package(module_name: str, test_name: str | None = None, *, short: bool = False, count: bool = False) -> int: # pragma: no cover + """Parse ANTA test submodules recursively and print AntaTest examples. + + Parameters + ---------- + module_name + Name of the module to explore (e.g., 'anta.tests.routing.bgp'). + test_name + If provided, only show tests starting with this name. + short + If True, only print test names without their inputs. + count + If True, only count the tests. + + Returns + ------- + int: + The number of tests found. + """ + try: + module_spec = importlib.util.find_spec(module_name) + except ModuleNotFoundError: + # Relying on module_spec check below. + module_spec = None + except ImportError as e: + msg = "`anta get tests --module ` does not support relative imports" + raise ValueError(msg) from e + + # Giving a second chance adding CWD to PYTHONPATH + if module_spec is None: + try: + logger.info("Could not find module `%s`, injecting CWD in PYTHONPATH and retrying...", module_name) + sys.path = [str(Path.cwd()), *sys.path] + module_spec = importlib.util.find_spec(module_name) + except ImportError: + module_spec = None + + if module_spec is None or module_spec.origin is None: + msg = f"Module `{module_name}` was not found!" + raise ValueError(msg) + + tests_found = 0 + if module_spec.submodule_search_locations: + for _, sub_module_name, ispkg in pkgutil.walk_packages(module_spec.submodule_search_locations): + qname = f"{module_name}.{sub_module_name}" + if ispkg: + tests_found += explore_package(qname, test_name=test_name, short=short, count=count) + continue + tests_found += find_tests_examples(qname, test_name, short=short, count=count) + + else: + tests_found += find_tests_examples(module_spec.name, test_name, short=short, count=count) + + return tests_found + + +@deprecated("This function is deprecated, use `find_tests_in_module`. This will be removed in ANTA v2.0.0.", category=DeprecationWarning) +def find_tests_examples(qname: str, test_name: str | None, *, short: bool = False, count: bool = False) -> int: # pragma: no cover + """Print tests from `qname`, filtered by `test_name` if provided. + + Parameters + ---------- + qname + Name of the module to explore (e.g., 'anta.tests.routing.bgp'). + test_name + If provided, only show tests starting with this name. + short + If True, only print test names without their inputs. + count + If True, only count the tests. + + Returns + ------- + int: + The number of tests found. + """ + try: + qname_module = importlib.import_module(qname) + except (AssertionError, ImportError) as e: + msg = f"Error when importing `{qname}` using importlib!" + raise ValueError(msg) from e + + module_printed = False + tests_found = 0 + + for _name, obj in inspect.getmembers(qname_module): + # Only retrieves the subclasses of AntaTest + if not inspect.isclass(obj) or not issubclass(obj, AntaTest) or obj == AntaTest: + continue + if test_name and not obj.name.startswith(test_name): + continue + if not module_printed: + if not count: + console.print(f"{qname}:") + module_printed = True + tests_found += 1 + if count: + continue + print_test(obj, short=short) + + return tests_found diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index bbb2982a3..776b6fcd0 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -1,23 +1,18 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Click commands that run ANTA tests using anta.runner.""" from __future__ import annotations -import asyncio -from typing import TYPE_CHECKING, get_args +from typing import TYPE_CHECKING import click from anta.cli.nrfu import commands from anta.cli.utils import AliasedGroup, catalog_options, inventory_options -from anta.custom_types import TestStatus -from anta.models import AntaTest from anta.result_manager import ResultManager -from anta.runner import main - -from .utils import anta_progress_bar, print_settings +from anta.result_manager.models import AntaTestStatus if TYPE_CHECKING: from anta.catalog import AntaCatalog @@ -37,6 +32,7 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: """Ignore MissingParameter exception when parsing arguments if `--help` is present for a subcommand.""" # Adding a flag for potential callbacks ctx.ensure_object(dict) + ctx.obj["args"] = args if "--help" in args: ctx.obj["_anta_help"] = True @@ -46,21 +42,22 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: if "--help" not in args: raise - # remove the required params so that help can display + # Fake presence of the required params so that help can display for param in self.params: - param.required = False + if param.required: + param.value_is_missing = lambda value: False # type: ignore[method-assign] # noqa: ARG005 return super().parse_args(ctx, args) -HIDE_STATUS: list[str] = list(get_args(TestStatus)) +HIDE_STATUS: list[str] = list(AntaTestStatus) HIDE_STATUS.remove("unset") @click.group(invoke_without_command=True, cls=IgnoreRequiredWithHelp) @click.pass_context @inventory_options -@catalog_options +@catalog_options() @click.option( "--device", "-d", @@ -96,10 +93,17 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: default=None, type=click.Choice(HIDE_STATUS, case_sensitive=False), multiple=True, - help="Group result by test or device.", + help="Hide results by type: success / failure / error / skipped'.", required=False, ) -# pylint: disable=too-many-arguments +@click.option( + "--dry-run", + help="Run anta nrfu command but stop before starting to execute the tests. Considers all devices as connected.", + type=str, + show_envvar=True, + is_flag=True, + default=False, +) def nrfu( ctx: click.Context, inventory: AntaInventory, @@ -111,26 +115,36 @@ def nrfu( *, ignore_status: bool, ignore_error: bool, + dry_run: bool, + catalog_format: str = "yaml", ) -> None: """Run ANTA tests on selected inventory devices.""" # If help is invoke somewhere, skip the command if ctx.obj.get("_anta_help"): return + # We use ctx.obj to pass stuff to the next Click functions ctx.ensure_object(dict) ctx.obj["result_manager"] = ResultManager() ctx.obj["ignore_status"] = ignore_status ctx.obj["ignore_error"] = ignore_error ctx.obj["hide"] = set(hide) if hide else None - print_settings(inventory, catalog) - with anta_progress_bar() as AntaTest.progress: - asyncio.run(main(ctx.obj["result_manager"], inventory, catalog, tags=tags, devices=set(device) if device else None, tests=set(test) if test else None)) + ctx.obj["catalog"] = catalog + ctx.obj["catalog_format"] = catalog_format + ctx.obj["inventory"] = inventory + ctx.obj["tags"] = tags + ctx.obj["device"] = device + ctx.obj["test"] = test + ctx.obj["dry_run"] = dry_run + # Invoke `anta nrfu table` if no command is passed - if ctx.invoked_subcommand is None: + if not ctx.invoked_subcommand: ctx.invoke(commands.table) nrfu.add_command(commands.table) +nrfu.add_command(commands.csv) nrfu.add_command(commands.json) nrfu.add_command(commands.text) nrfu.add_command(commands.tpl_report) +nrfu.add_command(commands.md_report) diff --git a/anta/cli/nrfu/commands.py b/anta/cli/nrfu/commands.py index 4dd779b41..d21b641f9 100644 --- a/anta/cli/nrfu/commands.py +++ b/anta/cli/nrfu/commands.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Click commands that render ANTA tests results.""" @@ -13,7 +13,7 @@ from anta.cli.utils import exit_with_code -from .utils import print_jinja, print_json, print_table, print_text +from .utils import print_jinja, print_json, print_table, print_text, run_tests, save_markdown_report, save_to_csv logger = logging.getLogger(__name__) @@ -27,11 +27,9 @@ help="Group result by test or device.", required=False, ) -def table( - ctx: click.Context, - group_by: Literal["device", "test"] | None, -) -> None: - """ANTA command to check network states with table result.""" +def table(ctx: click.Context, group_by: Literal["device", "test"] | None) -> None: + """ANTA command to check network state with table results.""" + run_tests(ctx) print_table(ctx, group_by=group_by) exit_with_code(ctx) @@ -44,10 +42,14 @@ def table( type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path), show_envvar=True, required=False, - help="Path to save report as a file", + help="Path to save report as a JSON file", ) def json(ctx: click.Context, output: pathlib.Path | None) -> None: - """ANTA command to check network state with JSON result.""" + """ANTA command to check network state with JSON results. + + If no `--output` is specified, the output is printed to stdout. + """ + run_tests(ctx) print_json(ctx, output=output) exit_with_code(ctx) @@ -55,11 +57,34 @@ def json(ctx: click.Context, output: pathlib.Path | None) -> None: @click.command() @click.pass_context def text(ctx: click.Context) -> None: - """ANTA command to check network states with text result.""" + """ANTA command to check network state with text results.""" + run_tests(ctx) print_text(ctx) exit_with_code(ctx) +@click.command() +@click.pass_context +@click.option( + "--csv-output", + type=click.Path( + file_okay=True, + dir_okay=False, + exists=False, + writable=True, + path_type=pathlib.Path, + ), + show_envvar=True, + required=True, + help="Path to save report as a CSV file", +) +def csv(ctx: click.Context, csv_output: pathlib.Path) -> None: + """ANTA command to check network state with CSV report.""" + run_tests(ctx) + save_to_csv(ctx, csv_file=csv_output) + exit_with_code(ctx) + + @click.command() @click.pass_context @click.option( @@ -80,5 +105,22 @@ def text(ctx: click.Context) -> None: ) def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path | None) -> None: """ANTA command to check network state with templated report.""" + run_tests(ctx) print_jinja(results=ctx.obj["result_manager"], template=template, output=output) exit_with_code(ctx) + + +@click.command() +@click.pass_context +@click.option( + "--md-output", + type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path), + show_envvar=True, + required=True, + help="Path to save the report as a Markdown file", +) +def md_report(ctx: click.Context, md_output: pathlib.Path) -> None: + """ANTA command to check network state with Markdown report.""" + run_context = run_tests(ctx) + save_markdown_report(ctx, md_output=md_output, run_context=run_context) + exit_with_code(ctx) diff --git a/anta/cli/nrfu/utils.py b/anta/cli/nrfu/utils.py index d7e53151c..3e49efebb 100644 --- a/anta/cli/nrfu/utils.py +++ b/anta/cli/nrfu/utils.py @@ -1,10 +1,11 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Utils functions to use with anta.cli.nrfu.commands module.""" from __future__ import annotations +import asyncio import json import logging from typing import TYPE_CHECKING, Literal @@ -13,8 +14,14 @@ from rich.panel import Panel from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn +from anta import __version__ as anta_version +from anta._runner import AntaRunContext, AntaRunFilters, AntaRunner from anta.cli.console import console +from anta.cli.utils import ExitCode +from anta.models import AntaTest from anta.reporter import ReportJinja, ReportTable +from anta.reporter.csv_reporter import ReportCsv +from anta.reporter.md_reporter import MDReportGenerator if TYPE_CHECKING: import pathlib @@ -28,9 +35,41 @@ logger = logging.getLogger(__name__) -def _get_result_manager(ctx: click.Context) -> ResultManager: +def run_tests(ctx: click.Context) -> AntaRunContext: + """Run the tests.""" + # Digging up the parameters from the parent context + if ctx.parent is None: + ctx.exit() + nrfu_ctx_params = ctx.parent.params + tags = nrfu_ctx_params["tags"] + device = nrfu_ctx_params["device"] or None + test = nrfu_ctx_params["test"] or None + dry_run = nrfu_ctx_params["dry_run"] + + catalog = ctx.obj["catalog"] + inventory = ctx.obj["inventory"] + + print_settings(inventory, catalog) + with anta_progress_bar() as AntaTest.progress: + runner = AntaRunner() + filters = AntaRunFilters( + devices=set(device) if device else None, + tests=set(test) if test else None, + tags=tags, + ) + run_ctx = asyncio.run(runner.run(inventory=inventory, catalog=catalog, result_manager=ctx.obj["result_manager"], filters=filters, dry_run=dry_run)) + + if dry_run: + ctx.exit() + + return run_ctx + + +def _get_result_manager(ctx: click.Context, *, apply_hide_filter: bool = True) -> ResultManager: """Get a ResultManager instance based on Click context.""" - return ctx.obj["result_manager"].filter(ctx.obj.get("hide")) if ctx.obj.get("hide") is not None else ctx.obj["result_manager"] + if apply_hide_filter: + return ctx.obj["result_manager"].filter(ctx.obj.get("hide")) if ctx.obj.get("hide") is not None else ctx.obj["result_manager"] + return ctx.obj["result_manager"] def print_settings( @@ -38,7 +77,7 @@ def print_settings( catalog: AntaCatalog, ) -> None: """Print ANTA settings before running tests.""" - message = f"Running ANTA tests:\n- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests" + message = f"- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests" console.print(Panel.fit(message, style="cyan", title="[green]Settings")) console.print() @@ -58,22 +97,33 @@ def print_table(ctx: click.Context, group_by: Literal["device", "test"] | None = def print_json(ctx: click.Context, output: pathlib.Path | None = None) -> None: - """Print result in a json format.""" + """Print results as JSON. If output is provided, save to file instead.""" results = _get_result_manager(ctx) - console.print() - console.print(Panel("JSON results", style="cyan")) - rich.print_json(results.json) - if output is not None: - with output.open(mode="w", encoding="utf-8") as fout: - fout.write(results.json) + + if output is None: + console.print() + console.print(Panel("JSON results", style="cyan")) + rich.print_json(results.json) + else: + try: + with output.open(mode="w", encoding="utf-8") as file: + file.write(results.json) + console.print(f"JSON results saved to {output} ✅", style="cyan") + except OSError: + console.print(f"Failed to save JSON results to {output} ❌", style="cyan") + ctx.exit(ExitCode.USAGE_ERROR) def print_text(ctx: click.Context) -> None: """Print results as simple text.""" console.print() for test in _get_result_manager(ctx).results: - message = f" ({test.messages[0]!s})" if len(test.messages) > 0 else "" - console.print(f"{test.name} :: {test.test} :: [{test.result}]{test.result.upper()}[/{test.result}]{message}", highlight=False) + if len(test.messages) <= 1: + message = test.messages[0] if len(test.messages) == 1 else "" + console.print(f"{test.name} :: {test.test} :: [{test.result}]{test.result.upper()}[/{test.result}]({message})", highlight=False) + else: # len(test.messages) > 1 + console.print(f"{test.name} :: {test.test} :: [{test.result}]{test.result.upper()}[/{test.result}]", highlight=False) + console.print("\n".join(f" {message}" for message in test.messages), highlight=False) def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib.Path | None = None) -> None: @@ -88,6 +138,64 @@ def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib. file.write(report) +def save_to_csv(ctx: click.Context, csv_file: pathlib.Path) -> None: + """Save results to a CSV file.""" + try: + ReportCsv.generate(results=_get_result_manager(ctx), csv_filename=csv_file) + console.print(f"CSV report saved to {csv_file} ✅", style="cyan") + except OSError: + console.print(f"Failed to save CSV report to {csv_file} ❌", style="cyan") + ctx.exit(ExitCode.USAGE_ERROR) + + +def save_markdown_report(ctx: click.Context, md_output: pathlib.Path, run_context: AntaRunContext | None = None) -> None: + """Save the markdown report to a file. + + Parameters + ---------- + ctx + Click context containing the result manager. + md_output + Path to save the markdown report. + run_context + Optional `AntaRunContext` instance returned from `AntaRunner.run()`. + If provided, a `Run Overview` section will be generated in the report including the run context information. + """ + extra_data = None + if run_context is not None: + active_filters_dict = {} + if run_context.filters.tags: + active_filters_dict["tags"] = sorted(run_context.filters.tags) + if run_context.filters.tests: + active_filters_dict["tests"] = sorted(run_context.filters.tests) + if run_context.filters.devices: + active_filters_dict["devices"] = sorted(run_context.filters.devices) + + extra_data = { + "anta_version": anta_version, + "test_execution_start_time": run_context.start_time, + "test_execution_end_time": run_context.end_time, + "total_duration": run_context.duration, + "total_devices_in_inventory": run_context.total_devices_in_inventory, + "devices_unreachable_at_setup": run_context.devices_unreachable_at_setup, + "devices_filtered_at_setup": run_context.devices_filtered_at_setup, + "filters_applied": active_filters_dict if active_filters_dict else None, + } + + if run_context.warnings_at_setup: + extra_data["warnings_at_setup"] = run_context.warnings_at_setup + + try: + manager = _get_result_manager(ctx, apply_hide_filter=False).sort(["name", "categories", "test"]) + filtered_manager = _get_result_manager(ctx, apply_hide_filter=True).sort(["name", "categories", "test"]) + sections = [(section, filtered_manager) if section.__name__ == "TestResults" else (section, manager) for section in MDReportGenerator.DEFAULT_SECTIONS] + MDReportGenerator.generate_sections(md_filename=md_output, sections=sections, extra_data=extra_data) + console.print(f"Markdown report saved to {md_output} ✅", style="cyan") + except OSError: + console.print(f"Failed to save Markdown report to {md_output} ❌", style="cyan") + ctx.exit(ExitCode.USAGE_ERROR) + + # Adding our own ANTA spinner - overriding rich SPINNERS for our own # so ignore warning for redefinition rich.spinner.SPINNERS = { # type: ignore[attr-defined] diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 53a97190f..b5ba69cd0 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Utils functions to use with anta.cli module.""" @@ -9,15 +9,15 @@ import functools import logging from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, Literal import click -from pydantic import ValidationError from yaml import YAMLError from anta.catalog import AntaCatalog from anta.inventory import AntaInventory from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError +from anta.logger import anta_log_exception if TYPE_CHECKING: from click import Option @@ -41,7 +41,6 @@ class ExitCode(enum.IntEnum): def parse_tags(ctx: click.Context, param: Option, value: str | None) -> set[str] | None: - # pylint: disable=unused-argument # ruff: noqa: ARG001 """Click option callback to parse an ANTA inventory tags.""" if value is not None: @@ -61,9 +60,10 @@ def exit_with_code(ctx: click.Context) -> None: * 1 if status is `failure` * 2 if status is `error`. - Args: - ---- - ctx: Click Context + Parameters + ---------- + ctx + Click Context. """ if ctx.obj.get("ignore_status"): @@ -113,7 +113,7 @@ def resolve_command(self, ctx: click.Context, args: Any) -> Any: return cmd.name, cmd, args -def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]: +def core_options(f: Callable[..., Any]) -> Callable[..., Any]: """Click common options when requiring an inventory to interact with devices.""" @click.option( @@ -163,6 +163,7 @@ def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]: show_envvar=True, envvar="ANTA_TIMEOUT", show_default=True, + type=float, ) @click.option( "--insecure", @@ -192,13 +193,12 @@ def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]: type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path), ) @click.option( - "--tags", - help="List of tags using comma as separator: tag1,tag2,tag3.", + "--inventory-format", + envvar="ANTA_INVENTORY_FORMAT", show_envvar=True, - envvar="ANTA_TAGS", - type=str, - required=False, - callback=parse_tags, + help="Format of the inventory file, either 'yaml' or 'json'", + default="yaml", + type=click.Choice(["yaml", "json"], case_sensitive=False), ) @click.pass_context @functools.wraps(f) @@ -206,7 +206,6 @@ def wrapper( ctx: click.Context, *args: tuple[Any], inventory: Path, - tags: set[str] | None, username: str, password: str | None, enable_password: str | None, @@ -215,12 +214,12 @@ def wrapper( timeout: float, insecure: bool, disable_cache: bool, + inventory_format: Literal["json", "yaml"], **kwargs: dict[str, Any], ) -> Any: - # pylint: disable=too-many-arguments # If help is invoke somewhere, do not parse inventory if ctx.obj.get("_anta_help"): - return f(*args, inventory=None, tags=tags, **kwargs) + return f(*args, inventory=None, **kwargs) if prompt: # User asked for a password prompt if password is None: @@ -253,47 +252,96 @@ def wrapper( timeout=timeout, insecure=insecure, disable_cache=disable_cache, + file_format=inventory_format, ) - except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError): + except (TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError) as e: + anta_log_exception(e, f"Failed to parse the inventory: {inventory}", logger) ctx.exit(ExitCode.USAGE_ERROR) - return f(*args, inventory=i, tags=tags, **kwargs) + return f(*args, inventory=i, **kwargs) return wrapper -def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]: - """Click common options when requiring a test catalog to execute ANTA tests.""" +def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]: + """Click common options when requiring an inventory to interact with devices.""" + @core_options @click.option( - "--catalog", - "-c", - envvar="ANTA_CATALOG", + "--tags", + help="List of tags using comma as separator: tag1,tag2,tag3.", show_envvar=True, - help="Path to the test catalog YAML file", - type=click.Path( - file_okay=True, - dir_okay=False, - exists=True, - readable=True, - path_type=Path, - ), - required=True, + envvar="ANTA_TAGS", + type=str, + required=False, + callback=parse_tags, ) @click.pass_context @functools.wraps(f) def wrapper( ctx: click.Context, *args: tuple[Any], - catalog: Path, + tags: set[str] | None, **kwargs: dict[str, Any], ) -> Any: - # If help is invoke somewhere, do not parse catalog + # If help is invoke somewhere, do not parse inventory if ctx.obj.get("_anta_help"): - return f(*args, catalog=None, **kwargs) - try: - c = AntaCatalog.parse(catalog) - except (ValidationError, TypeError, ValueError, YAMLError, OSError): - ctx.exit(ExitCode.USAGE_ERROR) - return f(*args, catalog=c, **kwargs) + return f(*args, tags=tags, **kwargs) + return f(*args, tags=tags, **kwargs) + + return wrapper + + +def catalog_options(*, required: bool = True) -> Callable[..., Callable[..., Any]]: + """Click common options when requiring a test catalog to execute ANTA tests.""" + + def wrapper(f: Callable[..., Any]) -> Callable[..., Any]: + """Click common options when requiring a test catalog to execute ANTA tests.""" + + @click.option( + "--catalog", + "-c", + envvar="ANTA_CATALOG", + show_envvar=True, + help="Path to the test catalog file", + type=click.Path( + file_okay=True, + dir_okay=False, + exists=True, + readable=True, + path_type=Path, + ), + required=required, + ) + @click.option( + "--catalog-format", + envvar="ANTA_CATALOG_FORMAT", + show_envvar=True, + help="Format of the catalog file, either 'yaml' or 'json'", + default="yaml", + type=click.Choice(["yaml", "json"], case_sensitive=False), + ) + @click.pass_context + @functools.wraps(f) + def wrapper( + ctx: click.Context, + *args: tuple[Any], + catalog: Path | None, + catalog_format: Literal["yaml", "json"], + **kwargs: dict[str, Any], + ) -> Any: + # If help is invoke somewhere, do not parse catalog + if ctx.obj.get("_anta_help"): + return f(*args, catalog=None, **kwargs) + if not catalog and not required: + return f(*args, catalog=None, **kwargs) + try: + file_format = catalog_format.lower() + c = AntaCatalog.parse(catalog, file_format=file_format) # type: ignore[arg-type] + except (TypeError, ValueError, YAMLError, OSError) as e: + anta_log_exception(e, f"Failed to parse the catalog: {catalog}", logger) + ctx.exit(ExitCode.USAGE_ERROR) + return f(*args, catalog=c, **kwargs) + + return wrapper return wrapper diff --git a/anta/constants.py b/anta/constants.py new file mode 100644 index 000000000..caf9c7f07 --- /dev/null +++ b/anta/constants.py @@ -0,0 +1,87 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Constants used in ANTA.""" + +from __future__ import annotations + +ACRONYM_CATEGORIES: set[str] = { + "aaa", + "anta", + "avt", + "bfd", + "bgp", + "igmp", + "ip", + "isis", + "lanz", + "lldp", + "mlag", + "ntp", + "ospf", + "ptp", + "snmp", + "stp", + "stun", + "vlan", + "vxlan", +} +"""A set of network protocol or feature acronyms that should be represented in uppercase.""" + +MD_REPORT_TOC = """**Table of Contents:** + +- [ANTA Report](#anta-report) + - [Test Results Summary](#test-results-summary) + - [Summary Totals](#summary-totals) + - [Summary Totals Device Under Test](#summary-totals-device-under-test) + - [Summary Totals Per Category](#summary-totals-per-category) + - [Test Results](#test-results)""" +"""Table of Contents for the Markdown report.""" + +MD_REPORT_TOC_WITH_RUN_OVERVIEW = """**Table of Contents:** + +- [ANTA Report](#anta-report) + - [Run Overview](#run-overview) + - [Test Results Summary](#test-results-summary) + - [Summary Totals](#summary-totals) + - [Summary Totals Device Under Test](#summary-totals-device-under-test) + - [Summary Totals Per Category](#summary-totals-per-category) + - [Test Results](#test-results)""" +"""Table of Contents for the Markdown report, including Run Overview.""" + +KNOWN_EOS_ERRORS = [ + r"BGP inactive", + r"VRF '.*' is not active", + r".* does not support IP", + r"IS-IS (.*) is disabled because: .*", + r"No source interface .*", + r".*controller\snot\sready.*", +] +"""List of known EOS errors. + +!!! failure "Generic EOS Error Handling" + When catching these errors, **ANTA will fail the affected test** and reported the error message. +""" + +EOS_BLACKLIST_CMDS = [ + r"^reload.*", + r"^conf.*", + r"^wr.*", +] +"""List of blacklisted EOS commands. + +!!! success "Disruptive commands safeguard" + ANTA implements a mechanism to **prevent the execution of disruptive commands** such as `reload`, `write erase` or `configure terminal`. +""" + +UNSUPPORTED_PLATFORM_ERRORS = [ + "not supported on this hardware platform", + "Invalid input (at token 2: 'trident')", +] +"""Error messages indicating platform or hardware unsupported commands. Includes both general hardware +platform errors and specific ASIC family limitations. + +!!! tip "Running EOS commands unsupported by hardware" + When catching these errors, ANTA will skip the affected test and raise a warning. The **test catalog must be updated** to remove execution of the affected test + on unsupported devices. +""" diff --git a/anta/custom_types.py b/anta/custom_types.py index 5de7a612c..a83756cdc 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module that provides predefined types for AntaTest.Input instances.""" @@ -9,6 +9,29 @@ from pydantic import Field from pydantic.functional_validators import AfterValidator, BeforeValidator +# Regular Expression definition +REGEXP_PATH_MARKERS = r"[\\\/\s]" +"""Match directory path from string.""" +REGEXP_INTERFACE_ID = r"\d+(\/\d+)*(\.\d+)?" +"""Match Interface ID lilke 1/1.1.""" +REGEXP_TYPE_EOS_INTERFACE = r"^(Dps|Ethernet|Fabric|Loopback|Management|Port-Channel|Recirc-Channel|Tunnel|Vlan|Vxlan)[0-9]+(\/[0-9]+)*(\.[0-9]+)?$" +"""Match EOS interface types like Ethernet1/1, Vlan1, Loopback1, etc.""" +REGEXP_TYPE_VXLAN_SRC_INTERFACE = r"^(Loopback)([0-9]|[1-9][0-9]{1,2}|[1-7][0-9]{3}|8[01][0-9]{2}|819[01])$" +"""Match Vxlan source interface like Loopback10.""" +REGEX_TYPE_PORTCHANNEL = r"^Port-Channel[0-9]{1,6}$" +"""Match Port Channel interface like Port-Channel5.""" +REGEXP_EOS_INTERFACE_TYPE = r"^(Dps|Ethernet|Fabric|Loopback|Management|Port-Channel|Recirc-Channel|Tunnel|Vlan|Vxlan)$" +"""Match an EOS interface type like Ethernet or Loopback.""" +REGEXP_TYPE_HOSTNAME = r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$" +"""Match hostname like `my-hostname`, `my-hostname-1`, `my-hostname-1-2`.""" + + +# Regular expression for BGP redistributed routes +REGEX_IPV4_UNICAST = r"ipv4[-_ ]?unicast$" +REGEX_IPV4_MULTICAST = r"ipv4[-_ ]?multicast$" +REGEX_IPV6_UNICAST = r"ipv6[-_ ]?unicast$" +REGEX_IPV6_MULTICAST = r"ipv6[-_ ]?multicast$" + def aaa_group_prefix(v: str) -> str: """Prefix the AAA method with 'group' if it is known.""" @@ -24,20 +47,16 @@ def interface_autocomplete(v: str) -> str: - `po` will be changed to `Port-Channel` - `lo` will be changed to `Loopback` """ - intf_id_re = re.compile(r"[0-9]+(\/[0-9]+)*(\.[0-9]+)?") + intf_id_re = re.compile(REGEXP_INTERFACE_ID) m = intf_id_re.search(v) if m is None: msg = f"Could not parse interface ID in interface '{v}'" raise ValueError(msg) intf_id = m[0] - alias_map = {"et": "Ethernet", "eth": "Ethernet", "po": "Port-Channel", "lo": "Loopback"} + alias_map = {"et": "Ethernet", "eth": "Ethernet", "po": "Port-Channel", "lo": "Loopback", "vl": "Vlan"} - for alias, full_name in alias_map.items(): - if v.lower().startswith(alias): - return f"{full_name}{intf_id}" - - return v + return next((f"{full_name}{intf_id}" for alias, full_name in alias_map.items() if v.lower().startswith(alias)), v) def interface_case_sensitivity(v: str) -> str: @@ -45,12 +64,12 @@ def interface_case_sensitivity(v: str) -> str: Examples -------- - - ethernet -> Ethernet - - vlan -> Vlan - - loopback -> Loopback + - ethernet -> Ethernet + - vlan -> Vlan + - loopback -> Loopback """ - if isinstance(v, str) and len(v) > 0 and not v[0].isupper(): + if isinstance(v, str) and v != "" and not v[0].isupper(): return f"{v[0].upper()}{v[1:]}" return v @@ -58,74 +77,353 @@ def interface_case_sensitivity(v: str) -> str: def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str: """Abbreviations for different BGP multiprotocol capabilities. + Handles different separators (hyphen, underscore, space) and case sensitivity. + Examples -------- - - IPv4 Unicast - - L2vpnEVPN - - ipv4 MPLS Labels - - ipv4Mplsvpn - + ```python + >>> bgp_multiprotocol_capabilities_abbreviations("IPv4 Unicast") + 'ipv4Unicast' + >>> bgp_multiprotocol_capabilities_abbreviations("ipv4-Flow_Spec Vpn") + 'ipv4FlowSpecVpn' + >>> bgp_multiprotocol_capabilities_abbreviations("ipv6_labeled-unicast") + 'ipv6MplsLabels' + >>> bgp_multiprotocol_capabilities_abbreviations("ipv4_mpls_vpn") + 'ipv4MplsVpn' + >>> bgp_multiprotocol_capabilities_abbreviations("ipv4 mpls labels") + 'ipv4MplsLabels' + >>> bgp_multiprotocol_capabilities_abbreviations("rt-membership") + 'rtMembership' + >>> bgp_multiprotocol_capabilities_abbreviations("dynamic-path-selection") + 'dps' + ``` """ patterns = { - r"\b(l2[\s\-]?vpn[\s\-]?evpn)\b": "l2VpnEvpn", - r"\bipv4[\s_-]?mpls[\s_-]?label(s)?\b": "ipv4MplsLabels", - r"\bipv4[\s_-]?mpls[\s_-]?vpn\b": "ipv4MplsVpn", - r"\bipv4[\s_-]?uni[\s_-]?cast\b": "ipv4Unicast", + f"{r'dynamic[-_ ]?path[-_ ]?selection$'}": "dps", + f"{r'dps$'}": "dps", + f"{REGEX_IPV4_UNICAST}": "ipv4Unicast", + f"{REGEX_IPV6_UNICAST}": "ipv6Unicast", + f"{REGEX_IPV4_MULTICAST}": "ipv4Multicast", + f"{REGEX_IPV6_MULTICAST}": "ipv6Multicast", + f"{r'ipv4[-_ ]?labeled[-_ ]?Unicast$'}": "ipv4MplsLabels", + f"{r'ipv4[-_ ]?mpls[-_ ]?labels$'}": "ipv4MplsLabels", + f"{r'ipv6[-_ ]?labeled[-_ ]?Unicast$'}": "ipv6MplsLabels", + f"{r'ipv6[-_ ]?mpls[-_ ]?labels$'}": "ipv6MplsLabels", + f"{r'ipv4[-_ ]?sr[-_ ]?te$'}": "ipv4SrTe", # codespell:ignore + f"{r'ipv6[-_ ]?sr[-_ ]?te$'}": "ipv6SrTe", # codespell:ignore + f"{r'ipv4[-_ ]?mpls[-_ ]?vpn$'}": "ipv4MplsVpn", + f"{r'ipv6[-_ ]?mpls[-_ ]?vpn$'}": "ipv6MplsVpn", + f"{r'ipv4[-_ ]?Flow[-_ ]?spec$'}": "ipv4FlowSpec", + f"{r'ipv6[-_ ]?Flow[-_ ]?spec$'}": "ipv6FlowSpec", + f"{r'ipv4[-_ ]?Flow[-_ ]?spec[-_ ]?vpn$'}": "ipv4FlowSpecVpn", + f"{r'ipv6[-_ ]?Flow[-_ ]?spec[-_ ]?vpn$'}": "ipv6FlowSpecVpn", + f"{r'l2[-_ ]?vpn[-_ ]?vpls$'}": "l2VpnVpls", + f"{r'l2[-_ ]?vpn[-_ ]?evpn$'}": "l2VpnEvpn", + f"{r'link[-_ ]?state$'}": "linkState", + f"{r'rt[-_ ]?membership$'}": "rtMembership", + f"{r'ipv4[-_ ]?rt[-_ ]?membership$'}": "rtMembership", + f"{r'ipv4[-_ ]?mvpn$'}": "ipv4Mvpn", } + for pattern, replacement in patterns.items(): + match = re.match(pattern, value, re.IGNORECASE) + if match: + return replacement + return value + + +def validate_regex(value: str) -> str: + """Validate that the input value is a valid regex format.""" + try: + re.compile(value) + except re.error as e: + msg = f"Invalid regex: {e}" + raise ValueError(msg) from e + return value + + +def bgp_redistributed_route_proto_abbreviations(value: str) -> str: + """Abbreviations for different BGP redistributed route protocols. + + Handles different separators (hyphen, underscore, space) and case sensitivity. + + Examples + -------- + ```python + >>> bgp_redistributed_route_proto_abbreviations("IPv4 Unicast") + 'v4u' + >>> bgp_redistributed_route_proto_abbreviations("IPv4-multicast") + 'v4m' + >>> bgp_redistributed_route_proto_abbreviations("IPv6_multicast") + 'v6m' + >>> bgp_redistributed_route_proto_abbreviations("ipv6unicast") + 'v6u' + ``` + """ + patterns = {REGEX_IPV4_UNICAST: "v4u", REGEX_IPV4_MULTICAST: "v4m", REGEX_IPV6_UNICAST: "v6u", REGEX_IPV6_MULTICAST: "v6m"} for pattern, replacement in patterns.items(): - match = re.search(pattern, value, re.IGNORECASE) + match = re.match(pattern, value, re.IGNORECASE) if match: return replacement return value -# ANTA framework -TestStatus = Literal["unset", "success", "failure", "error", "skipped"] +def update_bgp_redistributed_proto_user(value: str) -> str: + """Update BGP redistributed route `User` proto with EOS SDK. + + Examples + -------- + ```python + >>> update_bgp_redistributed_proto_user("User") + 'EOS SDK' + >>> update_bgp_redistributed_proto_user("Bgp") + 'Bgp' + >>> update_bgp_redistributed_proto_user("RIP") + 'RIP' + ``` + """ + if value == "User": + value = "EOS SDK" + + return value + + +def convert_reload_cause(value: str) -> str: + """Convert a reload cause abbreviation into its full descriptive string. + + Examples + -------- + ```python + >>> convert_reload_cause("ZTP") + 'System reloaded due to Zero Touch Provisioning' + ``` + """ + reload_causes = {"ZTP": "System reloaded due to Zero Touch Provisioning", "USER": "Reload requested by the user.", "FPGA": "Reload requested after FPGA upgrade"} + if not reload_causes.get(value.upper()): + msg = f"Invalid reload cause: '{value}' - expected causes are {list(reload_causes)}" + raise ValueError(msg) + return reload_causes[value.upper()] + # AntaTest.Input types AAAAuthMethod = Annotated[str, AfterValidator(aaa_group_prefix)] -Vlan = Annotated[int, Field(ge=0, le=4094)] +VlanId = Annotated[int, Field(ge=0, le=4094)] MlagPriority = Annotated[int, Field(ge=1, le=32767)] Vni = Annotated[int, Field(ge=1, le=16777215)] Interface = Annotated[ str, - Field(pattern=r"^(Dps|Ethernet|Fabric|Loopback|Management|Port-Channel|Tunnel|Vlan|Vxlan)[0-9]+(\/[0-9]+)*(\.[0-9]+)?$"), + Field(pattern=REGEXP_TYPE_EOS_INTERFACE), + BeforeValidator(interface_autocomplete), + BeforeValidator(interface_case_sensitivity), +] +EthernetInterface = Annotated[ + str, + Field(pattern=r"^Ethernet[0-9]+(\/[0-9]+)*$"), BeforeValidator(interface_autocomplete), BeforeValidator(interface_case_sensitivity), ] VxlanSrcIntf = Annotated[ str, - Field(pattern=r"^(Loopback)([0-9]|[1-9][0-9]{1,2}|[1-7][0-9]{3}|8[01][0-9]{2}|819[01])$"), + Field(pattern=REGEXP_TYPE_VXLAN_SRC_INTERFACE), + BeforeValidator(interface_autocomplete), + BeforeValidator(interface_case_sensitivity), +] +PortChannelInterface = Annotated[ + str, + Field(pattern=REGEX_TYPE_PORTCHANNEL), BeforeValidator(interface_autocomplete), BeforeValidator(interface_case_sensitivity), ] +InterfaceType = Annotated[ + str, + Field(pattern=REGEXP_EOS_INTERFACE_TYPE), + BeforeValidator(interface_case_sensitivity), +] Afi = Literal["ipv4", "ipv6", "vpn-ipv4", "vpn-ipv6", "evpn", "rt-membership", "path-selection", "link-state"] Safi = Literal["unicast", "multicast", "labeled-unicast", "sr-te"] EncryptionAlgorithm = Literal["RSA", "ECDSA"] RsaKeySize = Literal[2048, 3072, 4096] -EcdsaKeySize = Literal[256, 384, 521] -MultiProtocolCaps = Annotated[str, BeforeValidator(bgp_multiprotocol_capabilities_abbreviations)] +EcdsaKeySize = Literal[256, 384, 512] +MultiProtocolCaps = Annotated[ + Literal[ + "dps", + "ipv4Unicast", + "ipv6Unicast", + "ipv4Multicast", + "ipv6Multicast", + "ipv4MplsLabels", + "ipv6MplsLabels", + "ipv4SrTe", + "ipv6SrTe", + "ipv4MplsVpn", + "ipv6MplsVpn", + "ipv4FlowSpec", + "ipv6FlowSpec", + "ipv4FlowSpecVpn", + "ipv6FlowSpecVpn", + "l2VpnVpls", + "l2VpnEvpn", + "linkState", + "rtMembership", + "ipv4Mvpn", + ], + BeforeValidator(bgp_multiprotocol_capabilities_abbreviations), +] BfdInterval = Annotated[int, Field(ge=50, le=60000)] BfdMultiplier = Annotated[int, Field(ge=3, le=50)] ErrDisableReasons = Literal[ "acl", "arp-inspection", + "bgp-session-tracking", "bpduguard", + "dot1x", + "dot1x-coa", "dot1x-session-replace", + "evpn-sa-mh", + "fabric-link-failure", + "fabric-link-flap", "hitless-reload-down", + "lacp-no-portid", "lacp-rate-limit", + "license-enforce", "link-flap", + "mlagasu", + "mlagdualprimary", + "mlagissu", + "mlagmaintdown", "no-internal-vlan", + "out-of-voqs", "portchannelguard", + "portgroup-disabled", "portsec", + "speed-misconfigured", + "storm-control", + "stp-no-portid", + "stuck-queue", "tapagg", "uplink-failure-detection", + "xcvr-misconfigured", + "xcvr-overheat", + "xcvr-power-unsupported", + "xcvr-unsupported", ] ErrDisableInterval = Annotated[int, Field(ge=30, le=86400)] Percent = Annotated[float, Field(ge=0.0, le=100.0)] PositiveInteger = Annotated[int, Field(ge=0)] Revision = Annotated[int, Field(ge=1, le=99)] -Hostname = Annotated[str, Field(pattern=r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$")] +Hostname = Annotated[str, Field(pattern=REGEXP_TYPE_HOSTNAME)] Port = Annotated[int, Field(ge=1, le=65535)] +RegexString = Annotated[str, AfterValidator(validate_regex)] +BgpDropStats = Literal[ + "inDropAsloop", + "inDropClusterIdLoop", + "inDropMalformedMpbgp", + "inDropOrigId", + "inDropNhLocal", + "inDropNhAfV6", + "prefixDroppedMartianV4", + "prefixDroppedMaxRouteLimitViolatedV4", + "prefixDroppedMartianV6", + "prefixDroppedMaxRouteLimitViolatedV6", + "prefixLuDroppedV4", + "prefixLuDroppedMartianV4", + "prefixLuDroppedMaxRouteLimitViolatedV4", + "prefixLuDroppedV6", + "prefixLuDroppedMartianV6", + "prefixLuDroppedMaxRouteLimitViolatedV6", + "prefixEvpnDroppedUnsupportedRouteType", + "prefixBgpLsDroppedReceptionUnsupported", + "outDropV4LocalAddr", + "outDropV6LocalAddr", + "prefixVpnIpv4DroppedImportMatchFailure", + "prefixVpnIpv4DroppedMaxRouteLimitViolated", + "prefixVpnIpv6DroppedImportMatchFailure", + "prefixVpnIpv6DroppedMaxRouteLimitViolated", + "prefixEvpnDroppedImportMatchFailure", + "prefixEvpnDroppedMaxRouteLimitViolated", + "prefixRtMembershipDroppedLocalAsReject", + "prefixRtMembershipDroppedMaxRouteLimitViolated", +] +BgpUpdateError = Literal["inUpdErrWithdraw", "inUpdErrIgnore", "inUpdErrDisableAfiSafi", "disabledAfiSafi", "lastUpdErrTime"] +BfdProtocol = Literal["bgp", "isis", "lag", "ospf", "ospfv3", "pim", "route-input", "static-bfd", "static-route", "vrrp", "vxlan"] +IPv4RouteType = Literal[ + "connected", + "static", + "kernel", + "OSPF", + "OSPF inter area", + "OSPF external type 1", + "OSPF external type 2", + "OSPF NSSA external type 1", + "OSPF NSSA external type2", + "Other BGP Routes", + "iBGP", + "eBGP", + "RIP", + "IS-IS level 1", + "IS-IS level 2", + "OSPFv3", + "BGP Aggregate", + "OSPF Summary", + "Nexthop Group Static Route", + "VXLAN Control Service", + "Martian", + "DHCP client installed default route", + "Dynamic Policy Route", + "VRF Leaked", + "gRIBI", + "Route Cache Route", + "CBF Leaked Route", +] +DynamicVlanSource = Literal["dmf", "dot1x", "dynvtep", "evpn", "mlag", "mlagsync", "mvpn", "swfwd", "vccbfd"] +LogSeverityLevel = Literal["alerts", "critical", "debugging", "emergencies", "errors", "informational", "notifications", "warnings"] + + +######################################## +# SNMP +######################################## +def snmp_v3_prefix(auth_type: Literal["auth", "priv", "noauth"]) -> str: + """Prefix the SNMP authentication type with 'v3'.""" + if auth_type == "noauth": + return "v3NoAuth" + return f"v3{auth_type.title()}" + + +SnmpVersion = Literal["v1", "v2c", "v3"] +SnmpHashingAlgorithm = Literal["MD5", "SHA", "SHA-224", "SHA-256", "SHA-384", "SHA-512"] +SnmpEncryptionAlgorithm = Literal["AES-128", "AES-192", "AES-256", "DES"] +SnmpPdu = Literal["inGetPdus", "inGetNextPdus", "inSetPdus", "outGetResponsePdus", "outTrapPdus"] +SnmpErrorCounter = Literal[ + "inVersionErrs", "inBadCommunityNames", "inBadCommunityUses", "inParseErrs", "outTooBigErrs", "outNoSuchNameErrs", "outBadValueErrs", "outGeneralErrs" +] +SnmpVersionV3AuthType = Annotated[Literal["auth", "priv", "noauth"], AfterValidator(snmp_v3_prefix)] +RedistributedProtocol = Annotated[ + Literal[ + "AttachedHost", + "Bgp", + "Connected", + "DHCP", + "Dynamic", + "IS-IS", + "OSPF Internal", + "OSPF External", + "OSPF Nssa-External", + "OSPFv3 Internal", + "OSPFv3 External", + "OSPFv3 Nssa-External", + "RIP", + "Static", + "User", + ], + AfterValidator(update_bgp_redistributed_proto_user), +] +RedistributedAfiSafi = Annotated[Literal["v4u", "v4m", "v6u", "v6m"], BeforeValidator(bgp_redistributed_route_proto_abbreviations)] +NTPStratumLevel = Annotated[int, Field(ge=0, le=16)] +PowerSupplyFanStatus = Literal["failed", "ok", "unknownHwStatus", "powerLoss", "unsupported"] +PowerSupplyStatus = Literal["ok", "unknown", "powerLoss", "failed"] +ReloadCause = Annotated[ + Literal["System reloaded due to Zero Touch Provisioning", "Reload requested by the user.", "Reload requested after FPGA upgrade", "USER", "FPGA", "ZTP"], + BeforeValidator(convert_reload_cause), +] +BgpCommunity = Literal["standard", "extended", "large"] diff --git a/anta/decorators.py b/anta/decorators.py index dc57e13ec..3474e57b7 100644 --- a/anta/decorators.py +++ b/anta/decorators.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """decorators for tests.""" @@ -17,29 +17,34 @@ F = TypeVar("F", bound=Callable[..., Any]) -def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]: +# TODO: Remove this decorator in ANTA v2.0.0 in favor of deprecated_test_class +def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]: # pragma: no cover """Return a decorator to log a message of WARNING severity when a test is deprecated. - Args: - ---- - new_tests: A list of new test classes that should replace the deprecated test. + Parameters + ---------- + new_tests + A list of new test classes that should replace the deprecated test. Returns ------- - Callable[[F], F]: A decorator that can be used to wrap test functions. + Callable[[F], F] + A decorator that can be used to wrap test functions. """ def decorator(function: F) -> F: """Actual decorator that logs the message. - Args: - ---- - function: The test function to be decorated. + Parameters + ---------- + function + The test function to be decorated. Returns ------- - F: The decorated function. + F + The decorated function. """ @@ -53,7 +58,58 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: logger.warning("%s test is deprecated.", anta_test.name) return await function(*args, **kwargs) - return cast(F, wrapper) + return cast("F", wrapper) + + return decorator + + +def deprecated_test_class(new_tests: list[str] | None = None, removal_in_version: str | None = None) -> Callable[[type[AntaTest]], type[AntaTest]]: + """Return a decorator to log a message of WARNING severity when a test is deprecated. + + Parameters + ---------- + new_tests + A list of new test classes that should replace the deprecated test. + removal_in_version + A string indicating the version in which the test will be removed. + + Returns + ------- + Callable[[type], type] + A decorator that can be used to wrap test functions. + + """ + + def decorator(cls: type[AntaTest]) -> type[AntaTest]: + """Actual decorator that logs the message. + + Parameters + ---------- + cls + The cls to be decorated. + + Returns + ------- + cls + The decorated cls. + """ + orig_init = cls.__init__ + + def new_init(*args: Any, **kwargs: Any) -> None: + """Overload __init__ to generate a warning message for deprecation.""" + if new_tests: + new_test_names = ", ".join(new_tests) + logger.warning("%s test is deprecated. Consider using the following new tests: %s.", cls.name, new_test_names) + else: + logger.warning("%s test is deprecated.", cls.name) + orig_init(*args, **kwargs) + + if removal_in_version is not None: + cls.__removal_in_version = removal_in_version + + # NOTE: we are ignoring mypy warning as we want to assign to a method here + cls.__init__ = new_init # type: ignore[method-assign] + return cls return decorator @@ -64,26 +120,30 @@ def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]: This decorator factory generates a decorator that will check the hardware model of the device the test is run on. If the model is in the list of platforms specified, the test will be skipped. - Args: - ---- - platforms: List of hardware models on which the test should be skipped. + Parameters + ---------- + platforms + List of hardware models on which the test should be skipped. Returns ------- - Callable[[F], F]: A decorator that can be used to wrap test functions. + Callable[[F], F] + A decorator that can be used to wrap test functions. """ def decorator(function: F) -> F: """Actual decorator that either runs the test or skips it based on the device's hardware model. - Args: - ---- - function: The test function to be decorated. + Parameters + ---------- + function + The test function to be decorated. Returns ------- - F: The decorated function. + F + The decorated function. """ @@ -101,12 +161,12 @@ async def wrapper(*args: Any, **kwargs: Any) -> TestResult: return anta_test.result if anta_test.device.hw_model in platforms: - anta_test.result.is_skipped(f"{anta_test.__class__.__name__} test is not supported on {anta_test.device.hw_model}.") + anta_test.result.is_skipped(f"{anta_test.__class__.__name__} test is not supported on {anta_test.device.hw_model}") AntaTest.update_progress() return anta_test.result return await function(*args, **kwargs) - return cast(F, wrapper) + return cast("F", wrapper) return decorator diff --git a/anta/device.py b/anta/device.py index d517b8fb0..544f63ee8 100644 --- a/anta/device.py +++ b/anta/device.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """ANTA Device Abstraction Module.""" @@ -8,17 +8,17 @@ import asyncio import logging from abc import ABC, abstractmethod -from collections import defaultdict +from collections import OrderedDict, defaultdict +from time import monotonic from typing import TYPE_CHECKING, Any, Literal import asyncssh import httpcore -from aiocache import Cache -from aiocache.plugins import HitMissRatioPlugin from asyncssh import SSHClientConnection, SSHClientConnectionOptions from httpx import ConnectError, HTTPError, TimeoutException -from anta import __DEBUG__, aioeapi +import asynceapi +from anta import __DEBUG__ from anta.logger import anta_log_exception, exc_to_str from anta.models import AntaCommand @@ -26,12 +26,79 @@ from collections.abc import Iterator from pathlib import Path + from asynceapi._types import EapiComplexCommand, EapiSimpleCommand + logger = logging.getLogger(__name__) # Do not load the default keypairs multiple times due to a performance issue introduced in cryptography 37.0 # https://github.com/pyca/cryptography/issues/7236#issuecomment-1131908472 CLIENT_KEYS = asyncssh.public_key.load_default_keypairs() +# Limit concurrency to 100 requests (HTTPX default) to avoid high-concurrency performance issues +# See: https://github.com/encode/httpx/issues/3215 +MAX_CONCURRENT_REQUESTS = 100 + + +class AntaCache: + """Class to be used as cache. + + Example + ------- + + ```python + # Create cache + cache = AntaCache("device1") + with cache.locks[key]: + command_output = cache.get(key) + ``` + """ + + def __init__(self, device: str, max_size: int = 128, ttl: int = 60) -> None: + """Initialize the cache.""" + self.device = device + self.cache: OrderedDict[str, Any] = OrderedDict() + self.locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock) + self.max_size = max_size + self.ttl = ttl + + # Stats + self.stats: dict[str, int] = {} + self._init_stats() + + def _init_stats(self) -> None: + """Initialize the stats.""" + self.stats["hits"] = 0 + self.stats["total"] = 0 + + async def get(self, key: str) -> Any: # noqa: ANN401 + """Return the cached entry for key.""" + self.stats["total"] += 1 + if key in self.cache: + timestamp, value = self.cache[key] + if monotonic() - timestamp < self.ttl: + # checking the value is still valid + self.cache.move_to_end(key) + self.stats["hits"] += 1 + return value + # Time expired + del self.cache[key] + del self.locks[key] + return None + + async def set(self, key: str, value: Any) -> bool: # noqa: ANN401 + """Set the cached entry for key to value.""" + timestamp = monotonic() + if len(self.cache) > self.max_size: + self.cache.popitem(last=False) + self.cache[key] = timestamp, value + return True + + def clear(self) -> None: + """Empty the cache.""" + logger.debug("Clearing cache for device %s", self.device) + self.cache = OrderedDict() + self._init_stats() + class AntaDevice(ABC): """Abstract class representing a device in ANTA. @@ -41,24 +108,38 @@ class AntaDevice(ABC): Attributes ---------- - name: Device name - is_online: True if the device IP is reachable and a port can be open. - established: True if remote command execution succeeds. - hw_model: Hardware model of the device. - tags: Tags for this device. - cache: In-memory cache from aiocache library for this device (None if cache is disabled). - cache_locks: Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled. - + name : str + Device name. + is_online : bool + True if the device IP is reachable and a port can be open. + established : bool + True if remote command execution succeeds. + hw_model : str | None + Hardware model of the device. + tags : set[str] + Tags for this device. + cache : AntaCache | None + In-memory cache for this device (None if cache is disabled). + cache_locks : defaultdict[str, asyncio.Lock] | None + Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled. + Deprecated, will be removed in ANTA v2.0.0, use self.cache.locks instead. + max_connections : int | None + For informational/logging purposes only. Can be used by the runner to verify that + the total potential connections of a run do not exceed the system file descriptor limit. + This does **not** affect the actual device configuration. None if not available. """ def __init__(self, name: str, tags: set[str] | None = None, *, disable_cache: bool = False) -> None: """Initialize an AntaDevice. - Args: - ---- - name: Device name. - tags: Tags for this device. - disable_cache: Disable caching for all commands for this device. + Parameters + ---------- + name + Device name. + tags + Tags for this device. + disable_cache + Disable caching for all commands for this device. """ self.name: str = name @@ -68,7 +149,8 @@ def __init__(self, name: str, tags: set[str] | None = None, *, disable_cache: bo self.tags.add(self.name) self.is_online: bool = False self.established: bool = False - self.cache: Cache | None = None + self.cache: AntaCache | None = None + # Keeping cache_locks for backward compatibility. self.cache_locks: defaultdict[str, asyncio.Lock] | None = None # Initialize cache if not disabled @@ -80,6 +162,11 @@ def __init__(self, name: str, tags: set[str] | None = None, *, disable_cache: bo def _keys(self) -> tuple[Any, ...]: """Read-only property to implement hashing and equality for AntaDevice classes.""" + @property + def max_connections(self) -> int | None: + """Maximum number of concurrent connections allowed by the device. Can be overridden by subclasses, returns None if not available.""" + return None + def __eq__(self, other: object) -> bool: """Implement equality for AntaDevice objects.""" return self._keys == other._keys if isinstance(other, self.__class__) else False @@ -90,17 +177,16 @@ def __hash__(self) -> int: def _init_cache(self) -> None: """Initialize cache for the device, can be overridden by subclasses to manipulate how it works.""" - self.cache = Cache(cache_class=Cache.MEMORY, ttl=60, namespace=self.name, plugins=[HitMissRatioPlugin()]) - self.cache_locks = defaultdict(asyncio.Lock) + self.cache = AntaCache(device=self.name, ttl=60) + self.cache_locks = self.cache.locks @property def cache_statistics(self) -> dict[str, Any] | None: - """Returns the device cache statistics for logging purposes.""" - # Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough - # https://github.com/pylint-dev/pylint/issues/7258 + """Return the device cache statistics for logging purposes.""" if self.cache is not None: - stats = getattr(self.cache, "hit_miss_ratio", {"total": 0, "hits": 0, "hit_ratio": 0}) - return {"total_commands_sent": stats["total"], "cache_hits": stats["hits"], "cache_hit_ratio": f"{stats['hit_ratio'] * 100:.2f}%"} + stats = self.cache.stats + ratio = stats["hits"] / stats["total"] if stats["total"] > 0 else 0 + return {"total_commands_sent": stats["total"], "cache_hits": stats["hits"], "cache_hit_ratio": f"{ratio * 100:.2f}%"} return None def __rich_repr__(self) -> Iterator[tuple[str, Any]]: @@ -115,8 +201,19 @@ def __rich_repr__(self) -> Iterator[tuple[str, Any]]: yield "established", self.established yield "disable_cache", self.cache is None + def __repr__(self) -> str: + """Return a printable representation of an AntaDevice.""" + return ( + f"AntaDevice({self.name!r}, " + f"tags={self.tags!r}, " + f"hw_model={self.hw_model!r}, " + f"is_online={self.is_online!r}, " + f"established={self.established!r}, " + f"disable_cache={self.cache is None!r})" + ) + @abstractmethod - async def _collect(self, command: AntaCommand) -> None: + async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: """Collect device command output. This abstract coroutine can be used to implement any command collection method @@ -129,13 +226,15 @@ async def _collect(self, command: AntaCommand) -> None: exception and implement proper logging, the `output` attribute of the `AntaCommand` object passed as argument would be `None` in this case. - Args: - ---- - command: the command to collect - + Parameters + ---------- + command + The command to collect. + collection_id + An identifier used to build the eAPI request ID. """ - async def collect(self, command: AntaCommand) -> None: + async def collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: """Collect the output for a specified command. When caching is activated on both the device and the command, @@ -146,44 +245,49 @@ async def collect(self, command: AntaCommand) -> None: When caching is NOT enabled, either at the device or command level, the method directly collects the output via the private `_collect` method without interacting with the cache. - Args: - ---- - command (AntaCommand): The command to process. - + Parameters + ---------- + command + The command to collect. + collection_id + An identifier used to build the eAPI request ID. """ - # Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough - # https://github.com/pylint-dev/pylint/issues/7258 - if self.cache is not None and self.cache_locks is not None and command.use_cache: - async with self.cache_locks[command.uid]: - cached_output = await self.cache.get(command.uid) # pylint: disable=no-member + if self.cache is not None and command.use_cache: + async with self.cache.locks[command.uid]: + cached_output = await self.cache.get(command.uid) if cached_output is not None: logger.debug("Cache hit for %s on %s", command.command, self.name) command.output = cached_output else: - await self._collect(command=command) - await self.cache.set(command.uid, command.output) # pylint: disable=no-member + await self._collect(command=command, collection_id=collection_id) + await self.cache.set(command.uid, command.output) else: - await self._collect(command=command) + await self._collect(command=command, collection_id=collection_id) - async def collect_commands(self, commands: list[AntaCommand]) -> None: + async def collect_commands(self, commands: list[AntaCommand], *, collection_id: str | None = None) -> None: """Collect multiple commands. - Args: - ---- - commands: the commands to collect - + Parameters + ---------- + commands + The commands to collect. + collection_id + An identifier used to build the eAPI request ID. """ - await asyncio.gather(*(self.collect(command=command) for command in commands)) + await asyncio.gather(*(self.collect(command=command, collection_id=collection_id) for command in commands)) @abstractmethod async def refresh(self) -> None: """Update attributes of an AntaDevice instance. This coroutine must update the following attributes of AntaDevice: - - `is_online`: When the device IP is reachable and a port can be open - - `established`: When a command execution succeeds - - `hw_model`: The hardware model of the device + + - `is_online`: When the device IP is reachable and a port can be open. + + - `established`: When a command execution succeeds. + + - `hw_model`: The hardware model of the device. """ async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None: @@ -191,11 +295,14 @@ async def copy(self, sources: list[Path], destination: Path, direction: Literal[ It is not mandatory to implement this for a valid AntaDevice subclass. - Args: - ---- - sources: List of files to copy to or from the device. - destination: Local or remote destination when copying the files. Can be a folder. - direction: Defines if this coroutine copies files to or from the device. + Parameters + ---------- + sources + List of files to copy to or from the device. + destination + Local or remote destination when copying the files. Can be a folder. + direction + Defines if this coroutine copies files to or from the device. """ _ = (sources, destination, direction) @@ -204,20 +311,23 @@ async def copy(self, sources: list[Path], destination: Path, direction: Literal[ class AsyncEOSDevice(AntaDevice): - """Implementation of AntaDevice for EOS using aio-eapi. + """Implementation of AntaDevice for EOS using the `asynceapi` library, which is built on HTTPX. Attributes ---------- - name: Device name - is_online: True if the device IP is reachable and a port can be open - established: True if remote command execution succeeds - hw_model: Hardware model of the device - tags: Tags for this device - + name : str + Device name. + is_online : bool + True if the device IP is reachable and a port can be open. + established : bool + True if remote command execution succeeds. + hw_model : str + Hardware model of the device. + tags : set[str] + Tags for this device. """ - # pylint: disable=R0913 - def __init__( + def __init__( # noqa: PLR0913 self, host: str, username: str, @@ -225,7 +335,7 @@ def __init__( name: str | None = None, enable_password: str | None = None, port: int | None = None, - ssh_port: int | None = 22, + ssh_port: int = 22, tags: set[str] | None = None, timeout: float | None = None, proto: Literal["http", "https"] = "https", @@ -236,22 +346,34 @@ def __init__( ) -> None: """Instantiate an AsyncEOSDevice. - Args: - ---- - host: Device FQDN or IP. - username: Username to connect to eAPI and SSH. - password: Password to connect to eAPI and SSH. - name: Device name. - enable: Collect commands using privileged mode. - enable_password: Password used to gain privileged access on EOS. - port: eAPI port. Defaults to 80 is proto is 'http' or 443 if proto is 'https'. - ssh_port: SSH port. - tags: Tags for this device. - timeout: Timeout value in seconds for outgoing API calls. - insecure: Disable SSH Host Key validation. - proto: eAPI protocol. Value can be 'http' or 'https'. - disable_cache: Disable caching for all commands for this device. - + Parameters + ---------- + host + Device FQDN or IP. + username + Username to connect to eAPI and SSH. + password + Password to connect to eAPI and SSH. + name + Device name. + enable_password + Password used to gain privileged access on EOS. + port + eAPI port. Defaults to 80 is proto is 'http' or 443 if proto is 'https'. + ssh_port + SSH port. + tags + Tags for this device. + timeout + Global timeout value in seconds for outgoing eAPI calls. None means no timeout. + proto + eAPI protocol. Value can be 'http' or 'https'. + enable + Collect commands using privileged mode. + insecure + Disable SSH Host Key validation. + disable_cache + Disable caching for all commands for this device. """ if host is None: message = "'host' is required to create an AsyncEOSDevice" @@ -270,7 +392,7 @@ def __init__( raise ValueError(message) self.enable = enable self._enable_password = enable_password - self._session: aioeapi.Device = aioeapi.Device(host=host, port=port, username=username, password=password, proto=proto, timeout=timeout) + self._session: asynceapi.Device = asynceapi.Device(host=host, port=port, username=username, password=password, proto=proto, timeout=timeout) ssh_params: dict[str, Any] = {} if insecure: ssh_params["known_hosts"] = None @@ -278,6 +400,10 @@ def __init__( host=host, port=ssh_port, username=username, password=password, client_keys=CLIENT_KEYS, **ssh_params ) + # In Python 3.9, Semaphore must be created within a running event loop + # TODO: Once we drop Python 3.9 support, initialize the semaphore here + self._command_semaphore: asyncio.Semaphore | None = None + def __rich_repr__(self) -> Iterator[tuple[str, Any]]: """Implement Rich Repr Protocol. @@ -296,6 +422,23 @@ def __rich_repr__(self) -> Iterator[tuple[str, Any]]: _ssh_opts["kwargs"]["password"] = removed_pw yield ("_session", vars(self._session)) yield ("_ssh_opts", _ssh_opts) + yield ("max_connections", self.max_connections) if self.max_connections is not None else ("max_connections", "N/A") + + def __repr__(self) -> str: + """Return a printable representation of an AsyncEOSDevice.""" + return ( + f"AsyncEOSDevice({self.name!r}, " + f"tags={self.tags!r}, " + f"hw_model={self.hw_model!r}, " + f"is_online={self.is_online!r}, " + f"established={self.established!r}, " + f"disable_cache={self.cache is None!r}, " + f"host={self._session.host!r}, " + f"eapi_port={self._session.port!r}, " + f"username={self._ssh_opts.username!r}, " + f"enable={self.enable!r}, " + f"insecure={self._ssh_opts.known_hosts is None!r})" + ) @property def _keys(self) -> tuple[Any, ...]: @@ -305,108 +448,153 @@ def _keys(self) -> tuple[Any, ...]: """ return (self._session.host, self._session.port) - async def _collect(self, command: AntaCommand) -> None: # noqa: C901 function is too complex - because of many required except blocks + @property + def max_connections(self) -> int | None: + """Maximum number of concurrent connections allowed by the device. Returns None if not available.""" + try: + return self._session._transport._pool._max_connections # type: ignore[attr-defined] # noqa: SLF001 + except AttributeError: + return None + + async def _get_semaphore(self) -> asyncio.Semaphore: + """Return the semaphore, initializing it if needed. + + TODO: Remove this method once we drop Python 3.9 support. + """ + if self._command_semaphore is None: + self._command_semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS) + return self._command_semaphore + + async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: """Collect device command output from EOS using aio-eapi. Supports outformat `json` and `text` as output structure. Gain privileged access using the `enable_password` attribute of the `AntaDevice` instance if populated. - Args: - ---- - command: the AntaCommand to collect. + Parameters + ---------- + command + The command to collect. + collection_id + An identifier used to build the eAPI request ID. """ - commands: list[dict[str, Any]] = [] - if self.enable and self._enable_password is not None: - commands.append( - { - "cmd": "enable", - "input": str(self._enable_password), - }, - ) - elif self.enable: - # No password - commands.append({"cmd": "enable"}) - commands += [{"cmd": command.command, "revision": command.revision}] if command.revision else [{"cmd": command.command}] - try: - response: list[dict[str, Any]] = await self._session.cli( - commands=commands, - ofmt=command.ofmt, - version=command.version, - ) - # Do not keep response of 'enable' command - command.output = response[-1] - except aioeapi.EapiCommandError as e: - # This block catches exceptions related to EOS issuing an error. - command.errors = e.errors - if command.requires_privileges: + semaphore = await self._get_semaphore() + + async with semaphore: + commands: list[EapiComplexCommand | EapiSimpleCommand] = [] + if self.enable and self._enable_password is not None: + commands.append( + { + "cmd": "enable", + "input": str(self._enable_password), + }, + ) + elif self.enable: + # No password + commands.append({"cmd": "enable"}) + commands += [{"cmd": command.command, "revision": command.revision}] if command.revision else [{"cmd": command.command}] + try: + response = await self._session.cli( + commands=commands, + ofmt=command.ofmt, + version=command.version, + req_id=f"ANTA-{collection_id}-{id(command)}" if collection_id else f"ANTA-{id(command)}", + ) + # Do not keep response of 'enable' command + command.output = response[-1] + except asynceapi.EapiCommandError as e: + # This block catches exceptions related to EOS issuing an error. + self._log_eapi_command_error(command, e) + except TimeoutException as e: + # This block catches Timeout exceptions. + command.errors = [exc_to_str(e)] + timeouts = self._session.timeout.as_dict() logger.error( - "Command '%s' requires privileged mode on %s. Verify user permissions and if the `enable` option is required.", command.command, self.name + "%s occurred while sending a command to %s. Consider increasing the timeout.\nCurrent timeouts: Connect: %s | Read: %s | Write: %s | Pool: %s", + exc_to_str(e), + self.name, + timeouts["connect"], + timeouts["read"], + timeouts["write"], + timeouts["pool"], ) - if command.supported: - logger.error("Command '%s' failed on %s: %s", command.command, self.name, e.errors[0] if len(e.errors) == 1 else e.errors) - else: - logger.debug("Command '%s' is not supported on '%s' (%s)", command.command, self.name, self.hw_model) - except TimeoutException as e: - # This block catches Timeout exceptions. - command.errors = [exc_to_str(e)] - timeouts = self._session.timeout.as_dict() - logger.error( - "%s occurred while sending a command to %s. Consider increasing the timeout.\nCurrent timeouts: Connect: %s | Read: %s | Write: %s | Pool: %s", - exc_to_str(e), - self.name, - timeouts["connect"], - timeouts["read"], - timeouts["write"], - timeouts["pool"], - ) - except (ConnectError, OSError) as e: - # This block catches OSError and socket issues related exceptions. - command.errors = [exc_to_str(e)] - if (isinstance(exc := e.__cause__, httpcore.ConnectError) and isinstance(os_error := exc.__context__, OSError)) or isinstance(os_error := e, OSError): # pylint: disable=no-member - if isinstance(os_error.__cause__, OSError): - os_error = os_error.__cause__ - logger.error("A local OS error occurred while connecting to %s: %s.", self.name, os_error) - else: + except (ConnectError, OSError) as e: + # This block catches OSError and socket issues related exceptions. + command.errors = [exc_to_str(e)] + # pylint: disable=no-member + if (isinstance(exc := e.__cause__, httpcore.ConnectError) and isinstance(os_error := exc.__context__, OSError)) or isinstance( + os_error := e, OSError + ): + if isinstance(os_error.__cause__, OSError): + os_error = os_error.__cause__ + logger.error("A local OS error occurred while connecting to %s: %s.", self.name, os_error) + else: + anta_log_exception(e, f"An error occurred while issuing an eAPI request to {self.name}", logger) + except HTTPError as e: + # This block catches most of the httpx Exceptions and logs a general message. + command.errors = [exc_to_str(e)] anta_log_exception(e, f"An error occurred while issuing an eAPI request to {self.name}", logger) - except HTTPError as e: - # This block catches most of the httpx Exceptions and logs a general message. - command.errors = [exc_to_str(e)] - anta_log_exception(e, f"An error occurred while issuing an eAPI request to {self.name}", logger) - logger.debug("%s: %s", self.name, command) + logger.debug("%s: %s", self.name, command) + + def _log_eapi_command_error(self, command: AntaCommand, e: asynceapi.EapiCommandError) -> None: + """Appropriately log the eapi command error.""" + command.errors = e.errors + if command.requires_privileges: + logger.error("Command '%s' requires privileged mode on %s. Verify user permissions and if the `enable` option is required.", command.command, self.name) + if not command.supported: + logger.debug("Command '%s' is not supported on '%s' (%s)", command.command, self.name, self.hw_model) + elif command.returned_known_eos_error: + logger.debug("Command '%s' returned a known error '%s': %s", command.command, self.name, command.errors) + else: + logger.error("Command '%s' failed on %s: %s", command.command, self.name, e.errors[0] if len(e.errors) == 1 else e.errors) async def refresh(self) -> None: """Update attributes of an AsyncEOSDevice instance. This coroutine must update the following attributes of AsyncEOSDevice: - - is_online: When a device IP is reachable and a port can be open + - is_online: When a device eAPI HTTP endpoint is accessible - established: When a command execution succeeds - hw_model: The hardware model of the device """ logger.debug("Refreshing device %s", self.name) - self.is_online = await self._session.check_connection() - if self.is_online: - show_version = AntaCommand(command="show version") - await self._collect(show_version) - if not show_version.collected: - logger.warning("Cannot get hardware information from device %s", self.name) - else: - self.hw_model = show_version.json_output.get("modelName", None) - if self.hw_model is None: - logger.critical("Cannot parse 'show version' returned by device %s", self.name) + try: + self.is_online = await self._session.check_api_endpoint() + except HTTPError as e: + self.is_online = False + self.established = False + logger.warning("Could not connect to device %s: %s", self.name, e) + return + + show_version = AntaCommand(command="show version") + await self._collect(show_version) + if not show_version.collected: + self.established = False + logger.warning("Cannot get hardware information from device %s", self.name) + return + + self.hw_model = show_version.json_output.get("modelName", None) + if self.hw_model is None: + self.established = False + logger.critical("Cannot parse 'show version' returned by device %s", self.name) + # in some cases it is possible that 'modelName' comes back empty + elif self.hw_model == "": + self.established = False + logger.critical("Got an empty 'modelName' in the 'show version' returned by device %s", self.name) else: - logger.warning("Could not connect to device %s: cannot open eAPI port", self.name) - - self.established = bool(self.is_online and self.hw_model) + self.established = True async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None: """Copy files to and from the device using asyncssh.scp(). - Args: - ---- - sources: List of files to copy to or from the device. - destination: Local or remote destination when copying the files. Can be a folder. - direction: Defines if this coroutine copies files to or from the device. + Parameters + ---------- + sources + List of files to copy to or from the device. + destination + Local or remote destination when copying the files. Can be a folder. + direction + Defines if this coroutine copies files to or from the device. """ async with asyncssh.connect( diff --git a/anta/input_models/__init__.py b/anta/input_models/__init__.py new file mode 100644 index 000000000..5dbf8272a --- /dev/null +++ b/anta/input_models/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Package related to all ANTA tests input models.""" diff --git a/anta/input_models/avt.py b/anta/input_models/avt.py new file mode 100644 index 000000000..44fd780fc --- /dev/null +++ b/anta/input_models/avt.py @@ -0,0 +1,36 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for AVT tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address + +from pydantic import BaseModel, ConfigDict + + +class AVTPath(BaseModel): + """AVT (Adaptive Virtual Topology) model representing path details and associated information.""" + + model_config = ConfigDict(extra="forbid") + vrf: str = "default" + """VRF context. Defaults to `default`.""" + avt_name: str + """The name of the Adaptive Virtual Topology (AVT).""" + destination: IPv4Address + """The IPv4 address of the destination peer in the AVT.""" + next_hop: IPv4Address + """The IPv4 address of the next hop used to reach the AVT peer.""" + path_type: str | None = None + """Specifies the type of path for the AVT. If not specified, both types 'direct' and 'multihop' are considered.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the AVTPath for reporting. + + Examples + -------- + AVT CONTROL-PLANE-PROFILE VRF: default (Destination: 10.101.255.2, Next-hop: 10.101.255.1) + + """ + return f"AVT: {self.avt_name} VRF: {self.vrf} Destination: {self.destination} Next-hop: {self.next_hop}" diff --git a/anta/input_models/bfd.py b/anta/input_models/bfd.py new file mode 100644 index 000000000..221dc94a3 --- /dev/null +++ b/anta/input_models/bfd.py @@ -0,0 +1,43 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for BFD tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address, IPv6Address + +from pydantic import BaseModel, ConfigDict + +from anta.custom_types import BfdInterval, BfdMultiplier, BfdProtocol, Interface + + +class BFDPeer(BaseModel): + """Model for a BFD peer.""" + + model_config = ConfigDict(extra="forbid") + peer_address: IPv4Address | IPv6Address + """IPv4 or IPv6 address of the BFD peer.""" + vrf: str = "default" + """VRF of the BFD peer.""" + interface: Interface | None = None + """Single-hop transport interface. Use `None` for multi-hop sessions.""" + protocols: list[BfdProtocol] | None = None + """List of protocols using BFD with this peer. Required field in the `VerifyBFDPeersRegProtocols` test.""" + tx_interval: BfdInterval | None = None + """Operational transmit interval of the BFD session in milliseconds. Required field in the `VerifyBFDPeersIntervals` test.""" + rx_interval: BfdInterval | None = None + """Operational minimum receive interval of the BFD session in milliseconds. Required field in the `VerifyBFDPeersIntervals` test.""" + multiplier: BfdMultiplier | None = None + """Multiplier of the BFD session. Required field in the `VerifyBFDPeersIntervals` test.""" + detection_time: int | None = None + """Detection time of the BFD session in milliseconds. Defines how long it takes for BFD to detect connection failure. + + Optional field in the `VerifyBFDPeersIntervals` test.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the BFDPeer for reporting.""" + base = f"Peer: {self.peer_address} VRF: {self.vrf}" + if self.interface is not None: + base += f" Interface: {self.interface}" + return base diff --git a/anta/input_models/connectivity.py b/anta/input_models/connectivity.py new file mode 100644 index 000000000..2d4f0e9f4 --- /dev/null +++ b/anta/input_models/connectivity.py @@ -0,0 +1,89 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for connectivity tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address, IPv6Address +from typing import Any +from warnings import warn + +from pydantic import BaseModel, ConfigDict + +from anta.custom_types import Interface + + +class Host(BaseModel): + """Model for a remote host to ping.""" + + model_config = ConfigDict(extra="forbid") + destination: IPv4Address | IPv6Address + """Destination address to ping.""" + source: IPv4Address | IPv6Address | Interface | None = None + """Source address IP or egress interface to use. Can be provided in the `VerifyReachability` test.""" + vrf: str = "default" + """VRF context.""" + repeat: int = 2 + """Number of ping repetition.""" + size: int = 100 + """Specify datagram size.""" + df_bit: bool = False + """Enable do not fragment bit in IP header.""" + reachable: bool = True + """Indicates whether the destination should be reachable.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the Host for reporting. + + Examples + -------- + - Host: 10.1.1.1 Source: 10.2.2.2 VRF: mgmt + - Host: 10.1.1.1 VRF: mgmt + + """ + base_string = f"Host: {self.destination}" + if self.source: + base_string += f" Source: {self.source}" + base_string += f" VRF: {self.vrf}" + return base_string + + +class LLDPNeighbor(BaseModel): + """LLDP (Link Layer Discovery Protocol) model representing the port details and neighbor information.""" + + model_config = ConfigDict(extra="forbid") + port: Interface + """The LLDP port for the local device.""" + neighbor_device: str + """The system name of the LLDP neighbor device.""" + neighbor_port: Interface + """The LLDP port on the neighboring device.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the LLDPNeighbor for reporting. + + Examples + -------- + Port: Ethernet1 Neighbor: DC1-SPINE2 Neighbor Port: Ethernet2 + + """ + return f"Port: {self.port} Neighbor: {self.neighbor_device} Neighbor Port: {self.neighbor_port}" + + +class Neighbor(LLDPNeighbor): # pragma: no cover + """Alias for the LLDPNeighbor model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the LLDPNeighbor model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the LLDPNeighbor class, emitting a depreciation warning.""" + warn( + message="Neighbor model is deprecated and will be removed in ANTA v2.0.0. Use the LLDPNeighbor model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) diff --git a/anta/input_models/cvx.py b/anta/input_models/cvx.py new file mode 100644 index 000000000..0e21c8914 --- /dev/null +++ b/anta/input_models/cvx.py @@ -0,0 +1,21 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for CVX tests.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + +from anta.custom_types import Hostname + + +class CVXPeers(BaseModel): + """Model for a CVX Cluster Peer.""" + + peer_name: Hostname + """The CVX Peer used communicate with a CVX server.""" + registration_state: Literal["Connecting", "Connected", "Registration error", "Registration complete", "Unexpected peer state"] = "Registration complete" + """The CVX registration state.""" diff --git a/anta/input_models/evpn.py b/anta/input_models/evpn.py new file mode 100644 index 000000000..9030cd84f --- /dev/null +++ b/anta/input_models/evpn.py @@ -0,0 +1,65 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for EVPN tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Interface, IPv6Interface +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +from anta.custom_types import Vni + + +class EVPNType5Prefix(BaseModel): + """Model for an EVPN Type-5 prefix.""" + + model_config = ConfigDict(extra="forbid") + address: IPv4Interface | IPv6Interface + """IPv4 or IPv6 prefix address to verify.""" + vni: Vni + """VNI associated with the prefix.""" + routes: list[EVPNRoute] | None = None + """Specific EVPN routes to verify for this prefix.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the EVPNType5Prefix for reporting.""" + return f"Prefix: {self.address} VNI: {self.vni}" + + +class EVPNRoute(BaseModel): + """Model for an EVPN Type-5 route for a prefix.""" + + model_config = ConfigDict(extra="forbid") + rd: str + """Expected route distinguisher `:` of the route.""" + domain: Literal["local", "remote"] = "local" + """EVPN domain. Can be remote on gateway nodes in a multi-domain EVPN VXLAN fabric.""" + paths: list[EVPNPath] | None = None + """Specific paths to verify for this route.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the EVPNRoute for reporting.""" + value = f"RD: {self.rd}" + if self.domain == "remote": + value += " Domain: remote" + return value + + +class EVPNPath(BaseModel): + """Model for an EVPN Type-5 path for a route.""" + + model_config = ConfigDict(extra="forbid") + nexthop: str + """Expected next-hop IPv4 or IPv6 address. Can be an empty string for local paths.""" + route_targets: list[str] | None = None + """List of expected RTs following the `ASN(asplain):nn` or `ASN(asdot):nn` or `IP-address:nn` format.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the EVPNPath for reporting.""" + value = f"Nexthop: {self.nexthop}" + if self.route_targets: + value += f" RTs: {', '.join(self.route_targets)}" + return value diff --git a/anta/input_models/flow_tracking.py b/anta/input_models/flow_tracking.py new file mode 100644 index 000000000..5f4c25bde --- /dev/null +++ b/anta/input_models/flow_tracking.py @@ -0,0 +1,72 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for flow tracking tests.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class FlowTracker(BaseModel): + """Flow Tracking model representing the tracker details.""" + + model_config = ConfigDict(extra="forbid") + name: str + """The name of the flow tracker.""" + record_export: RecordExport | None = None + """Configuration for record export, specifying details about timeouts.""" + exporters: list[Exporter] | None = None + """A list of exporters associated with the flow tracker.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the FlowTracker for reporting. + + Examples + -------- + Flow Tracker: FLOW-TRACKER + + """ + return f"Flow Tracker: {self.name}" + + +class RecordExport(BaseModel): + """Model representing the record export configuration for a flow tracker.""" + + model_config = ConfigDict(extra="forbid") + on_inactive_timeout: int + """The timeout in milliseconds for exporting flow records when the flow becomes inactive.""" + on_interval: int + """The interval in milliseconds for exporting flow records.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the RecordExport for reporting. + + Examples + -------- + Inactive Timeout: 60000, Active Interval: 300000 + + """ + return f"Inactive Timeout: {self.on_inactive_timeout} Active Interval: {self.on_interval}" + + +class Exporter(BaseModel): + """Model representing the exporter used for flow record export.""" + + model_config = ConfigDict(extra="forbid") + name: str + """The name of the exporter.""" + local_interface: str + """The local interface used by the exporter to send flow records.""" + template_interval: int + """The template interval, in milliseconds, for the exporter to refresh the flow template.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the Exporter for reporting. + + Examples + -------- + Exporter: CVP-TELEMETRY + + """ + return f"Exporter: {self.name}" diff --git a/anta/input_models/interfaces.py b/anta/input_models/interfaces.py new file mode 100644 index 000000000..c9ecedb70 --- /dev/null +++ b/anta/input_models/interfaces.py @@ -0,0 +1,81 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for interface tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Interface +from typing import Any, Literal +from warnings import warn + +from pydantic import BaseModel, ConfigDict, Field + +from anta.custom_types import Interface, PortChannelInterface + + +class InterfaceState(BaseModel): + """Model for an interface state. + + TODO: Need to review this class name in ANTA v2.0.0. + """ + + model_config = ConfigDict(extra="forbid") + name: Interface + """Interface to validate.""" + status: Literal["up", "down", "adminDown"] | None = None + """Expected status of the interface. Required field in the `VerifyInterfacesStatus` test.""" + line_protocol_status: Literal["up", "down", "testing", "unknown", "dormant", "notPresent", "lowerLayerDown"] | None = None + """Expected line protocol status of the interface. Optional field in the `VerifyInterfacesStatus` test.""" + portchannel: PortChannelInterface | None = None + """Port-Channel in which the interface is bundled. Required field in the `VerifyLACPInterfacesStatus` test.""" + lacp_rate_fast: bool = False + """Specifies the LACP timeout mode for the link aggregation group. + + Options: + - True: Also referred to as fast mode. + - False: The default mode, also known as slow mode. + + Can be enabled in the `VerifyLACPInterfacesStatus` tests. + """ + primary_ip: IPv4Interface | None = None + """Primary IPv4 address in CIDR notation. Required field in the `VerifyInterfaceIPv4` test.""" + secondary_ips: list[IPv4Interface] | None = None + """List of secondary IPv4 addresses in CIDR notation. Can be provided in the `VerifyInterfaceIPv4` test.""" + auto: bool = False + """The auto-negotiation status of the interface. Can be provided in the `VerifyInterfacesSpeed` test.""" + speed: float | None = Field(None, ge=1, le=1000) + """The speed of the interface in Gigabits per second. Valid range is 1 to 1000. Required field in the `VerifyInterfacesSpeed` test.""" + lanes: int | None = Field(None, ge=1, le=8) + """The number of lanes in the interface. Valid range is 1 to 8. Can be provided in the `VerifyInterfacesSpeed` test.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the InterfaceState for reporting. + + Examples + -------- + - Interface: Ethernet1 Port-Channel: Port-Channel100 + - Interface: Ethernet1 + """ + base_string = f"Interface: {self.name}" + if self.portchannel is not None: + base_string += f" Port-Channel: {self.portchannel}" + return base_string + + +class InterfaceDetail(InterfaceState): # pragma: no cover + """Alias for the InterfaceState model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the InterfaceState model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the InterfaceState class, emitting a depreciation warning.""" + warn( + message="InterfaceDetail model is deprecated and will be removed in ANTA v2.0.0. Use the InterfaceState model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) diff --git a/anta/input_models/logging.py b/anta/input_models/logging.py new file mode 100644 index 000000000..977f1ab0b --- /dev/null +++ b/anta/input_models/logging.py @@ -0,0 +1,21 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for logging tests.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from anta.custom_types import LogSeverityLevel, RegexString + + +class LoggingQuery(BaseModel): + """Logging query model representing the logging details.""" + + regex_match: RegexString + """Log regex pattern to be searched in last log entries.""" + last_number_messages: int = Field(ge=1, le=9999) + """Last number of messages to check in the logging buffers.""" + severity_level: LogSeverityLevel = "informational" + """Log severity level.""" diff --git a/anta/input_models/path_selection.py b/anta/input_models/path_selection.py new file mode 100644 index 000000000..cf06c9002 --- /dev/null +++ b/anta/input_models/path_selection.py @@ -0,0 +1,28 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for path-selection tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address + +from pydantic import BaseModel, ConfigDict + + +class DpsPath(BaseModel): + """Model for a list of DPS path entries.""" + + model_config = ConfigDict(extra="forbid") + peer: IPv4Address + """Static peer IPv4 address.""" + path_group: str + """Router path group name.""" + source_address: IPv4Address + """Source IPv4 address of path.""" + destination_address: IPv4Address + """Destination IPv4 address of path.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the DpsPath for reporting.""" + return f"Peer: {self.peer} PathGroup: {self.path_group} Source: {self.source_address} Destination: {self.destination_address}" diff --git a/anta/input_models/routing/__init__.py b/anta/input_models/routing/__init__.py new file mode 100644 index 000000000..772b4f979 --- /dev/null +++ b/anta/input_models/routing/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Package related to routing tests input models.""" diff --git a/anta/input_models/routing/bgp.py b/anta/input_models/routing/bgp.py new file mode 100644 index 000000000..8c2a4ff9d --- /dev/null +++ b/anta/input_models/routing/bgp.py @@ -0,0 +1,409 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for routing BGP tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network +from typing import TYPE_CHECKING, Any, Literal +from warnings import warn + +from pydantic import BaseModel, ConfigDict, Field, PositiveInt, model_validator +from pydantic_extra_types.mac_address import MacAddress + +from anta.custom_types import Afi, BgpCommunity, BgpDropStats, BgpUpdateError, Interface, MultiProtocolCaps, RedistributedAfiSafi, RedistributedProtocol, Safi, Vni + +if TYPE_CHECKING: + import sys + + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + +AFI_SAFI_EOS_KEY = { + ("ipv4", "unicast"): "ipv4Unicast", + ("ipv4", "multicast"): "ipv4Multicast", + ("ipv4", "labeled-unicast"): "ipv4MplsLabels", + ("ipv4", "sr-te"): "ipv4SrTe", + ("ipv6", "unicast"): "ipv6Unicast", + ("ipv6", "multicast"): "ipv6Multicast", + ("ipv6", "labeled-unicast"): "ipv6MplsLabels", + ("ipv6", "sr-te"): "ipv6SrTe", + ("vpn-ipv4", None): "ipv4MplsVpn", + ("vpn-ipv6", None): "ipv6MplsVpn", + ("evpn", None): "l2VpnEvpn", + ("rt-membership", None): "rtMembership", + ("path-selection", None): "dps", + ("link-state", None): "linkState", +} +"""Dictionary mapping AFI/SAFI to EOS key representation.""" +AFI_SAFI_MAPPINGS = {"v4u": "IPv4 Unicast", "v4m": "IPv4 Multicast", "v6u": "IPv6 Unicast", "v6m": "IPv6 Multicast"} +"""Dictionary mapping AFI/SAFI to EOS key representation for BGP redistributed route protocol.""" +IPV4_MULTICAST_SUPPORTED_PROTO = [ + "AttachedHost", + "Connected", + "IS-IS", + "OSPF Internal", + "OSPF External", + "OSPF Nssa-External", + "OSPFv3 Internal", + "OSPFv3 External", + "OSPFv3 Nssa-External", + "Static", +] +"""List of BGP redistributed route protocol, supported for IPv4 multicast address family.""" +IPV6_MULTICAST_SUPPORTED_PROTO = [proto for proto in IPV4_MULTICAST_SUPPORTED_PROTO if proto != "AttachedHost"] +"""List of BGP redistributed route protocol, supported for IPv6 multicast address family.""" + + +class BgpAddressFamily(BaseModel): + """Model for a BGP address family.""" + + model_config = ConfigDict(extra="forbid") + afi: Afi + """BGP Address Family Identifier (AFI).""" + safi: Safi | None = None + """BGP Subsequent Address Family Identifier (SAFI). Required when `afi` is `ipv4` or `ipv6`.""" + vrf: str = "default" + """Optional VRF when `afi` is `ipv4` or `ipv6`. Defaults to `default`. + + If the input `afi` is NOT `ipv4` or `ipv6` (e.g. `evpn`, `vpn-ipv4`, etc.), the `vrf` must be `default`. + + These AFIs operate at a global level and do not use the VRF concept in the same way as IPv4/IPv6. + """ + num_peers: PositiveInt | None = None + """Number of expected established BGP peers with negotiated AFI/SAFI. Required field in the `VerifyBGPPeerCount` test.""" + peers: list[IPv4Address | IPv6Address] | None = None + """List of expected IPv4/IPv6 BGP peers supporting the AFI/SAFI. Required field in the `VerifyBGPSpecificPeers` test.""" + check_tcp_queues: bool = True + """Flag to check if the TCP session queues are empty for a BGP peer. Defaults to `True`. + + Can be disabled in the `VerifyBGPPeersHealth` and `VerifyBGPSpecificPeers` tests. + """ + check_peer_state: bool = False + """Flag to check if the peers are established with negotiated AFI/SAFI. Defaults to `False`. + + Can be enabled in the `VerifyBGPPeerCount` tests.""" + + @model_validator(mode="after") + def validate_inputs(self) -> Self: + """Validate the inputs provided to the BgpAddressFamily class. + + If `afi` is either `ipv4` or `ipv6`, `safi` must be provided. + + If `afi` is not `ipv4` or `ipv6`, `safi` must NOT be provided and `vrf` must be `default`. + """ + if self.afi in ["ipv4", "ipv6"]: + if self.safi is None: + msg = "'safi' must be provided when afi is ipv4 or ipv6" + raise ValueError(msg) + elif self.safi is not None: + msg = "'safi' must not be provided when afi is not ipv4 or ipv6" + raise ValueError(msg) + elif self.vrf != "default": + msg = "'vrf' must be default when afi is not ipv4 or ipv6" + raise ValueError(msg) + return self + + @property + def eos_key(self) -> str: + """AFI/SAFI EOS key representation.""" + # Pydantic handles the validation of the AFI/SAFI combination, so we can ignore error handling here. + return AFI_SAFI_EOS_KEY[(self.afi, self.safi)] + + def __str__(self) -> str: + """Return a human-readable string representation of the BgpAddressFamily for reporting. + + Examples + -------- + - AFI:ipv4 SAFI:unicast VRF:default + - AFI:evpn + """ + base_string = f"AFI: {self.afi}" + if self.safi is not None: + base_string += f" SAFI: {self.safi}" + if self.afi in ["ipv4", "ipv6"]: + base_string += f" VRF: {self.vrf}" + return base_string + + +class BgpAfi(BgpAddressFamily): # pragma: no cover + """Alias for the BgpAddressFamily model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the BgpAddressFamily model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the BgpAfi class, emitting a deprecation warning.""" + warn( + message="BgpAfi model is deprecated and will be removed in ANTA v2.0.0. Use the BgpAddressFamily model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) + + +class BgpPeer(BaseModel): + """Model for a BGP peer. + + Supports IPv4, IPv6 and IPv6 link-local neighbors. + + Also supports RFC5549 by providing the interface to be used for session establishment. + """ + + model_config = ConfigDict(extra="forbid") + peer_address: IPv4Address | IPv6Address | None = None + """IP address of the BGP peer. Optional only if using `interface` for BGP RFC5549.""" + interface: Interface | None = None + """Interface to be used for BGP RFC5549 session establishment.""" + vrf: str = "default" + """VRF for the BGP peer.""" + peer_group: str | None = None + """Peer group of the BGP peer. Required field in the `VerifyBGPPeerGroup` test.""" + advertised_routes: list[IPv4Network | IPv6Network] | None = None + """List of advertised routes in CIDR format. Required field in the `VerifyBGPExchangedRoutes` test.""" + received_routes: list[IPv4Network | IPv6Network] | None = None + """List of received routes in CIDR format. Required field in the `VerifyBGPExchangedRoutes` test.""" + capabilities: list[MultiProtocolCaps] | None = None + """List of BGP multiprotocol capabilities. Required field in the `VerifyBGPPeerMPCaps`, `VerifyBGPNlriAcceptance` tests.""" + strict: bool = False + """If True, requires exact match of the provided BGP multiprotocol capabilities. + + Optional field in the `VerifyBGPPeerMPCaps` test.""" + hold_time: int | None = Field(default=None, ge=3, le=7200) + """BGP hold time in seconds. Required field in the `VerifyBGPTimers` test.""" + keep_alive_time: int | None = Field(default=None, ge=0, le=3600) + """BGP keepalive time in seconds. Required field in the `VerifyBGPTimers` test.""" + drop_stats: list[BgpDropStats] | None = None + """List of drop statistics to be verified. + + Optional field in the `VerifyBGPPeerDropStats` test. If not provided, the test will verifies all drop statistics.""" + update_errors: list[BgpUpdateError] | None = None + """List of update error counters to be verified. + + Optional field in the `VerifyBGPPeerUpdateErrors` test. If not provided, the test will verifies all the update error counters.""" + inbound_route_map: str | None = None + """Inbound route map applied to the peer. Optional field in the `VerifyBgpRouteMaps` test. If not provided, `outbound_route_map` must be provided.""" + outbound_route_map: str | None = None + """Outbound route map applied to the peer. Optional field in the `VerifyBgpRouteMaps` test. If not provided, `inbound_route_map` must be provided.""" + maximum_routes: int | None = Field(default=None, ge=0, le=4294967294) + """The maximum allowable number of BGP routes. `0` means unlimited. Required field in the `VerifyBGPPeerRouteLimit` test.""" + warning_limit: int | None = Field(default=None, ge=0, le=4294967294) + """The warning limit for the maximum routes. `0` means no warning. + + Optional field in the `VerifyBGPPeerRouteLimit` test. If not provided, the test will not verify the warning limit.""" + ttl: int | None = Field(default=None, ge=1, le=255) + """The Time-To-Live (TTL). Required field in the `VerifyBGPPeerTtlMultiHops` test.""" + max_ttl_hops: int | None = Field(default=None, ge=1, le=255) + """The Max TTL hops. Required field in the `VerifyBGPPeerTtlMultiHops` test.""" + advertised_communities: list[BgpCommunity] = Field(default=["standard", "extended", "large"]) + """List of advertised communities to be verified. + + Optional field in the `VerifyBGPAdvCommunities` test. If not provided, the test will verify that all communities are advertised.""" + + @model_validator(mode="after") + def validate_inputs(self) -> Self: + """Validate the inputs provided to the BgpPeer class. + + Either `peer_address` or `interface` must be provided, not both. + """ + if (self.peer_address is None) == (self.interface is None): + msg = "Exactly one of 'peer_address' or 'interface' must be provided" + raise ValueError(msg) + return self + + def __str__(self) -> str: + """Return a human-readable string representation of the BgpPeer for reporting.""" + identifier = f"Peer: {self.peer_address}" if self.peer_address is not None else f"Interface: {self.interface}" + return f"{identifier} VRF: {self.vrf}" + + +class BgpNeighbor(BgpPeer): # pragma: no cover + """Alias for the BgpPeer model to maintain backward compatibility. + + When initialised, it will emit a deprecation warning and call the BgpPeer model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the BgpPeer class, emitting a depreciation warning.""" + warn( + message="BgpNeighbor model is deprecated and will be removed in ANTA v2.0.0. Use the BgpPeer model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) + + +class VxlanEndpoint(BaseModel): + """Model for a VXLAN endpoint.""" + + address: IPv4Address | MacAddress + """IPv4 or MAC address of the VXLAN endpoint.""" + vni: Vni + """VNI of the VXLAN endpoint.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the VxlanEndpoint for reporting.""" + return f"Address: {self.address} VNI: {self.vni}" + + +class BgpRoute(BaseModel): + """Model representing BGP routes. + + Only IPv4 prefixes are supported for now. + """ + + model_config = ConfigDict(extra="forbid") + prefix: IPv4Network + """The IPv4 network address.""" + vrf: str = "default" + """Optional VRF for the BGP peer. Defaults to `default`.""" + paths: list[BgpRoutePath] | None = None + """A list of paths for the BGP route. Required field in the `VerifyBGPRoutePaths` test.""" + ecmp_count: int | None = None + """The expected number of ECMP paths for the BGP route. Required field in the `VerifyBGPRouteECMP` test.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the BgpRoute for reporting. + + Examples + -------- + - Prefix: 192.168.66.100/24 VRF: default + """ + return f"Prefix: {self.prefix} VRF: {self.vrf}" + + +class BgpRoutePath(BaseModel): + """Model representing a BGP route path.""" + + model_config = ConfigDict(extra="forbid") + nexthop: IPv4Address + """The next-hop IPv4 address for the path.""" + origin: Literal["Igp", "Egp", "Incomplete"] + """The BGP origin attribute of the route.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the RoutePath for reporting. + + Examples + -------- + - Next-hop: 192.168.66.101 Origin: Igp + """ + return f"Next-hop: {self.nexthop} Origin: {self.origin}" + + +class BgpVrf(BaseModel): + """Model representing a VRF in a BGP instance.""" + + vrf: str = "default" + """VRF context.""" + address_families: list[AddressFamilyConfig] + """List of address family configuration.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the BgpVrf for reporting. + + Examples + -------- + - VRF: default + """ + return f"VRF: {self.vrf}" + + +class RedistributedRouteConfig(BaseModel): + """Model representing a BGP redistributed route configuration.""" + + proto: RedistributedProtocol + """The redistributed protocol.""" + include_leaked: bool = False + """Flag to include leaked routes of the redistributed protocol while redistributing.""" + route_map: str | None = None + """Optional route map applied to the redistribution.""" + + @model_validator(mode="after") + def validate_inputs(self) -> Self: + """Validate that 'include_leaked' is not set when the redistributed protocol is AttachedHost, User, Dynamic, or RIP.""" + if self.include_leaked and self.proto in ["AttachedHost", "EOS SDK", "Dynamic", "RIP"]: + msg = f"'include_leaked' field is not supported for redistributed protocol '{self.proto}'" + raise ValueError(msg) + return self + + def __str__(self) -> str: + """Return a human-readable string representation of the RedistributedRouteConfig for reporting. + + Examples + -------- + - Proto: Connected, Include Leaked: True, Route Map: RM-CONN-2-BGP + """ + base_string = f"Proto: {self.proto}" + if self.include_leaked: + base_string += f", Include Leaked: {self.include_leaked}" + if self.route_map: + base_string += f", Route Map: {self.route_map}" + return base_string + + +class AddressFamilyConfig(BaseModel): + """Model representing a BGP address family configuration.""" + + afi_safi: RedistributedAfiSafi + """AFI/SAFI abbreviation per EOS.""" + redistributed_routes: list[RedistributedRouteConfig] + """List of redistributed route configuration.""" + + @model_validator(mode="after") + def validate_afi_safi_supported_routes(self) -> Self: + """Validate each address family supported redistributed protocol. + + Following table shows the supported redistributed routes for each address family. + + | IPv4 Unicast | IPv6 Unicast | IPv4 Multicast | IPv6 Multicast | + |-------------------------|-------------------------|------------------------|------------------------| + | AttachedHost | AttachedHost | AttachedHost | Connected | + | Bgp | Bgp | Connected | IS-IS | + | Connected | Connected | IS-IS | OSPF Internal | + | Dynamic | DHCP | OSPF Internal | OSPF External | + | IS-IS | Dynamic | OSPF External | OSPF Nssa-External | + | OSPF Internal | IS-IS | OSPF Nssa-External | OSPFv3 Internal | + | OSPF External | OSPFv3 Internal | OSPFv3 Internal | OSPFv3 External | + | OSPF Nssa-External | OSPFv3 External | OSPFv3 External | OSPFv3 Nssa-External | + | OSPFv3 Internal | OSPFv3 Nssa-External | OSPFv3 Nssa-External | Static | + | OSPFv3 External | Static | Static | | + | OSPFv3 Nssa-External | User | | | + | RIP | | | | + | Static | | | | + | User | | | | + """ + for routes_data in self.redistributed_routes: + if all([self.afi_safi == "v4u", routes_data.proto == "DHCP"]): + msg = f"Redistributed protocol 'DHCP' is not supported for address-family '{AFI_SAFI_MAPPINGS[self.afi_safi]}'" + raise ValueError(msg) + + if self.afi_safi == "v6u" and routes_data.proto in ["OSPF Internal", "OSPF External", "OSPF Nssa-External", "RIP"]: + msg = f"Redistributed protocol '{routes_data.proto}' is not supported for address-family '{AFI_SAFI_MAPPINGS[self.afi_safi]}'" + raise ValueError(msg) + + if self.afi_safi == "v4m" and routes_data.proto not in IPV4_MULTICAST_SUPPORTED_PROTO: + msg = f"Redistributed protocol '{routes_data.proto}' is not supported for address-family '{AFI_SAFI_MAPPINGS[self.afi_safi]}'" + raise ValueError(msg) + + if self.afi_safi == "v6m" and routes_data.proto not in IPV6_MULTICAST_SUPPORTED_PROTO: + msg = f"Redistributed protocol '{routes_data.proto}' is not supported for address-family '{AFI_SAFI_MAPPINGS[self.afi_safi]}'" + raise ValueError(msg) + + return self + + def __str__(self) -> str: + """Return a human-readable string representation of the AddressFamilyConfig for reporting. + + Examples + -------- + - AFI-SAFI: IPv4 Unicast + """ + return f"AFI-SAFI: {AFI_SAFI_MAPPINGS[self.afi_safi]}" diff --git a/anta/input_models/routing/generic.py b/anta/input_models/routing/generic.py new file mode 100644 index 000000000..72609fc46 --- /dev/null +++ b/anta/input_models/routing/generic.py @@ -0,0 +1,34 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for generic routing tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address, IPv4Network + +from pydantic import BaseModel, ConfigDict + +from anta.custom_types import IPv4RouteType + + +class IPv4Routes(BaseModel): + """Model for a list of IPV4 route entries.""" + + model_config = ConfigDict(extra="forbid") + prefix: IPv4Network + """IPv4 prefix in CIDR notation.""" + vrf: str = "default" + """VRF context. Defaults to `default` VRF.""" + route_type: IPv4RouteType | None = None + """Expected route type. Required field in the `VerifyIPv4RouteType` test.""" + nexthops: list[IPv4Address] | None = None + """A list of the next-hop IP addresses for the route. Required field in the `VerifyIPv4RouteNextHops` test.""" + strict: bool = False + """If True, requires exact matching of provided nexthop(s). + + Can be enabled in `VerifyIPv4RouteNextHops` test.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the IPv4Routes for reporting.""" + return f"Prefix: {self.prefix} VRF: {self.vrf}" diff --git a/anta/input_models/routing/isis.py b/anta/input_models/routing/isis.py new file mode 100644 index 000000000..9465487f8 --- /dev/null +++ b/anta/input_models/routing/isis.py @@ -0,0 +1,206 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for routing IS-IS tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address, IPv4Network +from typing import Any, Literal +from warnings import warn + +from pydantic import BaseModel, ConfigDict + +from anta.custom_types import Interface + + +class ISISInstance(BaseModel): + """Model for an IS-IS instance.""" + + model_config = ConfigDict(extra="forbid") + name: str + """The name of the IS-IS instance.""" + vrf: str = "default" + """VRF context of the IS-IS instance.""" + dataplane: Literal["MPLS", "mpls", "unset"] = "MPLS" + """Configured SR data-plane for the IS-IS instance.""" + segments: list[Segment] | None = None + """List of IS-IS SR segments associated with the instance. Required field in the `VerifyISISSegmentRoutingAdjacencySegments` test.""" + graceful_restart: bool = False + """Graceful restart status.""" + graceful_restart_helper: bool = True + """Graceful restart helper status.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the ISISInstance for reporting.""" + return f"Instance: {self.name} VRF: {self.vrf}" + + +class Segment(BaseModel): + """Model for an IS-IS segment.""" + + model_config = ConfigDict(extra="forbid") + interface: Interface + """Local interface name.""" + level: Literal[1, 2] = 2 + """IS-IS level of the segment.""" + sid_origin: Literal["dynamic", "configured"] = "dynamic" + """Origin of the segment ID.""" + address: IPv4Address + """Adjacency IPv4 address of the segment.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the Segment for reporting.""" + return f"Local Intf: {self.interface} Adj IP Address: {self.address}" + + +class ISISInterface(BaseModel): + """Model for an IS-IS enabled interface.""" + + model_config = ConfigDict(extra="forbid") + name: Interface + """Interface name.""" + vrf: str = "default" + """VRF context of the interface.""" + level: Literal[1, 2] = 2 + """IS-IS level of the interface.""" + count: int | None = None + """Expected number of IS-IS neighbors on this interface. Required field in the `VerifyISISNeighborCount` test.""" + mode: Literal["point-to-point", "broadcast", "passive"] | None = None + """IS-IS network type of the interface. Required field in the `VerifyISISInterfaceMode` test.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the ISISInterface for reporting.""" + return f"Interface: {self.name} VRF: {self.vrf} Level: {self.level}" + + +class InterfaceCount(ISISInterface): # pragma: no cover + """Alias for the ISISInterface model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the ISISInterface model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the InterfaceCount class, emitting a deprecation warning.""" + warn( + message="InterfaceCount model is deprecated and will be removed in ANTA v2.0.0. Use the ISISInterface model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) + + +class InterfaceState(ISISInterface): # pragma: no cover + """Alias for the ISISInterface model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the ISISInterface model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the InterfaceState class, emitting a deprecation warning.""" + warn( + message="InterfaceState model is deprecated and will be removed in ANTA v2.0.0. Use the ISISInterface model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) + + +class IsisInstance(ISISInstance): # pragma: no cover + """Alias for the ISISInstance model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the ISISInstance model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the IsisInstance class, emitting a deprecation warning.""" + warn( + message="IsisInstance model is deprecated and will be removed in ANTA v2.0.0. Use the ISISInstance model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) + + +class Tunnel(BaseModel): + """Model for a IS-IS SR tunnel.""" + + model_config = ConfigDict(extra="forbid") + endpoint: IPv4Network + """Endpoint of the tunnel.""" + vias: list[TunnelPath] | None = None + """Optional list of paths to reach the endpoint.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the Tunnel for reporting.""" + return f"Endpoint: {self.endpoint}" + + +class TunnelPath(BaseModel): + """Model for a IS-IS tunnel path.""" + + model_config = ConfigDict(extra="forbid") + nexthop: IPv4Address | None = None + """Nexthop of the tunnel.""" + type: Literal["ip", "tunnel"] | None = None + """Type of the tunnel.""" + interface: Interface | None = None + """Interface of the tunnel.""" + tunnel_id: Literal["TI-LFA", "ti-lfa", "unset"] | None = None + """Computation method of the tunnel.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the TunnelPath for reporting.""" + base_string = "" + if self.nexthop: + base_string += f" Next-hop: {self.nexthop}" + if self.type: + base_string += f" Type: {self.type}" + if self.interface: + base_string += f" Interface: {self.interface}" + if self.tunnel_id: + base_string += f" Tunnel ID: {self.tunnel_id}" + + return base_string.lstrip() + + +class Entry(Tunnel): # pragma: no cover + """Alias for the Tunnel model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the Tunnel model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the Entry class, emitting a deprecation warning.""" + warn( + message="Entry model is deprecated and will be removed in ANTA v2.0.0. Use the Tunnel model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) + + +class Vias(TunnelPath): # pragma: no cover + """Alias for the TunnelPath model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the TunnelPath model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the Vias class, emitting a deprecation warning.""" + warn( + message="Vias model is deprecated and will be removed in ANTA v2.0.0. Use the TunnelPath model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) diff --git a/anta/input_models/routing/ospf.py b/anta/input_models/routing/ospf.py new file mode 100644 index 000000000..5c0f61575 --- /dev/null +++ b/anta/input_models/routing/ospf.py @@ -0,0 +1,41 @@ +# Copyright (c) 2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for routing OSPF tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from anta.custom_types import Interface + + +class OSPFNeighbor(BaseModel): + """Model for an OSPF neighbor.""" + + model_config = ConfigDict(extra="forbid") + instance: int = Field(ge=1, le=65535) + """OSPF instance.""" + vrf: str = "default" + """VRF context of the OSPF instance.""" + ip_address: IPv4Address + """Neighbor interface IP address.""" + local_interface: Interface + """Local interface establishing the adjacency.""" + state: Literal["full", "2Ways"] = "full" + """Expected adjacency state.""" + area_id: IPv4Address | Annotated[int, Field(ge=0, le=4294967295)] + """OSPF area-id in decimal or IP address format.""" + + @field_validator("area_id", mode="after") + @classmethod + def convert_area_id(cls, area_id: IPv4Address | int) -> IPv4Address: + """Convert a decimal OSPF area-id to an IP address format if needed.""" + return IPv4Address(area_id) if isinstance(area_id, int) else area_id + + def __str__(self) -> str: + """Return a human-readable string representation of the OSPFNeighbor for reporting.""" + return f"Instance: {self.instance} VRF: {self.vrf} Neighbor IP: {self.ip_address} Area: {self.area_id}" diff --git a/anta/input_models/security.py b/anta/input_models/security.py new file mode 100644 index 000000000..79bdc17da --- /dev/null +++ b/anta/input_models/security.py @@ -0,0 +1,172 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for security tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address +from typing import TYPE_CHECKING, Any, ClassVar, get_args +from warnings import warn + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from anta.custom_types import EcdsaKeySize, EncryptionAlgorithm, RsaKeySize + +if TYPE_CHECKING: + import sys + + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + + +class IPSecPeer(BaseModel): + """IPSec (Internet Protocol Security) model represents the details of an IPv4 security peer.""" + + model_config = ConfigDict(extra="forbid") + peer: IPv4Address + """The IPv4 address of the security peer.""" + vrf: str = "default" + """VRF context. Defaults to `default`.""" + connections: list[IPSecConn] | None = None + """A list of IPv4 security connections associated with the peer. Defaults to None.""" + + def __str__(self) -> str: + """Return a string representation of the IPSecPeer model. Used in failure messages. + + Examples + -------- + - Peer: 1.1.1.1 VRF: default + """ + return f"Peer: {self.peer} VRF: {self.vrf}" + + +class IPSecConn(BaseModel): + """Details of an IPv4 security connection for a peer.""" + + model_config = ConfigDict(extra="forbid") + source_address: IPv4Address + """The IPv4 address of the source in the security connection.""" + destination_address: IPv4Address + """The IPv4 address of the destination in the security connection.""" + + +class APISSLCertificate(BaseModel): + """Model for an API SSL certificate.""" + + model_config = ConfigDict(extra="forbid") + certificate_name: str + """The name of the certificate to be verified.""" + expiry_threshold: int + """The expiry threshold of the certificate in days.""" + common_name: str + """The Common Name of the certificate.""" + encryption_algorithm: EncryptionAlgorithm + """The encryption algorithm used by the certificate.""" + key_size: RsaKeySize | EcdsaKeySize + """The key size (in bits) of the encryption algorithm.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the APISSLCertificate for reporting. + + Examples + -------- + - Certificate: SIGNING_CA.crt + """ + return f"Certificate: {self.certificate_name}" + + @model_validator(mode="after") + def validate_inputs(self) -> Self: + """Validate the key size provided to the APISSLCertificates class. + + If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}. + + If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}. + """ + if self.encryption_algorithm == "RSA" and self.key_size not in get_args(RsaKeySize): + msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {get_args(RsaKeySize)}." + raise ValueError(msg) + + if self.encryption_algorithm == "ECDSA" and self.key_size not in get_args(EcdsaKeySize): + msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {get_args(EcdsaKeySize)}." + raise ValueError(msg) + + return self + + +class ACLEntry(BaseModel): + """Model for an Access Control List (ACL) entry.""" + + model_config = ConfigDict(extra="forbid") + sequence: int = Field(ge=1, le=4294967295) + """Sequence number of the ACL entry, used to define the order of processing. Must be between 1 and 4294967295.""" + action: str + """Action of the ACL entry. Example: `deny ip any any`.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the ACLEntry for reporting. + + Examples + -------- + - Sequence: 10 + """ + return f"Sequence: {self.sequence}" + + +class ACL(BaseModel): + """Model for an Access Control List (ACL).""" + + model_config = ConfigDict(extra="forbid") + name: str + """Name of the ACL.""" + entries: list[ACLEntry] + """List of the ACL entries.""" + IPv4ACLEntry: ClassVar[type[ACLEntry]] = ACLEntry + """To maintain backward compatibility.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the ACL for reporting. + + Examples + -------- + - ACL name: Test + """ + return f"ACL name: {self.name}" + + +class IPv4ACL(ACL): # pragma: no cover + """Alias for the ACL model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the ACL model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the IPv4ACL class, emitting a deprecation warning.""" + warn( + message="IPv4ACL model is deprecated and will be removed in ANTA v2.0.0. Use the ACL model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) + + +class IPSecPeers(IPSecPeer): # pragma: no cover + """Alias for the IPSecPeers model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the IPSecPeer model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the IPSecPeers class, emitting a deprecation warning.""" + warn( + message="IPSecPeers model is deprecated and will be removed in ANTA v2.0.0. Use the IPSecPeer model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) diff --git a/anta/input_models/services.py b/anta/input_models/services.py new file mode 100644 index 000000000..0c602c8e6 --- /dev/null +++ b/anta/input_models/services.py @@ -0,0 +1,74 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for services tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address, IPv6Address +from typing import Any, Literal +from warnings import warn + +from pydantic import BaseModel, ConfigDict, Field + +from anta.custom_types import ErrDisableReasons + + +class DnsServer(BaseModel): + """Model for a DNS server configuration.""" + + model_config = ConfigDict(extra="forbid") + server_address: IPv4Address | IPv6Address + """The IPv4 or IPv6 address of the DNS server.""" + vrf: str = "default" + """The VRF instance in which the DNS server resides. Defaults to 'default'.""" + priority: int = Field(ge=0, le=4) + """The priority level of the DNS server, ranging from 0 to 4. Lower values indicate a higher priority, with 0 being the highest and 4 the lowest.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the DnsServer for reporting. + + Examples + -------- + Server 10.0.0.1 (VRF: default, Priority: 1) + """ + return f"Server {self.server_address} VRF: {self.vrf} Priority: {self.priority}" + + +class ErrdisableRecovery(BaseModel): + """Model for the error disable recovery functionality.""" + + model_config = ConfigDict(extra="forbid") + reason: ErrDisableReasons + """Name of the error disable reason.""" + status: Literal["Enabled", "Disabled"] = "Enabled" + """Operational status of the reason. Defaults to 'Enabled'.""" + interval: int = Field(ge=30, le=86400) + """Timer interval of the reason in seconds.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the ErrdisableRecovery for reporting. + + Examples + -------- + Reason: acl Status: Enabled Interval: 300 + """ + return f"Reason: {self.reason} Status: {self.status} Interval: {self.interval}" + + +class ErrDisableReason(ErrdisableRecovery): # pragma: no cover + """Alias for the ErrdisableRecovery model to maintain backward compatibility. + + When initialised, it will emit a deprecation warning and call the ErrdisableRecovery model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the ErrdisableRecovery class, emitting a depreciation warning.""" + warn( + message="ErrDisableReason model is deprecated and will be removed in ANTA v2.0.0. Use the ErrdisableRecovery model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) diff --git a/anta/input_models/snmp.py b/anta/input_models/snmp.py new file mode 100644 index 000000000..d5f14083d --- /dev/null +++ b/anta/input_models/snmp.py @@ -0,0 +1,127 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for SNMP tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address +from typing import TYPE_CHECKING, Literal + +from pydantic import BaseModel, ConfigDict, model_validator + +from anta.custom_types import Hostname, Interface, Port, SnmpEncryptionAlgorithm, SnmpHashingAlgorithm, SnmpVersion, SnmpVersionV3AuthType + +if TYPE_CHECKING: + import sys + + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + + +class SnmpHost(BaseModel): + """Model for a SNMP host.""" + + model_config = ConfigDict(extra="forbid") + hostname: IPv4Address | Hostname + """IPv4 address or Hostname of the SNMP notification host.""" + vrf: str = "default" + """Optional VRF for SNMP Hosts. If not provided, it defaults to `default`.""" + notification_type: Literal["trap", "inform"] = "trap" + """Type of SNMP notification (trap or inform), it defaults to trap.""" + version: SnmpVersion | None = None + """SNMP protocol version. Required field in the `VerifySnmpNotificationHost` test.""" + udp_port: Port | int = 162 + """UDP port for SNMP. If not provided then defaults to 162.""" + community_string: str | None = None + """Optional SNMP community string for authentication,required for SNMP version is v1 or v2c. Can be provided in the `VerifySnmpNotificationHost` test.""" + user: str | None = None + """Optional SNMP user for authentication, required for SNMP version v3. Can be provided in the `VerifySnmpNotificationHost` test.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the SnmpHost for reporting. + + Examples + -------- + - Host: 192.168.1.100 VRF: default + """ + return f"Host: {self.hostname} VRF: {self.vrf}" + + +class SnmpUser(BaseModel): + """Model for a SNMP User.""" + + model_config = ConfigDict(extra="forbid") + username: str + """SNMP user name.""" + group_name: str + """SNMP group for the user.""" + version: SnmpVersion + """SNMP protocol version.""" + auth_type: SnmpHashingAlgorithm | None = None + """User authentication algorithm. Can be provided in the `VerifySnmpUser` test.""" + priv_type: SnmpEncryptionAlgorithm | None = None + """User privacy algorithm. Can be provided in the `VerifySnmpUser` test.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the SnmpUser for reporting. + + Examples + -------- + - User: Test Group: Test_Group Version: v2c + """ + return f"User: {self.username} Group: {self.group_name} Version: {self.version}" + + +class SnmpSourceInterface(BaseModel): + """Model for a SNMP source-interface.""" + + interface: Interface + """Interface to use as the source IP address of SNMP messages.""" + vrf: str = "default" + """VRF of the source interface.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the SnmpSourceInterface for reporting. + + Examples + -------- + - Source Interface: Ethernet1 VRF: default + """ + return f"Source Interface: {self.interface} VRF: {self.vrf}" + + +class SnmpGroup(BaseModel): + """Model for an SNMP group.""" + + group_name: str + """SNMP group name.""" + version: SnmpVersion + """SNMP protocol version.""" + read_view: str | None = None + """Optional field, View to restrict read access.""" + write_view: str | None = None + """Optional field, View to restrict write access.""" + notify_view: str | None = None + """Optional field, View to restrict notifications.""" + authentication: SnmpVersionV3AuthType | None = None + """SNMPv3 authentication settings. Required when version is v3. Can be provided in the `VerifySnmpGroup` test.""" + + @model_validator(mode="after") + def validate_inputs(self) -> Self: + """Validate the inputs provided to the SnmpGroup class.""" + if self.version == "v3" and self.authentication is None: + msg = f"{self!s}: `authentication` field is missing in the input" + raise ValueError(msg) + return self + + def __str__(self) -> str: + """Return a human-readable string representation of the SnmpGroup for reporting. + + Examples + -------- + - Group: Test_Group Version: v2c + """ + return f"Group: {self.group_name} Version: {self.version}" diff --git a/anta/input_models/stun.py b/anta/input_models/stun.py new file mode 100644 index 000000000..1d915672a --- /dev/null +++ b/anta/input_models/stun.py @@ -0,0 +1,35 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for services tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address + +from pydantic import BaseModel, ConfigDict + +from anta.custom_types import Port + + +class StunClientTranslation(BaseModel): + """STUN (Session Traversal Utilities for NAT) model represents the configuration of an IPv4-based client translations.""" + + model_config = ConfigDict(extra="forbid") + source_address: IPv4Address + """The IPv4 address of the STUN client""" + source_port: Port = 4500 + """The port number used by the STUN client for communication. Defaults to 4500.""" + public_address: IPv4Address | None = None + """The public-facing IPv4 address of the STUN client, discovered via the STUN server.""" + public_port: Port | None = None + """The public-facing port number of the STUN client, discovered via the STUN server.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the StunClientTranslation for reporting. + + Examples + -------- + Client 10.0.0.1 Port: 4500 + """ + return f"Client {self.source_address} Port: {self.source_port}" diff --git a/anta/input_models/system.py b/anta/input_models/system.py new file mode 100644 index 000000000..3e098c4ec --- /dev/null +++ b/anta/input_models/system.py @@ -0,0 +1,41 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for system tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address + +from pydantic import BaseModel, ConfigDict + +from anta.custom_types import Hostname, NTPStratumLevel + + +class NTPServer(BaseModel): + """Model for a NTP server.""" + + model_config = ConfigDict(extra="forbid") + server_address: Hostname | IPv4Address + """The NTP server address as an IPv4 address or hostname. The NTP server name defined in the running configuration + of the device may change during DNS resolution, which is not handled in ANTA. Please provide the DNS-resolved server name. + For example, 'ntp.example.com' in the configuration might resolve to 'ntp3.example.com' in the device output.""" + preferred: bool = False + """Optional preferred for NTP server. If not provided, it defaults to `False`.""" + stratum: NTPStratumLevel + """NTP stratum level (0 to 15) where 0 is the reference clock and 16 indicates unsynchronized. + Values should be between 0 and 15 for valid synchronization and 16 represents an out-of-sync state.""" + + def __str__(self) -> str: + """Representation of the NTPServer model.""" + return f"NTP Server: {self.server_address} Preferred: {self.preferred} Stratum: {self.stratum}" + + +class NTPPool(BaseModel): + """Model for a NTP server pool.""" + + model_config = ConfigDict(extra="forbid") + server_addresses: list[Hostname | IPv4Address] + """The list of NTP server addresses as an IPv4 addresses or hostnames.""" + preferred_stratum_range: list[NTPStratumLevel] + """Preferred NTP stratum range for the NTP server pool. If the expected stratum range is 1 to 3 then preferred_stratum_range should be `[1,3]`.""" diff --git a/anta/input_models/vlan.py b/anta/input_models/vlan.py new file mode 100644 index 000000000..25588577c --- /dev/null +++ b/anta/input_models/vlan.py @@ -0,0 +1,26 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for VLAN tests.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +from anta.custom_types import VlanId + + +class Vlan(BaseModel): + """Model for a VLAN.""" + + model_config = ConfigDict(extra="forbid") + vlan_id: VlanId + """The VLAN ID.""" + status: Literal["active", "suspended", "inactive"] + """The VLAN administrative status.""" + + def __str__(self) -> str: + """Representation of the VLAN model.""" + return f"VLAN: Vlan{self.vlan_id}" diff --git a/anta/inventory/__init__.py b/anta/inventory/__init__.py index 5e66d84b2..72fca4b7f 100644 --- a/anta/inventory/__init__.py +++ b/anta/inventory/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Inventory module for ANTA.""" @@ -8,15 +8,16 @@ import asyncio import logging from ipaddress import ip_address, ip_network +from json import load as json_load from pathlib import Path -from typing import Any, ClassVar +from typing import Any, ClassVar, Literal from pydantic import ValidationError from yaml import YAMLError, safe_load from anta.device import AntaDevice, AsyncEOSDevice from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError -from anta.inventory.models import AntaInventoryInput +from anta.inventory.models import AntaInventoryHost, AntaInventoryInput from anta.logger import anta_log_exception logger = logging.getLogger(__name__) @@ -26,7 +27,7 @@ class AntaInventory(dict[str, AntaDevice]): """Inventory abstraction for ANTA framework.""" # Root key of inventory part of the inventory file - INVENTORY_ROOT_KEY = "anta_inventory" + INVENTORY_ROOT_KEY: str = "anta_inventory" # Supported Output format INVENTORY_OUTPUT_FORMAT: ClassVar[list[str]] = ["native", "json"] @@ -44,10 +45,12 @@ def __str__(self) -> str: def _update_disable_cache(kwargs: dict[str, Any], *, inventory_disable_cache: bool) -> dict[str, Any]: """Return new dictionary, replacing kwargs with added disable_cache value from inventory_value if disable_cache has not been set by CLI. - Args: - ---- - inventory_disable_cache: The value of disable_cache in the inventory - kwargs: The kwargs to instantiate the device + Parameters + ---------- + inventory_disable_cache + The value of disable_cache in the inventory. + kwargs + The kwargs to instantiate the device. """ updated_kwargs = kwargs.copy() @@ -62,11 +65,14 @@ def _parse_hosts( ) -> None: """Parse the host section of an AntaInventoryInput and add the devices to the inventory. - Args: - ---- - inventory_input: AntaInventoryInput used to parse the devices - inventory: AntaInventory to add the parsed devices to - **kwargs: Additional keyword arguments to pass to the device constructor + Parameters + ---------- + inventory_input + AntaInventoryInput used to parse the devices. + inventory + AntaInventory to add the parsed devices to. + **kwargs + Additional keyword arguments to pass to the device constructor. """ if inventory_input.hosts is None: @@ -91,15 +97,19 @@ def _parse_networks( ) -> None: """Parse the network section of an AntaInventoryInput and add the devices to the inventory. - Args: - ---- - inventory_input: AntaInventoryInput used to parse the devices - inventory: AntaInventory to add the parsed devices to - **kwargs: Additional keyword arguments to pass to the device constructor + Parameters + ---------- + inventory_input + AntaInventoryInput used to parse the devices. + inventory + AntaInventory to add the parsed devices to. + **kwargs + Additional keyword arguments to pass to the device constructor. Raises ------ - InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema. + InventoryIncorrectSchemaError + Inventory file is not following AntaInventory Schema. """ if inventory_input.networks is None: @@ -124,15 +134,19 @@ def _parse_ranges( ) -> None: """Parse the range section of an AntaInventoryInput and add the devices to the inventory. - Args: - ---- - inventory_input: AntaInventoryInput used to parse the devices - inventory: AntaInventory to add the parsed devices to - **kwargs: Additional keyword arguments to pass to the device constructor + Parameters + ---------- + inventory_input + AntaInventoryInput used to parse the devices. + inventory + AntaInventory to add the parsed devices to. + **kwargs + Additional keyword arguments to pass to the device constructor. Raises ------ - InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema. + InventoryIncorrectSchemaError + Inventory file is not following AntaInventory Schema. """ if inventory_input.ranges is None: @@ -158,7 +172,6 @@ def _parse_ranges( anta_log_exception(e, message, logger) raise InventoryIncorrectSchemaError(message) from e - # pylint: disable=too-many-arguments @staticmethod def parse( filename: str | Path, @@ -166,6 +179,7 @@ def parse( password: str, enable_password: str | None = None, timeout: float | None = None, + file_format: Literal["yaml", "json"] = "yaml", *, enable: bool = False, insecure: bool = False, @@ -175,23 +189,39 @@ def parse( The inventory devices are AsyncEOSDevice instances. - Args: - ---- - filename: Path to device inventory YAML file. - username: Username to use to connect to devices. - password: Password to use to connect to devices. - enable_password: Enable password to use if required. - timeout: Timeout value in seconds for outgoing API calls. - enable: Whether or not the commands need to be run in enable mode towards the devices. - insecure: Disable SSH Host Key validation. - disable_cache: Disable cache globally. + Parameters + ---------- + filename + Path to device inventory YAML file. + username + Username to use to connect to devices. + password + Password to use to connect to devices. + enable_password + Enable password to use if required. + timeout + Global timeout value in seconds for outgoing eAPI calls. None means no timeout. + file_format + Whether the inventory file is in JSON or YAML. + enable + Whether or not the commands need to be run in enable mode towards the devices. + insecure + Disable SSH Host Key validation. + disable_cache + Disable cache globally. Raises ------ - InventoryRootKeyError: Root key of inventory is missing. - InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema. + InventoryRootKeyError + Root key of inventory is missing. + InventoryIncorrectSchemaError + Inventory file is not following AntaInventory Schema. """ + if file_format not in ["yaml", "json"]: + message = f"'{file_format}' is not a valid format for an AntaInventory file. Only 'yaml' and 'json' are supported." + raise ValueError(message) + inventory = AntaInventory() kwargs: dict[str, Any] = { "username": username, @@ -202,20 +232,12 @@ def parse( "insecure": insecure, "disable_cache": disable_cache, } - if username is None: - message = "'username' is required to create an AntaInventory" - logger.error(message) - raise ValueError(message) - if password is None: - message = "'password' is required to create an AntaInventory" - logger.error(message) - raise ValueError(message) try: filename = Path(filename) with filename.open(encoding="UTF-8") as file: - data = safe_load(file) - except (TypeError, YAMLError, OSError) as e: + data = safe_load(file) if file_format == "yaml" else json_load(file) + except (TypeError, YAMLError, OSError, ValueError) as e: message = f"Unable to parse ANTA Device Inventory file '{filename}'" anta_log_exception(e, message, logger) raise @@ -243,6 +265,11 @@ def devices(self) -> list[AntaDevice]: """List of AntaDevice in this inventory.""" return list(self.values()) + @property + def max_potential_connections(self) -> int | None: + """Max potential connections of this inventory.""" + return self._get_potential_connections() + ########################################################################### # Public methods ########################################################################### @@ -254,14 +281,18 @@ def devices(self) -> list[AntaDevice]: def get_inventory(self, *, established_only: bool = False, tags: set[str] | None = None, devices: set[str] | None = None) -> AntaInventory: """Return a filtered inventory. - Args: - ---- - established_only: Whether or not to include only established devices. - tags: Tags to filter devices. - devices: Names to filter devices. + Parameters + ---------- + established_only + Whether or not to include only established devices. + tags + Tags to filter devices. + devices + Names to filter devices. Returns ------- + AntaInventory An inventory with filtered AntaDevice objects. """ @@ -279,6 +310,29 @@ def _filter_devices(device: AntaDevice) -> bool: result.add_device(device) return result + def _get_potential_connections(self) -> int | None: + """Calculate the total potential concurrent connections for the current inventory. + + This method sums the maximum concurrent connections allowed for each + AntaDevice in the inventory. + + Returns + ------- + int | None + The total sum of the `max_connections` attribute for all AntaDevice objects + in the inventory. Returns None if any AntaDevice does not have a `max_connections` + attribute or if its value is None, as the total count cannot be determined. + """ + potential_connections = 0 + all_have_connections = True + for device in self.devices: + if device.max_connections is None: + all_have_connections = False + logger.debug("Device %s 'max_connections' is not available", device.name) + break + potential_connections += device.max_connections + return None if not all_have_connections else potential_connections + ########################################################################### # SET methods ########################################################################### @@ -293,9 +347,10 @@ def __setitem__(self, key: str, value: AntaDevice) -> None: def add_device(self, device: AntaDevice) -> None: """Add a device to final inventory. - Args: - ---- - device: Device object to be added + Parameters + ---------- + device + Device object to be added. """ self[device.name] = device @@ -315,3 +370,20 @@ async def connect_inventory(self) -> None: if isinstance(r, Exception): message = "Error when refreshing inventory" anta_log_exception(r, message, logger) + + def dump(self) -> AntaInventoryInput: + """Dump the AntaInventory to an AntaInventoryInput. + + Each hosts is dumped individually. + """ + hosts = [ + AntaInventoryHost( + name=device.name, + host=device.host if hasattr(device, "host") else device.name, + port=device.port if hasattr(device, "port") else None, + tags=device.tags, + disable_cache=device.cache is None, + ) + for device in self.devices + ] + return AntaInventoryInput(hosts=hosts) diff --git a/anta/inventory/exceptions.py b/anta/inventory/exceptions.py index 90a672f61..f7adaa703 100644 --- a/anta/inventory/exceptions.py +++ b/anta/inventory/exceptions.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Manage Exception in Inventory module.""" diff --git a/anta/inventory/models.py b/anta/inventory/models.py index e26ea001c..493bad714 100644 --- a/anta/inventory/models.py +++ b/anta/inventory/models.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Models related to inventory management.""" @@ -6,29 +6,46 @@ from __future__ import annotations import logging +import math -from pydantic import BaseModel, ConfigDict, IPvAnyAddress, IPvAnyNetwork +import yaml +from pydantic import BaseModel, ConfigDict, FieldSerializationInfo, IPvAnyAddress, IPvAnyNetwork, field_serializer from anta.custom_types import Hostname, Port logger = logging.getLogger(__name__) -class AntaInventoryHost(BaseModel): +class AntaInventoryBaseModel(BaseModel): + """Pydantic BaseModel for AntaInventory objects.""" + + model_config = ConfigDict(extra="forbid") + + # Using check_fields as we plan to use this in the child classes + @field_serializer("tags", when_used="json", check_fields=False) + def serialize_tags(self, tags: set[str], _info: FieldSerializationInfo) -> list[str]: + """Make sure the tags are always dumped in the same order.""" + return sorted(tags) + + +class AntaInventoryHost(AntaInventoryBaseModel): """Host entry of AntaInventoryInput. Attributes ---------- - host: IP Address or FQDN of the device. - port: Custom eAPI port to use. - name: Custom name of the device. - tags: Tags of the device. - disable_cache: Disable cache for this device. + host : Hostname | IPvAnyAddress + IP Address or FQDN of the device. + port : Port | None + Custom eAPI port to use. + name : str | None + Custom name of the device. + tags : set[str] + Tags of the device. + disable_cache : bool + Disable cache for this device. """ - model_config = ConfigDict(extra="forbid") - name: str | None = None host: Hostname | IPvAnyAddress port: Port | None = None @@ -36,38 +53,41 @@ class AntaInventoryHost(BaseModel): disable_cache: bool = False -class AntaInventoryNetwork(BaseModel): +class AntaInventoryNetwork(AntaInventoryBaseModel): """Network entry of AntaInventoryInput. Attributes ---------- - network: Subnet to use for scanning. - tags: Tags of the devices in this network. - disable_cache: Disable cache for all devices in this network. + network : IPvAnyNetwork + Subnet to use for scanning. + tags : set[str] + Tags of the devices in this network. + disable_cache : bool + Disable cache for all devices in this network. """ - model_config = ConfigDict(extra="forbid") - network: IPvAnyNetwork tags: set[str] | None = None disable_cache: bool = False -class AntaInventoryRange(BaseModel): +class AntaInventoryRange(AntaInventoryBaseModel): """IP Range entry of AntaInventoryInput. Attributes ---------- - start: IPv4 or IPv6 address for the beginning of the range. - stop: IPv4 or IPv6 address for the end of the range. - tags: Tags of the devices in this IP range. - disable_cache: Disable cache for all devices in this IP range. + start : IPvAnyAddress + IPv4 or IPv6 address for the beginning of the range. + stop : IPvAnyAddress + IPv4 or IPv6 address for the end of the range. + tags : set[str] + Tags of the devices in this IP range. + disable_cache : bool + Disable cache for all devices in this IP range. """ - model_config = ConfigDict(extra="forbid") - start: IPvAnyAddress end: IPvAnyAddress tags: set[str] | None = None @@ -82,3 +102,26 @@ class AntaInventoryInput(BaseModel): networks: list[AntaInventoryNetwork] | None = None hosts: list[AntaInventoryHost] | None = None ranges: list[AntaInventoryRange] | None = None + + def yaml(self) -> str: + """Return a YAML representation string of this model. + + Returns + ------- + str + The YAML representation string of this model. + """ + # TODO: Pydantic and YAML serialization/deserialization is not supported natively. + # This could be improved. + # https://github.com/pydantic/pydantic/issues/1043 + # Explore if this worth using this: https://github.com/NowanIlfideme/pydantic-yaml + return yaml.safe_dump(yaml.safe_load(self.model_dump_json(serialize_as_any=True, exclude_unset=True)), width=math.inf) + + def to_json(self) -> str: + """Return a JSON representation string of this model. + + Returns + ------- + The JSON representation string of this model. + """ + return self.model_dump_json(serialize_as_any=True, exclude_unset=True, indent=2) diff --git a/anta/logger.py b/anta/logger.py index e53c93a0e..e6d04287b 100644 --- a/anta/logger.py +++ b/anta/logger.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Configure logging for ANTA.""" @@ -7,16 +7,15 @@ import logging import traceback +from datetime import timedelta from enum import Enum -from typing import TYPE_CHECKING, Literal +from pathlib import Path +from typing import Literal from rich.logging import RichHandler from anta import __DEBUG__ -if TYPE_CHECKING: - from pathlib import Path - logger = logging.getLogger(__name__) @@ -48,10 +47,12 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None: If a file is provided and logging level is DEBUG, only the logging level INFO and higher will be logged to stdout while all levels will be logged in the file. - Args: - ---- - level: ANTA logging level - file: Send logs to a file + Parameters + ---------- + level + ANTA logging level + file + Send logs to a file """ # Init root logger @@ -66,27 +67,65 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None: # httpx as well logging.getLogger("httpx").setLevel(logging.WARNING) - # Add RichHandler for stdout - rich_handler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False) - # Show Python module in stdout at DEBUG level - fmt_string = "[grey58]\\[%(name)s][/grey58] %(message)s" if loglevel == logging.DEBUG else "%(message)s" - formatter = logging.Formatter(fmt=fmt_string, datefmt="[%X]") - rich_handler.setFormatter(formatter) - root.addHandler(rich_handler) - # Add FileHandler if file is provided - if file: + # Add RichHandler for stdout if not already present + _maybe_add_rich_handler(loglevel, root) + + # Add FileHandler if file is provided and same File Handler is not already present + if file and not _get_file_handler(root, file): file_handler = logging.FileHandler(file) formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") file_handler.setFormatter(formatter) root.addHandler(file_handler) # If level is DEBUG and file is provided, do not send DEBUG level to stdout - if loglevel == logging.DEBUG: + if loglevel == logging.DEBUG and (rich_handler := _get_rich_handler(root)) is not None: rich_handler.setLevel(logging.INFO) if __DEBUG__: logger.debug("ANTA Debug Mode enabled") +def _get_file_handler(logger_instance: logging.Logger, file: Path) -> logging.FileHandler | None: + """Return the FileHandler if present.""" + return ( + next( + ( + handler + for handler in logger_instance.handlers + if isinstance(handler, logging.FileHandler) and str(Path(handler.baseFilename).resolve()) == str(file.resolve()) + ), + None, + ) + if logger_instance.hasHandlers() + else None + ) + + +def _get_rich_handler(logger_instance: logging.Logger) -> logging.Handler | None: + """Return the ANTA Rich Handler.""" + return next((handler for handler in logger_instance.handlers if handler.get_name() == "ANTA_RICH_HANDLER"), None) if logger_instance.hasHandlers() else None + + +def _maybe_add_rich_handler(loglevel: int, logger_instance: logging.Logger) -> None: + """Add RichHandler for stdout if not already present.""" + if _get_rich_handler(logger_instance) is not None: + # Nothing to do. + return + + anta_rich_handler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False) + anta_rich_handler.set_name("ANTA_RICH_HANDLER") + # Show Python module in stdout at DEBUG level + fmt_string = "[grey58]\\[%(name)s][/grey58] %(message)s" if loglevel == logging.DEBUG else "%(message)s" + formatter = logging.Formatter(fmt=fmt_string, datefmt="[%X]") + anta_rich_handler.setFormatter(formatter) + logger_instance.addHandler(anta_rich_handler) + + +def format_td(seconds: float, digits: int = 3) -> str: + """Return a formatted string from a float number representing seconds and a number of digits.""" + isec, fsec = divmod(round(seconds * 10**digits), 10**digits) + return f"{timedelta(seconds=isec)}.{fsec:0{digits}.0f}" + + def exc_to_str(exception: BaseException) -> str: """Return a human readable string from an BaseException object.""" return f"{type(exception).__name__}{f': {exception}' if str(exception) else ''}" @@ -97,11 +136,14 @@ def anta_log_exception(exception: BaseException, message: str | None = None, cal If `anta.__DEBUG__` is True then the `logger.exception` method is called to get the traceback, otherwise `logger.error` is called. - Args: - ---- - exception: The Exception being logged. - message: An optional message. - calling_logger: A logger to which the exception should be logged. If not present, the logger in this file is used. + Parameters + ---------- + exception + The Exception being logged. + message + An optional message. + calling_logger + A logger to which the exception should be logged. If not present, the logger in this file is used. """ if calling_logger is None: diff --git a/anta/models.py b/anta/models.py index f963dc037..172f03297 100644 --- a/anta/models.py +++ b/anta/models.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Models to define a TestStructure.""" @@ -8,20 +8,17 @@ import hashlib import logging import re -import time from abc import ABC, abstractmethod -from copy import deepcopy -from datetime import timedelta from functools import wraps from string import Formatter from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, TypeVar from pydantic import BaseModel, ConfigDict, ValidationError, create_model -from anta import GITHUB_SUGGESTION +from anta.constants import EOS_BLACKLIST_CMDS, KNOWN_EOS_ERRORS, UNSUPPORTED_PLATFORM_ERRORS from anta.custom_types import Revision from anta.logger import anta_log_exception, exc_to_str -from anta.result_manager.models import TestResult +from anta.result_manager.models import AntaTestStatus, TestResult if TYPE_CHECKING: from collections.abc import Coroutine @@ -35,9 +32,6 @@ # This would imply overhead to define classes # https://stackoverflow.com/questions/74103528/type-hinting-an-instance-of-a-nested-class -# TODO: make this configurable - with an env var maybe? -BLACKLIST_REGEX = [r"^reload.*", r"^conf\w*\s*(terminal|session)*", r"^wr\w*\s*\w+"] - logger = logging.getLogger(__name__) @@ -46,78 +40,95 @@ class AntaParamsBaseModel(BaseModel): model_config = ConfigDict(extra="forbid") - if not TYPE_CHECKING: - # Following pydantic declaration and keeping __getattr__ only when TYPE_CHECKING is false. - # Disabling 1 Dynamically typed expressions (typing.Any) are disallowed in `__getattr__ - # ruff: noqa: ANN401 - def __getattr__(self, item: str) -> Any: - """For AntaParams if we try to access an attribute that is not present We want it to be None.""" - try: - return super().__getattr__(item) - except AttributeError: - return None - -class AntaTemplate(BaseModel): +class AntaTemplate: """Class to define a command template as Python f-string. Can render a command from parameters. Attributes ---------- - template: Python f-string. Example: 'show vlan {vlan_id}' - version: eAPI version - valid values are 1 or "latest". - revision: Revision of the command. Valid values are 1 to 99. Revision has precedence over version. - ofmt: eAPI output - json or text. - use_cache: Enable or disable caching for this AntaTemplate if the AntaDevice supports it. - + template + Python f-string. Example: 'show vlan {vlan_id}'. + version + eAPI version - valid values are 1 or "latest". + revision + Revision of the command. Valid values are 1 to 99. Revision has precedence over version. + ofmt + eAPI output - json or text. + use_cache + Enable or disable caching for this AntaTemplate if the AntaDevice supports it. """ - template: str - version: Literal[1, "latest"] = "latest" - revision: Revision | None = None - ofmt: Literal["json", "text"] = "json" - use_cache: bool = True + # pylint: disable=too-few-public-methods + + def __init__( + self, + template: str, + version: Literal[1, "latest"] = "latest", + revision: Revision | None = None, + ofmt: Literal["json", "text"] = "json", + *, + use_cache: bool = True, + ) -> None: + self.template = template + self.version = version + self.revision = revision + self.ofmt = ofmt + self.use_cache = use_cache + + # Create a AntaTemplateParams model to elegantly store AntaTemplate variables + field_names = [fname for _, fname, _, _ in Formatter().parse(self.template) if fname] + # Extracting the type from the params based on the expected field_names from the template + fields: dict[str, Any] = dict.fromkeys(field_names, (Any, ...)) + self.params_schema = create_model( + "AntaParams", + __base__=AntaParamsBaseModel, + **fields, + ) + + def __repr__(self) -> str: + """Return the representation of the class. + + Copying pydantic model style, excluding `params_schema` + """ + return " ".join(f"{a}={v!r}" for a, v in vars(self).items() if a != "params_schema") def render(self, **params: str | int | bool) -> AntaCommand: """Render an AntaCommand from an AntaTemplate instance. Keep the parameters used in the AntaTemplate instance. - Args: - ---- - params: dictionary of variables with string values to render the Python f-string + Parameters + ---------- + params + Dictionary of variables with string values to render the Python f-string. Returns ------- - command: The rendered AntaCommand. - This AntaCommand instance have a template attribute that references this - AntaTemplate instance. + AntaCommand + The rendered AntaCommand. + This AntaCommand instance have a template attribute that references this + AntaTemplate instance. + Raises + ------ + AntaTemplateRenderError + If a parameter is missing to render the AntaTemplate instance. """ - # Create params schema on the fly - field_names = [fname for _, fname, _, _ in Formatter().parse(self.template) if fname] - # Extracting the type from the params based on the expected field_names from the template - fields: dict[str, Any] = {key: (type(params.get(key)), ...) for key in field_names} - # Accepting ParamsSchema as non lowercase variable - ParamsSchema = create_model( # noqa: N806 - "ParamsSchema", - __base__=AntaParamsBaseModel, - **fields, - ) - try: - return AntaCommand( - command=self.template.format(**params), - ofmt=self.ofmt, - version=self.version, - revision=self.revision, - template=self, - params=ParamsSchema(**params), - use_cache=self.use_cache, - ) - except KeyError as e: + command = self.template.format(**params) + except (KeyError, SyntaxError) as e: raise AntaTemplateRenderError(self, e.args[0]) from e + return AntaCommand( + command=command, + ofmt=self.ofmt, + version=self.version, + revision=self.revision, + template=self, + params=self.params_schema(**params), + use_cache=self.use_cache, + ) class AntaCommand(BaseModel): @@ -136,18 +147,29 @@ class AntaCommand(BaseModel): Attributes ---------- - command: Device command - version: eAPI version - valid values are 1 or "latest". - revision: eAPI revision of the command. Valid values are 1 to 99. Revision has precedence over version. - ofmt: eAPI output - json or text. - output: Output of the command. Only defined if there was no errors. - template: AntaTemplate object used to render this command. - errors: If the command execution fails, eAPI returns a list of strings detailing the error(s). - params: Pydantic Model containing the variables values used to render the template. - use_cache: Enable or disable caching for this AntaCommand if the AntaDevice supports it. + command + Device command. + version + eAPI version - valid values are 1 or "latest". + revision + eAPI revision of the command. Valid values are 1 to 99. Revision has precedence over version. + ofmt + eAPI output - json or text. + output + Output of the command. Only defined if there was no errors. + template + AntaTemplate object used to render this command. + errors + If the command execution fails, eAPI returns a list of strings detailing the error(s). + params + Pydantic Model containing the variables values used to render the template. + use_cache + Enable or disable caching for this AntaCommand if the AntaDevice supports it. """ + model_config = ConfigDict(arbitrary_types_allowed=True) + command: str version: Literal[1, "latest"] = "latest" revision: Revision | None = None @@ -207,9 +229,9 @@ def requires_privileges(self) -> bool: Raises ------ - RuntimeError - If the command has not been collected and has not returned an error. - AntaDevice.collect() must be called before this property. + RuntimeError + If the command has not been collected and has not returned an error. + AntaDevice.collect() must be called before this property. """ if not self.collected and not self.error: msg = f"Command '{self.command}' has not been collected and has not returned an error. Call AntaDevice.collect()." @@ -218,18 +240,38 @@ def requires_privileges(self) -> bool: @property def supported(self) -> bool: - """Return True if the command is supported on the device hardware platform, False otherwise. + """Indicates if the command is supported on the device. + + Returns + ------- + bool + True if the command is supported on the device hardware platform, False otherwise. Raises ------ - RuntimeError - If the command has not been collected and has not returned an error. - AntaDevice.collect() must be called before this property. + RuntimeError + If the command has not been collected and has not returned an error. + AntaDevice.collect() must be called before this property. """ if not self.collected and not self.error: msg = f"Command '{self.command}' has not been collected and has not returned an error. Call AntaDevice.collect()." + raise RuntimeError(msg) - return not any("not supported on this hardware platform" in e for e in self.errors) + + return not any(any(error in e for error in UNSUPPORTED_PLATFORM_ERRORS) for e in self.errors) + + @property + def returned_known_eos_error(self) -> bool: + """Return True if the command returned a known_eos_error on the device, False otherwise. + + RuntimeError + If the command has not been collected and has not returned an error. + AntaDevice.collect() must be called before this property. + """ + if not self.collected and not self.error: + msg = f"Command '{self.command}' has not been collected and has not returned an error. Call AntaDevice.collect()." + raise RuntimeError(msg) + return any(any(re.match(pattern, e) for e in self.errors) for pattern in KNOWN_EOS_ERRORS) class AntaTemplateRenderError(RuntimeError): @@ -238,10 +280,12 @@ class AntaTemplateRenderError(RuntimeError): def __init__(self, template: AntaTemplate, key: str) -> None: """Initialize an AntaTemplateRenderError. - Args: - ---- - template: The AntaTemplate instance that failed to render - key: Key that has not been provided to render the template + Parameters + ---------- + template + The AntaTemplate instance that failed to render. + key + Key that has not been provided to render the template. """ self.template = template @@ -260,8 +304,7 @@ class AntaTest(ABC): The following is an example of an AntaTest subclass implementation: ```python class VerifyReachability(AntaTest): - name = "VerifyReachability" - description = "Test the network reachability to one or many destination IP(s)." + '''Test the network reachability to one or many destination IP(s).''' categories = ["connectivity"] commands = [AntaTemplate(template="ping vrf {vrf} {dst} source {src} repeat 2")] @@ -273,14 +316,13 @@ class Host(BaseModel): vrf: str = "default" def render(self, template: AntaTemplate) -> list[AntaCommand]: - return [template.render({"dst": host.dst, "src": host.src, "vrf": host.vrf}) for host in self.inputs.hosts] + return [template.render(dst=host.dst, src=host.src, vrf=host.vrf) for host in self.inputs.hosts] @AntaTest.anta_test def test(self) -> None: failures = [] for command in self.instance_commands: - if command.params and ("src" and "dst") in command.params: - src, dst = command.params["src"], command.params["dst"] + src, dst = command.params.src, command.params.dst if "2 received" not in command.json_output["messages"][0]: failures.append((str(src), str(dst))) if not failures: @@ -288,21 +330,34 @@ def test(self) -> None: else: self.result.is_failure(f"Connectivity test failed for the following source-destination pairs: {failures}") ``` - Attributes: - device: AntaDevice instance on which this test is run - inputs: AntaTest.Input instance carrying the test inputs - instance_commands: List of AntaCommand instances of this test - result: TestResult instance representing the result of this test - logger: Python logger for this test instance + Attributes + ---------- + device + AntaDevice instance on which this test is run. + inputs + AntaTest.Input instance carrying the test inputs. + instance_commands + List of AntaCommand instances of this test. + result + TestResult instance representing the result of this test. + logger + Python logger for this test instance. """ - # Mandatory class attributes - # TODO: find a way to tell mypy these are mandatory for child classes - maybe Protocol + # Optional class attributes name: ClassVar[str] description: ClassVar[str] + __removal_in_version: ClassVar[str] + """Internal class variable set by the `deprecated_test_class` decorator.""" + + # Mandatory class attributes + # TODO: find a way to tell mypy these are mandatory for child classes + # follow this https://discuss.python.org/t/provide-a-canonical-way-to-declare-an-abstract-class-variable/69416 + # for now only enforced at runtime with __init_subclass__ categories: ClassVar[list[str]] commands: ClassVar[list[AntaTemplate | AntaCommand]] + # Class attributes to handle the progress bar of ANTA CLI progress: Progress | None = None nrfu_task: TaskID | None = None @@ -322,9 +377,11 @@ class Input(BaseModel): description: "Test with overwritten description" custom_field: "Test run by John Doe" ``` - Attributes: - result_overwrite: Define fields to overwrite in the TestResult object + Attributes + ---------- + result_overwrite + Define fields to overwrite in the TestResult object. """ model_config = ConfigDict(extra="forbid") @@ -343,9 +400,12 @@ class ResultOverwrite(BaseModel): Attributes ---------- - description: overwrite TestResult.description - categories: overwrite TestResult.categories - custom_field: a free string that will be included in the TestResult object + description + Overwrite `TestResult.description`. + categories + Overwrite `TestResult.categories`. + custom_field + A free string that will be included in the TestResult object. """ @@ -359,8 +419,8 @@ class Filters(BaseModel): Attributes ---------- - tags: Tag of devices on which to run the test. - + tags + Tag of devices on which to run the test. """ model_config = ConfigDict(extra="forbid") @@ -372,17 +432,19 @@ def __init__( inputs: dict[str, Any] | AntaTest.Input | None = None, eos_data: list[dict[Any, Any] | str] | None = None, ) -> None: - """AntaTest Constructor. - - Args: - ---- - device: AntaDevice instance on which the test will be run - inputs: dictionary of attributes used to instantiate the AntaTest.Input instance - eos_data: Populate outputs of the test commands instead of collecting from devices. - This list must have the same length and order than the `instance_commands` instance attribute. - + """Initialize an AntaTest instance. + + Parameters + ---------- + device + AntaDevice instance on which the test will be run. + inputs + Dictionary of attributes used to instantiate the AntaTest.Input instance. + eos_data + Populate outputs of the test commands instead of collecting from devices. + This list must have the same length and order than the `instance_commands` instance attribute. """ - self.logger: logging.Logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}") + self.logger: logging.Logger = logging.getLogger(f"{self.module}.{self.__class__.__name__}") self.device: AntaDevice = device self.inputs: AntaTest.Input self.instance_commands: list[AntaCommand] = [] @@ -393,7 +455,7 @@ def __init__( description=self.description, ) self._init_inputs(inputs) - if self.result.result == "unset": + if self.result.result == AntaTestStatus.UNSET: self._init_commands(eos_data) def _init_inputs(self, inputs: dict[str, Any] | AntaTest.Input | None) -> None: @@ -411,7 +473,7 @@ def _init_inputs(self, inputs: dict[str, Any] | AntaTest.Input | None) -> None: elif isinstance(inputs, dict): self.inputs = self.Input(**inputs) except ValidationError as e: - message = f"{self.__module__}.{self.__class__.__name__}: Inputs are not valid\n{e}" + message = f"{self.module}.{self.name}: Inputs are not valid\n{e}" self.logger.error(message) self.result.is_error(message=message) return @@ -434,7 +496,7 @@ def _init_commands(self, eos_data: list[dict[Any, Any] | str] | None) -> None: if self.__class__.commands: for cmd in self.__class__.commands: if isinstance(cmd, AntaCommand): - self.instance_commands.append(deepcopy(cmd)) + self.instance_commands.append(cmd.model_copy()) elif isinstance(cmd, AntaTemplate): try: self.instance_commands.extend(self.render(cmd)) @@ -444,11 +506,11 @@ def _init_commands(self, eos_data: list[dict[Any, Any] | str] | None) -> None: except NotImplementedError as e: self.result.is_error(message=e.args[0]) return - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: # noqa: BLE001 # render() is user-defined code. # We need to catch everything if we want the AntaTest object # to live until the reporting - message = f"Exception in {self.__module__}.{self.__class__.__name__}.render()" + message = f"Exception in {self.module}.{self.__class__.__name__}.render()" anta_log_exception(e, message, self.logger) self.result.is_error(message=f"{message}: {exc_to_str(e)}") return @@ -469,21 +531,33 @@ def save_commands_data(self, eos_data: list[dict[str, Any] | str]) -> None: self.instance_commands[index].output = data def __init_subclass__(cls) -> None: - """Verify that the mandatory class attributes are defined.""" - mandatory_attributes = ["name", "description", "categories", "commands"] - for attr in mandatory_attributes: - if not hasattr(cls, attr): - msg = f"Class {cls.__module__}.{cls.__name__} is missing required class attribute {attr}" - raise NotImplementedError(msg) + """Verify that the mandatory class attributes are defined and set name and description if not set.""" + mandatory_attributes = ["categories", "commands"] + if missing_attrs := [attr for attr in mandatory_attributes if not hasattr(cls, attr)]: + msg = f"Class {cls.__module__}.{cls.__name__} is missing required class attribute(s): {', '.join(missing_attrs)}" + raise AttributeError(msg) + + cls.name = getattr(cls, "name", cls.__name__) + if not hasattr(cls, "description"): + if not cls.__doc__ or cls.__doc__.strip() == "": + # No doctsring or empty doctsring - raise + msg = f"Cannot set the description for class {cls.name}, either set it in the class definition or add a docstring to the class." + raise AttributeError(msg) + cls.description = cls.__doc__.split(sep="\n", maxsplit=1)[0] + + @property + def module(self) -> str: + """Return the Python module in which this AntaTest class is defined.""" + return self.__module__ @property def collected(self) -> bool: - """Returns True if all commands for this test have been collected.""" + """Return True if all commands for this test have been collected.""" return all(command.collected for command in self.instance_commands) @property def failed_commands(self) -> list[AntaCommand]: - """Returns a list of all the commands that have failed.""" + """Return a list of all the commands that have failed.""" return [command for command in self.instance_commands if command.error] def render(self, template: AntaTemplate) -> list[AntaCommand]: @@ -493,7 +567,7 @@ def render(self, template: AntaTemplate) -> list[AntaCommand]: no AntaTemplate for this test. """ _ = template - msg = f"AntaTemplate are provided but render() method has not been implemented for {self.__module__}.{self.name}" + msg = f"AntaTemplate are provided but render() method has not been implemented for {self.module}.{self.__class__.__name__}" raise NotImplementedError(msg) @property @@ -501,12 +575,12 @@ def blocked(self) -> bool: """Check if CLI commands contain a blocked keyword.""" state = False for command in self.instance_commands: - for pattern in BLACKLIST_REGEX: + for pattern in EOS_BLACKLIST_CMDS: if re.match(pattern, command.command): self.logger.error( "Command <%s> is blocked for security reason matching %s", command.command, - BLACKLIST_REGEX, + EOS_BLACKLIST_CMDS, ) self.result.is_error(f"<{command.command}> is blocked for security reason") state = True @@ -516,8 +590,8 @@ async def collect(self) -> None: """Collect outputs of all commands of this test class from the device of this test instance.""" try: if self.blocked is False: - await self.device.collect_commands(self.instance_commands) - except Exception as e: # pylint: disable=broad-exception-caught + await self.device.collect_commands(self.instance_commands, collection_id=self.name) + except Exception as e: # noqa: BLE001 # device._collect() is user-defined code. # We need to catch everything if we want the AntaTest object # to live until the reporting @@ -545,24 +619,22 @@ async def wrapper( ) -> TestResult: """Inner function for the anta_test decorator. - Args: - ---- - self: The test instance. - eos_data: Populate outputs of the test commands instead of collecting from devices. - This list must have the same length and order than the `instance_commands` instance attribute. - kwargs: Any keyword argument to pass to the test. + Parameters + ---------- + self + The test instance. + eos_data + Populate outputs of the test commands instead of collecting from devices. + This list must have the same length and order than the `instance_commands` instance attribute. + kwargs + Any keyword argument to pass to the test. Returns ------- - result: TestResult instance attribute populated with error status if any + TestResult + The TestResult instance attribute populated with error status if any. """ - - def format_td(seconds: float, digits: int = 3) -> str: - isec, fsec = divmod(round(seconds * 10**digits), 10**digits) - return f"{timedelta(seconds=isec)}.{fsec:0{digits}.0f}" - - start_time = time.time() if self.result.result != "unset": return self.result @@ -575,21 +647,18 @@ def format_td(seconds: float, digits: int = 3) -> str: if not self.collected: await self.collect() if self.result.result != "unset": + AntaTest.update_progress() return self.result - if cmds := self.failed_commands: - unsupported_commands = [f"'{c.command}' is not supported on {self.device.hw_model}" for c in cmds if not c.supported] - if unsupported_commands: - msg = f"Test {self.name} has been skipped because it is not supported on {self.device.hw_model}: {GITHUB_SUGGESTION}" - self.logger.warning(msg) - self.result.is_skipped("\n".join(unsupported_commands)) - return self.result - self.result.is_error(message="\n".join([f"{c.command} has failed: {', '.join(c.errors)}" for c in cmds])) + if self.failed_commands: + self._handle_failed_commands() + + AntaTest.update_progress() return self.result try: function(self, **kwargs) - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: # noqa: BLE001 # test() is user-defined code. # We need to catch everything if we want the AntaTest object # to live until the reporting @@ -597,15 +666,32 @@ def format_td(seconds: float, digits: int = 3) -> str: anta_log_exception(e, message, self.logger) self.result.is_error(message=exc_to_str(e)) - test_duration = time.time() - start_time - msg = f"Executing test {self.name} on device {self.device.name} took {format_td(test_duration)}" - self.logger.debug(msg) - + # TODO: find a correct way to time test execution AntaTest.update_progress() return self.result return wrapper + def _handle_failed_commands(self) -> None: + """Handle failed commands inside a test. + + There can be 3 types: + * unsupported on hardware commands which set the test status to 'skipped' + * known EOS error which set the test status to 'failure' + * unknown failure which set the test status to 'error' + """ + cmds = self.failed_commands + unsupported_commands = [f"'{c.command}' is not supported on {self.device.hw_model}" for c in cmds if not c.supported] + if unsupported_commands: + self.result.is_skipped("\n".join(unsupported_commands)) + return + returned_known_eos_error = [f"'{c.command}' failed on {self.device.name}: {', '.join(c.errors)}" for c in cmds if c.returned_known_eos_error] + if returned_known_eos_error: + self.result.is_failure("\n".join(returned_known_eos_error)) + return + + self.result.is_error(message="\n".join([f"{c.command} has failed: {', '.join(c.errors)}" for c in cmds])) + @classmethod def update_progress(cls: type[AntaTest]) -> None: """Update progress bar for all AntaTest objects if it exists.""" diff --git a/anta/py.typed b/anta/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/anta/reporter/__init__.py b/anta/reporter/__init__.py index 3e068f5bd..b6f061c10 100644 --- a/anta/reporter/__init__.py +++ b/anta/reporter/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Report management for ANTA.""" @@ -7,19 +7,20 @@ from __future__ import annotations import logging +from dataclasses import dataclass from typing import TYPE_CHECKING, Any from jinja2 import Template from rich.table import Table from anta import RICH_COLOR_PALETTE, RICH_COLOR_THEME +from anta.tools import convert_categories if TYPE_CHECKING: import pathlib - from anta.custom_types import TestStatus from anta.result_manager import ResultManager - from anta.result_manager.models import TestResult + from anta.result_manager.models import AntaTestStatus, TestResult logger = logging.getLogger(__name__) @@ -27,17 +28,33 @@ class ReportTable: """TableReport Generate a Table based on TestResult.""" + @dataclass + class Headers: + """Headers for the table report.""" + + device: str = "Device" + test_case: str = "Test Name" + number_of_success: str = "# of success" + number_of_failure: str = "# of failure" + number_of_skipped: str = "# of skipped" + number_of_errors: str = "# of errors" + list_of_error_nodes: str = "List of failed or error nodes" + list_of_error_tests: str = "List of failed or error test cases" + def _split_list_to_txt_list(self, usr_list: list[str], delimiter: str | None = None) -> str: """Split list to multi-lines string. - Args: - ---- - usr_list (list[str]): List of string to concatenate - delimiter (str, optional): A delimiter to use to start string. Defaults to None. + Parameters + ---------- + usr_list : list[str] + List of string to concatenate. + delimiter : str, optional + A delimiter to use to start string. Defaults to None. Returns ------- - str: Multi-lines string + str + Multi-lines string. """ if delimiter is not None: @@ -49,55 +66,58 @@ def _build_headers(self, headers: list[str], table: Table) -> Table: First key is considered as header and is colored using RICH_COLOR_PALETTE.HEADER - Args: - ---- - headers: List of headers. - table: A rich Table instance. + Parameters + ---------- + headers + List of headers. + table + A rich Table instance. Returns ------- + Table A rich `Table` instance with headers. """ for idx, header in enumerate(headers): if idx == 0: table.add_column(header, justify="left", style=RICH_COLOR_PALETTE.HEADER, no_wrap=True) - elif header == "Test Name": - # We always want the full test name - table.add_column(header, justify="left", no_wrap=True) else: table.add_column(header, justify="left") return table - def _color_result(self, status: TestStatus) -> str: - """Return a colored string based on the status value. + def _color_result(self, status: AntaTestStatus) -> str: + """Return a colored string based on an AntaTestStatus. - Args: - ---- - status (TestStatus): status value to color. + Parameters + ---------- + status + AntaTestStatus enum to color. Returns ------- - str: the colored string - + str + The colored string. """ - color = RICH_COLOR_THEME.get(status, "") + color = RICH_COLOR_THEME.get(str(status), "") return f"[{color}]{status}" if color != "" else str(status) def report_all(self, manager: ResultManager, title: str = "All tests results") -> Table: """Create a table report with all tests for one or all devices. - Create table with full output: Host / Test / Status / Message + Create table with full output: Device | Test Name | Test Status | Message(s) | Test description | Test category - Args: - ---- - manager: A ResultManager instance. - title: Title for the report. Defaults to 'All tests results'. + Parameters + ---------- + manager + A ResultManager instance. + title + Title for the report. Defaults to 'All tests results'. Returns ------- - A fully populated rich `Table` - + Table + A fully populated rich `Table`. """ table = Table(title=title, show_lines=True) headers = ["Device", "Test Name", "Test Status", "Message(s)", "Test description", "Test category"] @@ -106,7 +126,7 @@ def report_all(self, manager: ResultManager, title: str = "All tests results") - def add_line(result: TestResult) -> None: state = self._color_result(result.result) message = self._split_list_to_txt_list(result.messages) if len(result.messages) > 0 else "" - categories = ", ".join(result.categories) + categories = ", ".join(convert_categories(result.categories)) table.add_row(str(result.name), result.test, state, message, result.description, categories) for result in manager.results: @@ -121,43 +141,42 @@ def report_summary_tests( ) -> Table: """Create a table report with result aggregated per test. - Create table with full output: Test | Number of success | Number of failure | Number of error | List of nodes in error or failure + Create table with full output: + Test Name | # of success | # of skipped | # of failure | # of errors | List of failed or error nodes - Args: - ---- - manager: A ResultManager instance. - tests: List of test names to include. None to select all tests. - title: Title of the report. + Parameters + ---------- + manager + A ResultManager instance. + tests + List of test names to include. None to select all tests. + title + Title of the report. Returns ------- + Table A fully populated rich `Table`. """ table = Table(title=title, show_lines=True) headers = [ - "Test Case", - "# of success", - "# of skipped", - "# of failure", - "# of errors", - "List of failed or error nodes", + self.Headers.test_case, + self.Headers.number_of_success, + self.Headers.number_of_skipped, + self.Headers.number_of_failure, + self.Headers.number_of_errors, + self.Headers.list_of_error_nodes, ] table = self._build_headers(headers=headers, table=table) - for test in manager.get_tests(): + for test, stats in manager.test_stats.items(): if tests is None or test in tests: - results = manager.filter_by_tests({test}).results - nb_failure = len([result for result in results if result.result == "failure"]) - nb_error = len([result for result in results if result.result == "error"]) - list_failure = [result.name for result in results if result.result in ["failure", "error"]] - nb_success = len([result for result in results if result.result == "success"]) - nb_skipped = len([result for result in results if result.result == "skipped"]) table.add_row( test, - str(nb_success), - str(nb_skipped), - str(nb_failure), - str(nb_error), - str(list_failure), + str(stats.devices_success_count), + str(stats.devices_skipped_count), + str(stats.devices_failure_count), + str(stats.devices_error_count), + ", ".join(stats.devices_failure), ) return table @@ -169,43 +188,41 @@ def report_summary_devices( ) -> Table: """Create a table report with result aggregated per device. - Create table with full output: Host | Number of success | Number of failure | Number of error | List of nodes in error or failure + Create table with full output: Device | # of success | # of skipped | # of failure | # of errors | List of failed or error test cases - Args: - ---- - manager: A ResultManager instance. - devices: List of device names to include. None to select all devices. - title: Title of the report. + Parameters + ---------- + manager + A ResultManager instance. + devices + List of device names to include. None to select all devices. + title + Title of the report. Returns ------- + Table A fully populated rich `Table`. """ table = Table(title=title, show_lines=True) headers = [ - "Device", - "# of success", - "# of skipped", - "# of failure", - "# of errors", - "List of failed or error test cases", + self.Headers.device, + self.Headers.number_of_success, + self.Headers.number_of_skipped, + self.Headers.number_of_failure, + self.Headers.number_of_errors, + self.Headers.list_of_error_tests, ] table = self._build_headers(headers=headers, table=table) - for device in manager.get_devices(): + for device, stats in manager.device_stats.items(): if devices is None or device in devices: - results = manager.filter_by_devices({device}).results - nb_failure = len([result for result in results if result.result == "failure"]) - nb_error = len([result for result in results if result.result == "error"]) - list_failure = [result.test for result in results if result.result in ["failure", "error"]] - nb_success = len([result for result in results if result.result == "success"]) - nb_skipped = len([result for result in results if result.result == "skipped"]) table.add_row( device, - str(nb_success), - str(nb_skipped), - str(nb_failure), - str(nb_error), - str(list_failure), + str(stats.tests_success_count), + str(stats.tests_skipped_count), + str(stats.tests_failure_count), + str(stats.tests_error_count), + ", ".join(stats.tests_failure), ) return table @@ -215,18 +232,21 @@ class ReportJinja: def __init__(self, template_path: pathlib.Path) -> None: """Create a ReportJinja instance.""" - if template_path.is_file(): - self.tempalte_path = template_path - else: + if not template_path.is_file(): msg = f"template file is not found: {template_path}" raise FileNotFoundError(msg) + self.template_path = template_path + def render(self, data: list[dict[str, Any]], *, trim_blocks: bool = True, lstrip_blocks: bool = True) -> str: """Build a report based on a Jinja2 template. Report is built based on a J2 template provided by user. Data structure sent to template is: + Example + ------- + ``` >>> print(ResultManager.json) [ { @@ -238,19 +258,24 @@ def render(self, data: list[dict[str, Any]], *, trim_blocks: bool = True, lstrip description: ..., } ] + ``` - Args: - ---- - data: List of results from ResultManager.results - trim_blocks: enable trim_blocks for J2 rendering. - lstrip_blocks: enable lstrip_blocks for J2 rendering. + Parameters + ---------- + data + List of results from `ResultManager.results`. + trim_blocks + enable trim_blocks for J2 rendering. + lstrip_blocks + enable lstrip_blocks for J2 rendering. Returns ------- + str Rendered template """ - with self.tempalte_path.open(encoding="utf-8") as file_: + with self.template_path.open(encoding="utf-8") as file_: template = Template(file_.read(), trim_blocks=trim_blocks, lstrip_blocks=lstrip_blocks) return template.render({"data": data}) diff --git a/anta/reporter/csv_reporter.py b/anta/reporter/csv_reporter.py new file mode 100644 index 000000000..2a0a4de2c --- /dev/null +++ b/anta/reporter/csv_reporter.py @@ -0,0 +1,123 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""CSV Report management for ANTA.""" + +# pylint: disable = too-few-public-methods +from __future__ import annotations + +import csv +import logging +import os +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from anta.logger import anta_log_exception +from anta.tools import convert_categories + +if TYPE_CHECKING: + import pathlib + + from anta.result_manager import ResultManager + from anta.result_manager.models import TestResult + +logger = logging.getLogger(__name__) + + +class ReportCsv: + """Build a CSV report.""" + + @dataclass() + class Headers: + """Headers for the CSV report.""" + + device: str = "Device" + test_name: str = "Test Name" + test_status: str = "Test Status" + messages: str = "Message(s)" + description: str = "Test description" + categories: str = "Test category" + + @classmethod + def split_list_to_txt_list(cls, usr_list: list[str], delimiter: str = " - ") -> str: + """Split list to multi-lines string. + + Parameters + ---------- + usr_list + List of string to concatenate. + delimiter + A delimiter to use to start string. Defaults to None. + + Returns + ------- + str + Multi-lines string. + + """ + return f"{delimiter}".join(f"{line}" for line in usr_list) + + @classmethod + def convert_to_list(cls, result: TestResult) -> list[str]: + """Convert a TestResult into a list of string for creating file content. + + Parameters + ---------- + result + A TestResult to convert into list. + + Returns + ------- + list[str] + TestResult converted into a list. + """ + message = cls.split_list_to_txt_list(result.messages) if len(result.messages) > 0 else "" + categories = cls.split_list_to_txt_list(convert_categories(result.categories)) if len(result.categories) > 0 else "None" + return [ + str(result.name), + result.test, + result.result, + message, + result.description, + categories, + ] + + @classmethod + def generate(cls, results: ResultManager, csv_filename: pathlib.Path) -> None: + """Build CSV flle with tests results. + + Parameters + ---------- + results + A ResultManager instance. + csv_filename + File path where to save CSV data. + + Raises + ------ + OSError + if any is raised while writing the CSV file. + """ + headers = [ + cls.Headers.device, + cls.Headers.test_name, + cls.Headers.test_status, + cls.Headers.messages, + cls.Headers.description, + cls.Headers.categories, + ] + + try: + with csv_filename.open(mode="w", encoding="utf-8", newline="") as csvfile: + csvwriter = csv.writer( + csvfile, + delimiter=",", + lineterminator=os.linesep, + ) + csvwriter.writerow(headers) + for entry in results.results: + csvwriter.writerow(cls.convert_to_list(entry)) + except OSError as exc: + message = f"OSError caught while writing the CSV file '{csv_filename.resolve()}'." + anta_log_exception(exc, message, logger) + raise diff --git a/anta/reporter/md_reporter.py b/anta/reporter/md_reporter.py new file mode 100644 index 000000000..1625e6992 --- /dev/null +++ b/anta/reporter/md_reporter.py @@ -0,0 +1,484 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Markdown report generator for ANTA test results.""" + +from __future__ import annotations + +import logging +import re +from abc import ABC, abstractmethod +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, ClassVar, TextIO + +from anta.constants import ACRONYM_CATEGORIES, MD_REPORT_TOC, MD_REPORT_TOC_WITH_RUN_OVERVIEW +from anta.logger import anta_log_exception +from anta.result_manager.models import AntaTestStatus +from anta.tools import convert_categories + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + + from anta.result_manager import ResultManager + + +logger = logging.getLogger(__name__) + + +class MDReportBase(ABC): + """Base class for all sections subclasses. + + Every subclasses must implement the `generate_section` method that uses the `ResultManager` object + to generate and write content to the provided markdown file. + """ + + def __init__(self, mdfile: TextIO, results: ResultManager, extra_data: dict[str, Any] | None = None) -> None: + """Initialize the MDReportBase with an open markdown file object to write to and a ResultManager instance. + + Parameters + ---------- + mdfile + An open file object to write the markdown data into. + results + The ResultsManager instance containing all test results. + extra_data + Optional extra data dictionary. Can be used by subclasses to render additional data. + """ + self.mdfile = mdfile + self.results = results + self.extra_data = extra_data + + @abstractmethod + def generate_section(self) -> None: + """Abstract method to generate a specific section of the markdown report. + + Must be implemented by subclasses. + """ + msg = "Must be implemented by subclasses" + raise NotImplementedError(msg) + + def generate_rows(self) -> Generator[str, None, None]: + """Generate the rows of a markdown table for a specific report section. + + Subclasses can implement this method to generate the content of the table rows. + """ + msg = "Subclasses should implement this method" + raise NotImplementedError(msg) + + def generate_heading_name(self) -> str: + """Generate a formatted heading name based on the class name. + + Returns + ------- + str + Formatted header name. + + Example + ------- + - `ANTAReport` will become `ANTA Report`. + - `TestResultsSummary` will become `Test Results Summary`. + """ + class_name = self.__class__.__name__ + + # Split the class name into words, keeping acronyms together + words = re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|\d|\W|$)|\d+", class_name) + + # Capitalize each word, but keep acronyms in all caps + formatted_words = [word if word.isupper() else word.capitalize() for word in words] + + return " ".join(formatted_words) + + def write_table(self, table_heading: list[str], *, last_table: bool = False) -> None: + """Write a markdown table with a table heading and multiple rows to the markdown file. + + Parameters + ---------- + table_heading + List of strings to join for the table heading. + last_table + Flag to determine if it's the last table of the markdown file to avoid unnecessary new line. Defaults to False. + """ + self.mdfile.write("\n".join(table_heading) + "\n") + for row in self.generate_rows(): + self.mdfile.write(row) + if not last_table: + self.mdfile.write("\n") + + def write_heading(self, heading_level: int) -> None: + """Write a markdown heading to the markdown file. + + The heading name used is the class name. + + Parameters + ---------- + heading_level + The level of the heading (1-6). + + Example + ------- + `## Test Results Summary` + """ + # Ensure the heading level is within the valid range of 1 to 6 + heading_level = max(1, min(heading_level, 6)) + heading_name = self.generate_heading_name() + heading = "#" * heading_level + " " + heading_name + self.mdfile.write(f"{heading}\n\n") + + def safe_markdown(self, text: str | None) -> str: + """Escape markdown characters in the text to prevent markdown rendering issues. + + Parameters + ---------- + text + The text to escape markdown characters from. + + Returns + ------- + str + The text with escaped markdown characters. + """ + # Custom field from a TestResult object can be None + if text is None: + return "" + + # Replace newlines with
to preserve line breaks in HTML + return text.replace("\n", "
") + + def format_snake_case_to_title_case(self, value: str) -> str: + """Format a snake_case string to a Title Cased string with spaces, handling known network protocol or feature acronyms. + + Parameters + ---------- + value + A string value to be formatted. + + Returns + ------- + str + The value formatted in Title Cased. + + Example + ------- + - "hello_world" becomes "Hello World" + - "anta_version" becomes "ANTA Version" + """ + if not value: + return "" + + parts = value.split("_") + processed_parts = [] + for part in parts: + if part.lower() in ACRONYM_CATEGORIES: + processed_parts.append(part.upper()) + else: + processed_parts.append(part.capitalize()) + + return " ".join(processed_parts) + + # Value could be anything + def format_value(self, value: Any) -> str: # noqa: ANN401 + """Format different types of values for display in the report. + + Handles datetime, timedelta, lists, and other types by converting them to + human-readable string representations. + + Handles only positive timedelta values. + + Parameters + ---------- + value + A value of any type to be formatted. + + Returns + ------- + str + The value formatted to a human-readable string. + + Example + ------- + - datetime.now() becomes "YYYY-MM-DD HH:MM:SS.milliseconds" + - timedelta(hours=1, minutes=5, seconds=30) becomes "1 hour, 5 minutes, 30 seconds" + - ["item1", "item2"] becomes "item1, item2" + - 123 becomes "123" + """ + if isinstance(value, datetime): + return value.isoformat(sep=" ", timespec="milliseconds") + + if isinstance(value, timedelta): + return self.format_timedelta(value) + + if isinstance(value, list): + return ", ".join(str(v_item) for v_item in value) + + return str(value) + + def format_timedelta(self, value: timedelta) -> str: + """Format a timedelta object into a human-readable string. + + Handles positive timedelta values. Milliseconds are shown only + if they are the sole component of a duration less than 1 second. + Does not format "days"; 2 days will return 48 hours. + + Parameters + ---------- + value + The timedelta object to be formatted. + + Returns + ------- + str + The timedelta object formatted to a human-readable string. + """ + total_seconds = int(value.total_seconds()) + + if total_seconds < 0: + return "Invalid duration" + + if total_seconds == 0 and value.microseconds == 0: + return "0 seconds" + + parts = [] + + hours = total_seconds // 3600 + if hours > 0: + parts.append(f"{hours} hour{'s' if hours != 1 else ''}") + minutes = (total_seconds % 3600) // 60 + if minutes > 0: + parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") + seconds = total_seconds % 60 + if seconds > 0: + parts.append(f"{seconds} second{'s' if seconds != 1 else ''}") + milliseconds = value.microseconds // 1000 + if milliseconds > 0 and not parts and total_seconds == 0: + parts.append(f"{milliseconds} millisecond{'s' if milliseconds != 1 else ''}") + + return ", ".join(parts) if parts else "0 seconds" + + +class ANTAReport(MDReportBase): + """Generate the `# ANTA Report` section of the markdown report.""" + + def generate_section(self) -> None: + """Generate the `# ANTA Report` section of the markdown report.""" + self.write_heading(heading_level=1) + toc = MD_REPORT_TOC_WITH_RUN_OVERVIEW if self.extra_data else MD_REPORT_TOC + self.mdfile.write(toc + "\n\n") + + +class RunOverview(MDReportBase): + """Generate the `## Run Overview` section of the markdown report. + + The `extra_data` dictionary containing the desired run information + must be provided to the initializer to generate this section. + """ + + def generate_section(self) -> None: + """Generate the `## Run Overview` section of the markdown report.""" + if not self.extra_data: + return + + md_lines = [] + for key, value in self.extra_data.items(): + label = self.format_snake_case_to_title_case(key) + item_prefix = f"- **{label}:**" + placeholder_for_none = "None" + + if isinstance(value, list): + if not value: + md_lines.append(f"{item_prefix} {placeholder_for_none}") + else: + md_lines.append(item_prefix) + md_lines.extend([f" - {item!s}" for item in value]) + elif isinstance(value, dict): + if not value: + md_lines.append(f"{item_prefix} {placeholder_for_none}") + else: + md_lines.append(item_prefix) + for k, v_list_or_scalar in value.items(): + sub_label = self.format_snake_case_to_title_case(k) + sub_value_str = self.format_value(v_list_or_scalar) + md_lines.append(f" - {sub_label}: {sub_value_str}") + # Scalar values + else: + formatted_value = self.format_value(value) + md_lines.append(f"{item_prefix} {formatted_value}") + + self.write_heading(heading_level=2) + self.mdfile.write("\n".join(md_lines)) + self.mdfile.write("\n\n") + + +class TestResultsSummary(MDReportBase): + """Generate the `## Test Results Summary` section of the markdown report.""" + + def generate_section(self) -> None: + """Generate the `## Test Results Summary` section of the markdown report.""" + self.write_heading(heading_level=2) + + +class SummaryTotals(MDReportBase): + """Generate the `### Summary Totals` section of the markdown report.""" + + TABLE_HEADING: ClassVar[list[str]] = [ + "| Total Tests | Total Tests Success | Total Tests Skipped | Total Tests Failure | Total Tests Error |", + "| ----------- | ------------------- | ------------------- | ------------------- | ------------------|", + ] + + def generate_rows(self) -> Generator[str, None, None]: + """Generate the rows of the summary totals table.""" + yield ( + f"| {self.results.get_total_results()} " + f"| {self.results.get_total_results({AntaTestStatus.SUCCESS})} " + f"| {self.results.get_total_results({AntaTestStatus.SKIPPED})} " + f"| {self.results.get_total_results({AntaTestStatus.FAILURE})} " + f"| {self.results.get_total_results({AntaTestStatus.ERROR})} |\n" + ) + + def generate_section(self) -> None: + """Generate the `### Summary Totals` section of the markdown report.""" + self.write_heading(heading_level=3) + self.write_table(table_heading=self.TABLE_HEADING) + + +class SummaryTotalsDeviceUnderTest(MDReportBase): + """Generate the `### Summary Totals Devices Under Tests` section of the markdown report.""" + + TABLE_HEADING: ClassVar[list[str]] = [ + "| Device Under Test | Total Tests | Tests Success | Tests Skipped | Tests Failure | Tests Error | Categories Skipped | Categories Failed |", + "| ------------------| ----------- | ------------- | ------------- | ------------- | ----------- | -------------------| ------------------|", + ] + + def generate_rows(self) -> Generator[str, None, None]: + """Generate the rows of the summary totals device under test table.""" + for device, stat in self.results.device_stats.items(): + total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count + stat.tests_unset_count + categories_skipped = ", ".join(sorted(convert_categories(list(stat.categories_skipped)))) + categories_failed = ", ".join(sorted(convert_categories(list(stat.categories_failed)))) + yield ( + f"| {device} | {total_tests} | {stat.tests_success_count} | {stat.tests_skipped_count} | {stat.tests_failure_count} | {stat.tests_error_count} " + f"| {categories_skipped or '-'} | {categories_failed or '-'} |\n" + ) + + def generate_section(self) -> None: + """Generate the `### Summary Totals Devices Under Tests` section of the markdown report.""" + self.write_heading(heading_level=3) + self.write_table(table_heading=self.TABLE_HEADING) + + +class SummaryTotalsPerCategory(MDReportBase): + """Generate the `### Summary Totals Per Category` section of the markdown report.""" + + TABLE_HEADING: ClassVar[list[str]] = [ + "| Test Category | Total Tests | Tests Success | Tests Skipped | Tests Failure | Tests Error |", + "| ------------- | ----------- | ------------- | ------------- | ------------- | ----------- |", + ] + + def generate_rows(self) -> Generator[str, None, None]: + """Generate the rows of the summary totals per category table.""" + for category, stat in self.results.category_stats.items(): + converted_category = convert_categories([category])[0] + total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count + stat.tests_unset_count + yield ( + f"| {converted_category} | {total_tests} | {stat.tests_success_count} | {stat.tests_skipped_count} | {stat.tests_failure_count} " + f"| {stat.tests_error_count} |\n" + ) + + def generate_section(self) -> None: + """Generate the `### Summary Totals Per Category` section of the markdown report.""" + self.write_heading(heading_level=3) + self.write_table(table_heading=self.TABLE_HEADING) + + +class TestResults(MDReportBase): + """Generates the `## Test Results` section of the markdown report.""" + + TABLE_HEADING: ClassVar[list[str]] = [ + "| Device Under Test | Categories | Test | Description | Custom Field | Result | Messages |", + "| ----------------- | ---------- | ---- | ----------- | ------------ | ------ | -------- |", + ] + + def generate_rows(self) -> Generator[str, None, None]: + """Generate the rows of the all test results table.""" + for result in self.results.results: + messages = self.safe_markdown(result.messages[0]) if len(result.messages) == 1 else self.safe_markdown("
".join(result.messages)) + categories = ", ".join(sorted(convert_categories(result.categories))) + yield ( + f"| {result.name or '-'} | {categories or '-'} | {result.test or '-'} " + f"| {result.description or '-'} | {self.safe_markdown(result.custom_field) or '-'} | {result.result or '-'} | {messages or '-'} |\n" + ) + + def generate_section(self) -> None: + """Generate the `## Test Results` section of the markdown report.""" + self.write_heading(heading_level=2) + self.write_table(table_heading=self.TABLE_HEADING, last_table=True) + + +# pylint: disable=too-few-public-methods +class MDReportGenerator: + """Class responsible for generating a Markdown report based on the provided `ResultManager` object. + + It aggregates different report sections, each represented by a subclass of `MDReportBase`, + and sequentially generates their content into a markdown file. + + This class provides two methods for generating the report: + + - `generate`: Uses a single result manager instance to generate all sections defined in the `DEFAULT_SECTIONS` class variable list. + + - `generate_sections`: A custom list of sections is provided. Each section uses its own dedicated result manager instance, + allowing greater flexibility or isolation between section generations. + """ + + DEFAULT_SECTIONS: ClassVar[list[type[MDReportBase]]] = [ + ANTAReport, + RunOverview, + TestResultsSummary, + SummaryTotals, + SummaryTotalsDeviceUnderTest, + SummaryTotalsPerCategory, + TestResults, + ] + + @classmethod + def generate(cls, results: ResultManager, md_filename: Path, extra_data: dict[str, Any] | None = None) -> None: + """Generate the sections of the markdown report defined in DEFAULT_SECTIONS using a single result manager instance for all sections. + + Parameters + ---------- + results + The ResultsManager instance containing all test results. + md_filename + The path to the markdown file to write the report into. + extra_data + Optional extra data dictionary that can be used by the section generators to render additional data. + """ + try: + with md_filename.open("w", encoding="utf-8") as mdfile: + for section in cls.DEFAULT_SECTIONS: + section(mdfile, results, extra_data).generate_section() + except OSError as exc: + message = f"OSError caught while writing the Markdown file '{md_filename.resolve()}'." + anta_log_exception(exc, message, logger) + raise + + @classmethod + def generate_sections(cls, sections: list[tuple[type[MDReportBase], ResultManager]], md_filename: Path, extra_data: dict[str, Any] | None = None) -> None: + """Generate the different sections of the markdown report provided in the sections argument with each section using its own result manager instance. + + Parameters + ---------- + sections + A list of tuples, where each tuple contains a subclass of `MDReportBase` and an instance of `ResultManager`. + md_filename + The path to the markdown file to write the report into. + extra_data + Optional extra data dictionary that can be used by the section generators to render additional data. + """ + try: + with md_filename.open("w", encoding="utf-8") as md_file: + for section, rm in sections: + section(md_file, rm, extra_data).generate_section() + except OSError as exc: + message = f"OSError caught while writing the Markdown file '{md_filename.resolve()}'." + anta_log_exception(exc, message, logger) + raise diff --git a/anta/result_manager/__init__.py b/anta/result_manager/__init__.py index 9e1f6ae22..32e9da486 100644 --- a/anta/result_manager/__init__.py +++ b/anta/result_manager/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Result Manager module for ANTA.""" @@ -6,88 +6,80 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING +import logging +from collections import defaultdict +from functools import cached_property +from itertools import chain +from typing import Any -from pydantic import TypeAdapter +from typing_extensions import deprecated -from anta.custom_types import TestStatus +from anta.result_manager.models import AntaTestStatus, TestResult -if TYPE_CHECKING: - from anta.result_manager.models import TestResult +from .models import CategoryStats, DeviceStats, TestStats +logger = logging.getLogger(__name__) -class ResultManager: - """Helper to manage Test Results and generate reports. - - Examples - -------- - Create Inventory: - - inventory_anta = AntaInventory.parse( - filename='examples/inventory.yml', - username='ansible', - password='ansible', - ) - - Create Result Manager: - - manager = ResultManager() - - Run tests for all connected devices: - - for device in inventory_anta.get_inventory().devices: - manager.add( - VerifyNTP(device=device).test() - ) - manager.add( - VerifyEOSVersion(device=device).test(version='4.28.3M') - ) - - Print result in native format: - - manager.results - [ - TestResult( - host=IPv4Address('192.168.0.10'), - test='VerifyNTP', - result='failure', - message="device is not running NTP correctly" - ), - TestResult( - host=IPv4Address('192.168.0.10'), - test='VerifyEOSVersion', - result='success', - message=None - ), - ] +class ResultManager: + """Manager of ANTA Results. + + The status of the class is initialized to "unset" + + Then when adding a test with a status that is NOT 'error' the following + table shows the updated status: + + | Current Status | Added test Status | Updated Status | + | -------------- | ------------------------------- | -------------- | + | unset | Any | Any | + | skipped | unset, skipped | skipped | + | skipped | success | success | + | skipped | failure | failure | + | success | unset, skipped, success | success | + | success | failure | failure | + | failure | unset, skipped success, failure | failure | + + If the status of the added test is error, the status is untouched and the + `error_status` attribute is set to True. + + Attributes + ---------- + results + dump + status + Status rerpesenting all the results. + error_status + Will be `True` if a test returned an error. + results_by_status + dump + json + device_stats + category_stats + test_stats """ - def __init__(self) -> None: - """Class constructor. - - The status of the class is initialized to "unset" + _result_entries: list[TestResult] + status: AntaTestStatus + error_status: bool - Then when adding a test with a status that is NOT 'error' the following - table shows the updated status: + _device_stats: defaultdict[str, DeviceStats] + _category_stats: defaultdict[str, CategoryStats] + _test_stats: defaultdict[str, TestStats] + _stats_in_sync: bool - | Current Status | Added test Status | Updated Status | - | -------------- | ------------------------------- | -------------- | - | unset | Any | Any | - | skipped | unset, skipped | skipped | - | skipped | success | success | - | skipped | failure | failure | - | success | unset, skipped, success | success | - | success | failure | failure | - | failure | unset, skipped success, failure | failure | + def __init__(self) -> None: + """Initialize a ResultManager instance.""" + self.reset() - If the status of the added test is error, the status is untouched and the - error_status is set to True. - """ + def reset(self) -> None: + """Create or reset the attributes of the ResultManager instance.""" self._result_entries: list[TestResult] = [] - self.status: TestStatus = "unset" + self.status: AntaTestStatus = AntaTestStatus.UNSET self.error_status = False + # Initialize the statistics attributes + self._reset_stats() + def __len__(self) -> int: """Implement __len__ method to count number of results.""" return len(self._result_entries) @@ -99,102 +91,303 @@ def results(self) -> list[TestResult]: @results.setter def results(self, value: list[TestResult]) -> None: - self._result_entries = [] - self.status = "unset" - self.error_status = False - for e in value: - self.add(e) + """Set the list of TestResult.""" + # When setting the results, we need to reset the state of the current instance + self.reset() + + for result in value: + self.add(result) + + @property + def dump(self) -> list[dict[str, Any]]: + """Get a list of dictionary of the results.""" + return [result.model_dump() for result in self._result_entries] @property def json(self) -> str: """Get a JSON representation of the results.""" - return json.dumps([result.model_dump() for result in self._result_entries], indent=4) + return json.dumps(self.dump, indent=4) + + @property + def device_stats(self) -> dict[str, DeviceStats]: + """Get the device statistics.""" + self._ensure_stats_in_sync() + return dict(sorted(self._device_stats.items())) + + @property + def category_stats(self) -> dict[str, CategoryStats]: + """Get the category statistics.""" + self._ensure_stats_in_sync() + return dict(sorted(self._category_stats.items())) + + @property + def test_stats(self) -> dict[str, TestStats]: + """Get the test statistics.""" + self._ensure_stats_in_sync() + return dict(sorted(self._test_stats.items())) + + @property + @deprecated("This property is deprecated, use `category_stats` instead. This will be removed in ANTA v2.0.0.", category=DeprecationWarning) + def sorted_category_stats(self) -> dict[str, CategoryStats]: + """A property that returns the category_stats dictionary sorted by key name.""" + return self.category_stats + + @cached_property + def results_by_status(self) -> dict[AntaTestStatus, list[TestResult]]: + """A cached property that returns the results grouped by status.""" + return {status: [result for result in self._result_entries if result.result == status] for status in AntaTestStatus} + + def _update_status(self, test_status: AntaTestStatus) -> None: + """Update the status of the ResultManager instance based on the test status. + + Parameters + ---------- + test_status + AntaTestStatus to update the ResultManager status. + """ + if test_status == "error": + self.error_status = True + return + if self.status == "unset" or (self.status == "skipped" and test_status in {"success", "failure"}): + self.status = test_status + elif self.status == "success" and test_status == "failure": + self.status = AntaTestStatus.FAILURE + + def _reset_stats(self) -> None: + """Create or reset the statistics attributes.""" + self._device_stats = defaultdict(DeviceStats) + self._category_stats = defaultdict(CategoryStats) + self._test_stats = defaultdict(TestStats) + self._stats_in_sync = False + + def _update_stats(self, result: TestResult) -> None: + """Update the statistics based on the test result. + + Parameters + ---------- + result + TestResult to update the statistics. + """ + count_attr = f"tests_{result.result}_count" + + # Update device stats + device_stats: DeviceStats = self._device_stats[result.name] + setattr(device_stats, count_attr, getattr(device_stats, count_attr) + 1) + if result.result in ("failure", "error"): + device_stats.tests_failure.add(result.test) + device_stats.categories_failed.update(result.categories) + elif result.result == "skipped": + device_stats.categories_skipped.update(result.categories) + + # Update category stats + for category in result.categories: + category_stats: CategoryStats = self._category_stats[category] + setattr(category_stats, count_attr, getattr(category_stats, count_attr) + 1) + + # Update test stats + count_attr = f"devices_{result.result}_count" + test_stats: TestStats = self._test_stats[result.test] + setattr(test_stats, count_attr, getattr(test_stats, count_attr) + 1) + if result.result in ("failure", "error"): + test_stats.devices_failure.add(result.name) + + def _compute_stats(self) -> None: + """Compute all statistics from the current results.""" + logger.info("Computing statistics for all results.") + + # Reset all stats + self._reset_stats() + + # Recompute stats for all results + for result in self._result_entries: + self._update_stats(result) + + self._stats_in_sync = True + + def _ensure_stats_in_sync(self) -> None: + """Ensure statistics are in sync with current results.""" + if not self._stats_in_sync: + self._compute_stats() def add(self, result: TestResult) -> None: """Add a result to the ResultManager instance. - Args: - ---- - result: TestResult to add to the ResultManager instance. + The result is added to the internal list of results and the overall status + of the ResultManager instance is updated based on the added test status. + + Parameters + ---------- + result + TestResult to add to the ResultManager instance. """ + self._result_entries.append(result) + self._update_status(result.result) + self._stats_in_sync = False - def _update_status(test_status: TestStatus) -> None: - result_validator = TypeAdapter(TestStatus) - result_validator.validate_python(test_status) - if test_status == "error": - self.error_status = True - return - if self.status == "unset" or self.status == "skipped" and test_status in {"success", "failure"}: - self.status = test_status - elif self.status == "success" and test_status == "failure": - self.status = "failure" + # Every time a new result is added, we need to clear the cached property + self.__dict__.pop("results_by_status", None) - self._result_entries.append(result) - _update_status(result.result) + def get_results(self, status: set[AntaTestStatus] | None = None, sort_by: list[str] | None = None) -> list[TestResult]: + """Get the results, optionally filtered by status and sorted by TestResult fields. + + If no status is provided, all results are returned. + + Parameters + ---------- + status + Optional set of AntaTestStatus enum members to filter the results. + sort_by + Optional list of TestResult fields to sort the results. + + Returns + ------- + list[TestResult] + List of results. + """ + # Return all results if no status is provided, otherwise return results for multiple statuses + results = self._result_entries if status is None else list(chain.from_iterable(self.results_by_status.get(status, []) for status in status)) + + if sort_by: + accepted_fields = TestResult.model_fields.keys() + if not set(sort_by).issubset(set(accepted_fields)): + msg = f"Invalid sort_by fields: {sort_by}. Accepted fields are: {list(accepted_fields)}" + raise ValueError(msg) + results = sorted(results, key=lambda result: [getattr(result, field) or "" for field in sort_by]) + + return results + + def get_total_results(self, status: set[AntaTestStatus] | None = None) -> int: + """Get the total number of results, optionally filtered by status. + + If no status is provided, the total number of results is returned. + + Parameters + ---------- + status + Optional set of AntaTestStatus enum members to filter the results. + + Returns + ------- + int + Total number of results. + """ + if status is None: + # Return the total number of results + return sum(len(results) for results in self.results_by_status.values()) + + # Return the total number of results for multiple statuses + return sum(len(self.results_by_status.get(status, [])) for status in status) def get_status(self, *, ignore_error: bool = False) -> str: """Return the current status including error_status if ignore_error is False.""" return "error" if self.error_status and not ignore_error else self.status - def filter(self, hide: set[TestStatus]) -> ResultManager: + def sort(self, sort_by: list[str]) -> ResultManager: + """Sort the ResultManager results based on TestResult fields. + + Parameters + ---------- + sort_by + List of TestResult fields to sort the results. + """ + accepted_fields = TestResult.model_fields.keys() + if not set(sort_by).issubset(set(accepted_fields)): + msg = f"Invalid sort_by fields: {sort_by}. Accepted fields are: {list(accepted_fields)}" + raise ValueError(msg) + self._result_entries.sort(key=lambda result: [getattr(result, field) or "" for field in sort_by]) + return self + + def filter(self, hide: set[AntaTestStatus]) -> ResultManager: """Get a filtered ResultManager based on test status. - Args: - ---- - hide: set of TestStatus literals to select tests to hide based on their status. + Parameters + ---------- + hide + Set of AntaTestStatus enum members to select tests to hide based on their status. Returns ------- + ResultManager A filtered `ResultManager`. """ + possible_statuses = set(AntaTestStatus) manager = ResultManager() - manager.results = [test for test in self._result_entries if test.result not in hide] + manager.results = self.get_results(possible_statuses - hide) return manager + @classmethod + def merge_results(cls, results_managers: list[ResultManager]) -> ResultManager: + """Merge multiple ResultManager instances. + + Parameters + ---------- + results_managers + A list of ResultManager instances to merge. + + Returns + ------- + ResultManager + A new ResultManager instance containing the results of all the input ResultManagers. + """ + combined_results = list(chain(*(rm.results for rm in results_managers))) + merged_manager = cls() + merged_manager.results = combined_results + return merged_manager + + @deprecated("This method is deprecated. This will be removed in ANTA v2.0.0.", category=DeprecationWarning) def filter_by_tests(self, tests: set[str]) -> ResultManager: """Get a filtered ResultManager that only contains specific tests. - Args: - ---- - tests: Set of test names to filter the results. + Parameters + ---------- + tests + Set of test names to filter the results. Returns ------- + ResultManager A filtered `ResultManager`. """ manager = ResultManager() manager.results = [result for result in self._result_entries if result.test in tests] return manager + @deprecated("This method is deprecated. This will be removed in ANTA v2.0.0.", category=DeprecationWarning) def filter_by_devices(self, devices: set[str]) -> ResultManager: """Get a filtered ResultManager that only contains specific devices. - Args: - ---- - devices: Set of device names to filter the results. + Parameters + ---------- + devices + Set of device names to filter the results. Returns ------- + ResultManager A filtered `ResultManager`. """ manager = ResultManager() manager.results = [result for result in self._result_entries if result.name in devices] return manager + @deprecated("This method is deprecated. This will be removed in ANTA v2.0.0.", category=DeprecationWarning) def get_tests(self) -> set[str]: """Get the set of all the test names. Returns ------- + set[str] Set of test names. """ return {str(result.test) for result in self._result_entries} + @deprecated("This method is deprecated. This will be removed in ANTA v2.0.0.", category=DeprecationWarning) def get_devices(self) -> set[str]: """Get the set of all the device names. Returns ------- + set[str] Set of device names. """ return {str(result.name) for result in self._result_entries} diff --git a/anta/result_manager/models.py b/anta/result_manager/models.py index c53947ee4..a19c969de 100644 --- a/anta/result_manager/models.py +++ b/anta/result_manager/models.py @@ -1,13 +1,31 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Models related to anta.result_manager module.""" from __future__ import annotations +from dataclasses import dataclass, field +from enum import Enum + from pydantic import BaseModel -from anta.custom_types import TestStatus + +class AntaTestStatus(str, Enum): + """Test status Enum for the TestResult. + + NOTE: This could be updated to StrEnum when Python 3.11 is the minimum supported version in ANTA. + """ + + UNSET = "unset" + SUCCESS = "success" + FAILURE = "failure" + ERROR = "error" + SKIPPED = "skipped" + + def __str__(self) -> str: + """Override the __str__ method to return the value of the Enum, mimicking the behavior of StrEnum.""" + return self.value class TestResult(BaseModel): @@ -15,13 +33,20 @@ class TestResult(BaseModel): Attributes ---------- - name: Device name where the test has run. - test: Test name runs on the device. - categories: List of categories the TestResult belongs to, by default the AntaTest categories. - description: TestResult description, by default the AntaTest description. - result: Result of the test. Can be one of "unset", "success", "failure", "error" or "skipped". - messages: Message to report after the test if any. - custom_field: Custom field to store a string for flexibility in integrating with ANTA + name : str + Name of the device where the test was run. + test : str + Name of the test run on the device. + categories : list[str] + List of categories the TestResult belongs to. Defaults to the AntaTest categories. + description : str + Description of the TestResult. Defaults to the AntaTest description. + result : AntaTestStatus + Result of the test. Must be one of the AntaTestStatus Enum values: unset, success, failure, error or skipped. + messages : list[str] + Messages to report after the test, if any. + custom_field : str | None + Custom field to store a string for flexibility in integrating with ANTA. """ @@ -29,57 +54,63 @@ class TestResult(BaseModel): test: str categories: list[str] description: str - result: TestStatus = "unset" + result: AntaTestStatus = AntaTestStatus.UNSET messages: list[str] = [] custom_field: str | None = None def is_success(self, message: str | None = None) -> None: """Set status to success. - Args: - ---- - message: Optional message related to the test + Parameters + ---------- + message + Optional message related to the test. """ - self._set_status("success", message) + self._set_status(AntaTestStatus.SUCCESS, message) def is_failure(self, message: str | None = None) -> None: """Set status to failure. - Args: - ---- - message: Optional message related to the test + Parameters + ---------- + message + Optional message related to the test. """ - self._set_status("failure", message) + self._set_status(AntaTestStatus.FAILURE, message) def is_skipped(self, message: str | None = None) -> None: """Set status to skipped. - Args: - ---- - message: Optional message related to the test + Parameters + ---------- + message + Optional message related to the test. """ - self._set_status("skipped", message) + self._set_status(AntaTestStatus.SKIPPED, message) def is_error(self, message: str | None = None) -> None: """Set status to error. - Args: - ---- - message: Optional message related to the test + Parameters + ---------- + message + Optional message related to the test. """ - self._set_status("error", message) + self._set_status(AntaTestStatus.ERROR, message) - def _set_status(self, status: TestStatus, message: str | None = None) -> None: + def _set_status(self, status: AntaTestStatus, message: str | None = None) -> None: """Set status and insert optional message. - Args: - ---- - status: status of the test - message: optional message + Parameters + ---------- + status + Status of the test. + message + Optional message. """ self.result = status @@ -89,3 +120,40 @@ def _set_status(self, status: TestStatus, message: str | None = None) -> None: def __str__(self) -> str: """Return a human readable string of this TestResult.""" return f"Test '{self.test}' (on '{self.name}'): Result '{self.result}'\nMessages: {self.messages}" + + +@dataclass +class DeviceStats: + """Device statistics for a run of tests.""" + + tests_success_count: int = 0 + tests_skipped_count: int = 0 + tests_failure_count: int = 0 + tests_error_count: int = 0 + tests_unset_count: int = 0 + tests_failure: set[str] = field(default_factory=set) + categories_failed: set[str] = field(default_factory=set) + categories_skipped: set[str] = field(default_factory=set) + + +@dataclass +class CategoryStats: + """Category statistics for a run of tests.""" + + tests_success_count: int = 0 + tests_skipped_count: int = 0 + tests_failure_count: int = 0 + tests_error_count: int = 0 + tests_unset_count: int = 0 + + +@dataclass +class TestStats: + """Test statistics for a run of tests.""" + + devices_success_count: int = 0 + devices_skipped_count: int = 0 + devices_failure_count: int = 0 + devices_error_count: int = 0 + devices_unset_count: int = 0 + devices_failure: set[str] = field(default_factory=set) diff --git a/anta/runner.py b/anta/runner.py index 89fb7c366..736da50d1 100644 --- a/anta/runner.py +++ b/anta/runner.py @@ -1,48 +1,77 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -# pylint: disable=too-many-branches -"""ANTA runner function.""" +"""ANTA runner module.""" from __future__ import annotations -import asyncio import logging import os -import resource -from typing import TYPE_CHECKING +from collections import defaultdict +from typing import TYPE_CHECKING, Any + +from typing_extensions import deprecated from anta import GITHUB_SUGGESTION -from anta.catalog import AntaCatalog, AntaTestDefinition -from anta.device import AntaDevice +from anta._runner import AntaRunFilters, AntaRunner from anta.logger import anta_log_exception, exc_to_str -from anta.models import AntaTest +from anta.tools import Catchtime, cprofile if TYPE_CHECKING: + from collections.abc import Coroutine + + from anta.catalog import AntaCatalog, AntaTestDefinition + from anta.device import AntaDevice from anta.inventory import AntaInventory from anta.result_manager import ResultManager + from anta.result_manager.models import TestResult -logger = logging.getLogger(__name__) +if os.name == "posix": + import resource + + DEFAULT_NOFILE = 16384 + + @deprecated("This function is deprecated and will be removed in ANTA v2.0.0. Use AntaRunner class instead.", category=DeprecationWarning) + def adjust_rlimit_nofile() -> tuple[int, int]: + """Adjust the maximum number of open file descriptors for the ANTA process. + + The limit is set to the lower of the current hard limit and the value of the ANTA_NOFILE environment variable. + + If the `ANTA_NOFILE` environment variable is not set or is invalid, `DEFAULT_NOFILE` is used. + + Returns + ------- + tuple[int, int] + The new soft and hard limits for open file descriptors. + """ + try: + nofile = int(os.environ.get("ANTA_NOFILE", DEFAULT_NOFILE)) + except ValueError as exception: + logger.warning("The ANTA_NOFILE environment variable value is invalid: %s\nDefault to %s.", exc_to_str(exception), DEFAULT_NOFILE) + nofile = DEFAULT_NOFILE + + limits = resource.getrlimit(resource.RLIMIT_NOFILE) + logger.debug("Initial limit numbers for open file descriptors for the current ANTA process: Soft Limit: %s | Hard Limit: %s", limits[0], limits[1]) + nofile = min(limits[1], nofile) + logger.debug("Setting soft limit for open file descriptors for the current ANTA process to %s", nofile) + try: + resource.setrlimit(resource.RLIMIT_NOFILE, (nofile, limits[1])) + except ValueError as exception: + logger.warning("Failed to set soft limit for open file descriptors for the current ANTA process: %s", exc_to_str(exception)) + return resource.getrlimit(resource.RLIMIT_NOFILE) -AntaTestRunner = tuple[AntaTestDefinition, AntaDevice] -# Environment variable to set ANTA's maximum number of open file descriptors. -# Maximum number of file descriptor the ANTA process will be able to open. -# This limit is independent from the system's hard limit, the lower will be used. -DEFAULT_NOFILE = 16384 -try: - __NOFILE__ = int(os.environ.get("ANTA_NOFILE", DEFAULT_NOFILE)) -except ValueError as exception: - logger.warning("The ANTA_NOFILE environment variable value is invalid: %s\nDefault to %s.", exc_to_str(exception), DEFAULT_NOFILE) - __NOFILE__ = DEFAULT_NOFILE +logger = logging.getLogger(__name__) +@deprecated("This function is deprecated and will be removed in ANTA v2.0.0. Use AntaRunner class instead.", category=DeprecationWarning) def log_cache_statistics(devices: list[AntaDevice]) -> None: """Log cache statistics for each device in the inventory. - Args: - ---- - devices: List of devices in the inventory. + Parameters + ---------- + devices + List of devices in the inventory. """ for device in devices: if device.cache_statistics is not None: @@ -56,124 +85,184 @@ def log_cache_statistics(devices: list[AntaDevice]) -> None: logger.info("Caching is not enabled on %s", device.name) -async def main( # noqa: PLR0912 PLR0913 too-many-branches too-many-arguments - keep the main method readable - manager: ResultManager, - inventory: AntaInventory, - catalog: AntaCatalog, - devices: set[str] | None = None, - tests: set[str] | None = None, - tags: set[str] | None = None, - *, - established_only: bool = True, -) -> None: - # pylint: disable=too-many-arguments - """Run ANTA. +@deprecated("This function is deprecated and will be removed in ANTA v2.0.0. Use AntaRunner class instead.", category=DeprecationWarning) +async def setup_inventory(inventory: AntaInventory, tags: set[str] | None, devices: set[str] | None, *, established_only: bool) -> AntaInventory | None: + """Set up the inventory for the ANTA run. - Use this as an entrypoint to the test framework in your script. - ResultManager object gets updated with the test results. + Parameters + ---------- + inventory + AntaInventory object that includes the device(s). + tags + Tags to filter devices from the inventory. + devices + Devices on which to run tests. None means all devices. + established_only + If True use return only devices where a connection is established. - Args: - ---- - manager: ResultManager object to populate with the test results. - inventory: AntaInventory object that includes the device(s). - catalog: AntaCatalog object that includes the list of tests. - devices: devices on which to run tests. None means all devices. - tests: tests to run against devices. None means all tests. - tags: Tags to filter devices from the inventory. - established_only: Include only established device(s). + Returns + ------- + AntaInventory | None + The filtered inventory or None if there are no devices to run tests on. """ - limits = resource.getrlimit(resource.RLIMIT_NOFILE) - logger.debug("Initial limit numbers for open file descriptors for the current ANTA process: Soft Limit: %s | Hard Limit: %s", limits[0], limits[1]) - nofile = __NOFILE__ if limits[1] > __NOFILE__ else limits[1] - logger.debug("Setting soft limit for open file descriptors for the current ANTA process to %s", nofile) - resource.setrlimit(resource.RLIMIT_NOFILE, (nofile, limits[1])) - limits = resource.getrlimit(resource.RLIMIT_NOFILE) - - if not catalog.tests: - logger.info("The list of tests is empty, exiting") - return if len(inventory) == 0: logger.info("The inventory is empty, exiting") - return + return None - # Filter the inventory based on tags and devices parameters - selected_inventory = inventory.get_inventory( - tags=tags, - devices=devices, - ) - await selected_inventory.connect_inventory() + # Filter the inventory based on the CLI provided tags and devices if any + selected_inventory = inventory.get_inventory(tags=tags, devices=devices) if tags or devices else inventory + + with Catchtime(logger=logger, message="Connecting to devices"): + # Connect to the devices + await selected_inventory.connect_inventory() # Remove devices that are unreachable - inventory = selected_inventory.get_inventory(established_only=established_only) + selected_inventory = selected_inventory.get_inventory(established_only=established_only) - if not inventory.devices: - msg = f'No reachable device {f"matching the tags {tags} " if tags else ""}was found.{f" Selected devices: {devices} " if devices is not None else ""}' + # If there are no devices in the inventory after filtering, exit + if not selected_inventory.devices: + msg = f"No reachable device {f'matching the tags {tags} ' if tags else ''}was found.{f' Selected devices: {devices} ' if devices is not None else ''}" logger.warning(msg) - return - coros = [] + return None + + return selected_inventory + + +@deprecated("This function is deprecated and will be removed in ANTA v2.0.0. Use AntaRunner class instead.", category=DeprecationWarning) +def prepare_tests( + inventory: AntaInventory, catalog: AntaCatalog, tests: set[str] | None, tags: set[str] | None +) -> defaultdict[AntaDevice, set[AntaTestDefinition]] | None: + """Prepare the tests to run. - # Select the tests from the catalog - if tests: - catalog = AntaCatalog(catalog.get_tests_by_names(tests)) + Parameters + ---------- + inventory + AntaInventory object that includes the device(s). + catalog + AntaCatalog object that includes the list of tests. + tests + Tests to run against devices. None means all tests. + tags + Tags to filter devices from the inventory. + + Returns + ------- + defaultdict[AntaDevice, set[AntaTestDefinition]] | None + A mapping of devices to the tests to run or None if there are no tests to run. + """ + # Build indexes for the catalog. If `tests` is set, filter the indexes based on these tests + catalog.build_indexes(filtered_tests=tests) # Using a set to avoid inserting duplicate tests - selected_tests: set[AntaTestRunner] = set() + device_to_tests: defaultdict[AntaDevice, set[AntaTestDefinition]] = defaultdict(set) - # Create AntaTestRunner tuples from the tags + total_test_count = 0 + + # Create the device to tests mapping from the tags for device in inventory.devices: if tags: - # If there are CLI tags, only execute tests with matching tags - selected_tests.update((test, device) for test in catalog.get_tests_by_tags(tags)) + # If there are CLI tags, execute tests with matching tags for this device + if not (matching_tags := tags.intersection(device.tags)): + # The device does not have any selected tag, skipping + continue + device_to_tests[device].update(catalog.get_tests_by_tags(matching_tags)) else: - # If there is no CLI tags, execute all tests that do not have any filters - selected_tests.update((t, device) for t in catalog.tests if t.inputs.filters is None or t.inputs.filters.tags is None) + # If there is no CLI tags, execute all tests that do not have any tags + device_to_tests[device].update(catalog.tag_to_tests[None]) # Then add the tests with matching tags from device tags - selected_tests.update((t, device) for t in catalog.get_tests_by_tags(device.tags)) + device_to_tests[device].update(catalog.get_tests_by_tags(device.tags)) - if not selected_tests: - msg = f"There is no tests{f' matching the tags {tags} ' if tags else ' '}to run in the current test catalog and device inventory, please verify your inputs." - logger.warning(msg) - return - - run_info = ( - "--- ANTA NRFU Run Information ---\n" - f"Number of devices: {len(selected_inventory)} ({len(inventory)} established)\n" - f"Total number of selected tests: {len(selected_tests)}\n" - f"Maximum number of open file descriptors for the current ANTA process: {limits[0]}\n" - "---------------------------------" - ) - logger.info(run_info) - if len(selected_tests) > limits[0]: - logger.warning( - "The number of concurrent tests is higher than the open file descriptors limit for this ANTA process.\n" - "Errors may occur while running the tests.\n" - "Please consult the ANTA FAQ." + total_test_count += len(device_to_tests[device]) + + if total_test_count == 0: + msg = ( + f"There are no tests{f' matching the tags {tags} ' if tags else ' '}to run in the current test catalog and device inventory, please verify your inputs." ) + logger.warning(msg) + return None + + return device_to_tests + + +@deprecated("This function is deprecated and will be removed in ANTA v2.0.0. Use AntaRunner class instead.", category=DeprecationWarning) +def get_coroutines(selected_tests: defaultdict[AntaDevice, set[AntaTestDefinition]], manager: ResultManager | None = None) -> list[Coroutine[Any, Any, TestResult]]: + """Get the coroutines for the ANTA run. + + Parameters + ---------- + selected_tests + A mapping of devices to the tests to run. The selected tests are generated by the `prepare_tests` function. + manager + An optional ResultManager object to pre-populate with the test results. Used in dry-run mode. + + Returns + ------- + list[Coroutine[Any, Any, TestResult]] + The list of coroutines to run. + """ + coros = [] + for device, test_definitions in selected_tests.items(): + for test in test_definitions: + try: + test_instance = test.test(device=device, inputs=test.inputs) + if manager is not None: + manager.add(test_instance.result) + coros.append(test_instance.test()) + except Exception as e: # noqa: PERF203, BLE001 + # An AntaTest instance is potentially user-defined code. + # We need to catch everything and exit gracefully with an error message. + message = "\n".join( + [ + f"There is an error when creating test {test.test.__module__}.{test.test.__name__}.", + f"If this is not a custom test implementation: {GITHUB_SUGGESTION}", + ], + ) + anta_log_exception(e, message, logger) + return coros - for test_definition, device in selected_tests: - try: - test_instance = test_definition.test(device=device, inputs=test_definition.inputs) - - coros.append(test_instance.test()) - except Exception as e: # pylint: disable=broad-exception-caught - # An AntaTest instance is potentially user-defined code. - # We need to catch everything and exit gracefully with an - # error message - message = "\n".join( - [ - f"There is an error when creating test {test_definition.test.__module__}.{test_definition.test.__name__}.", - f"If this is not a custom test implementation: {GITHUB_SUGGESTION}", - ], - ) - anta_log_exception(e, message, logger) - if AntaTest.progress is not None: - AntaTest.nrfu_task = AntaTest.progress.add_task("Running NRFU Tests...", total=len(coros)) +@cprofile() +async def main( + manager: ResultManager, + inventory: AntaInventory, + catalog: AntaCatalog, + devices: set[str] | None = None, + tests: set[str] | None = None, + tags: set[str] | None = None, + *, + established_only: bool = True, + dry_run: bool = False, +) -> None: + """Run ANTA. - logger.info("Running ANTA tests...") - test_results = await asyncio.gather(*coros) - for r in test_results: - manager.add(r) + Use this as an entrypoint to the test framework in your script. + ResultManager object gets updated with the test results. - log_cache_statistics(inventory.devices) + Parameters + ---------- + manager + ResultManager object to populate with the test results. + inventory + AntaInventory object that includes the device(s). + catalog + AntaCatalog object that includes the list of tests. + devices + Devices on which to run tests. None means all devices. These may come from the `--device / -d` CLI option in NRFU. + tests + Tests to run against devices. None means all tests. These may come from the `--test / -t` CLI option in NRFU. + tags + Tags to filter devices from the inventory. These may come from the `--tags` CLI option in NRFU. + established_only + Include only established device(s). + dry_run + Build the list of coroutine to run and stop before test execution. + """ + runner = AntaRunner() + filters = AntaRunFilters( + devices=devices, + tests=tests, + tags=tags, + established_only=established_only, + ) + await runner.run(inventory, catalog, manager, filters, dry_run=dry_run) diff --git a/anta/settings.py b/anta/settings.py new file mode 100644 index 000000000..88fdf442d --- /dev/null +++ b/anta/settings.py @@ -0,0 +1,86 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Settings for ANTA.""" + +from __future__ import annotations + +import logging +import os +import sys +from typing import Any + +from pydantic import Field, PositiveInt +from pydantic_settings import BaseSettings, SettingsConfigDict + +from anta.logger import exc_to_str + +logger = logging.getLogger(__name__) + +DEFAULT_MAX_CONCURRENCY = 50000 +"""Default value for the maximum number of concurrent tests in the event loop.""" + +DEFAULT_NOFILE = 16384 +"""Default value for the maximum number of open file descriptors for the ANTA process.""" + + +class AntaRunnerSettings(BaseSettings): + """Environment variables for configuring the ANTA runner. + + When initialized, relevant environment variables are loaded. If not set, default values are used. + + On POSIX systems, also adjusts the process soft limit based on the `ANTA_NOFILE` environment variable + while respecting the system hard limit, meaning the new soft limit cannot exceed the system's hard limit. + + On non-POSIX systems (Windows), sets the limit to `sys.maxsize`. + + The adjusted limit is available with the `file_descriptor_limit` property after initialization. + + Attributes + ---------- + nofile : PositiveInt + Environment variable: ANTA_NOFILE + + The maximum number of open file descriptors for the ANTA process. Defaults to 16384. + + max_concurrency : PositiveInt + Environment variable: ANTA_MAX_CONCURRENCY + + The maximum number of concurrent tests that can run in the event loop. Defaults to 50000. + """ + + model_config = SettingsConfigDict(env_prefix="ANTA_") + + nofile: PositiveInt = Field(default=DEFAULT_NOFILE) + max_concurrency: PositiveInt = Field(default=DEFAULT_MAX_CONCURRENCY) + + # Computed in post-init + _file_descriptor_limit: PositiveInt + + # pylint: disable=arguments-differ + def model_post_init(self, _context: Any) -> None: # noqa: ANN401 + """Post-initialization method to set the file descriptor limit for the current ANTA process.""" + if os.name != "posix": + logger.warning("Running on a non-POSIX system, cannot adjust the maximum number of file descriptors.") + self._file_descriptor_limit = sys.maxsize + return + + import resource + + limits = resource.getrlimit(resource.RLIMIT_NOFILE) + logger.debug("Initial file descriptor limits for the current ANTA process: Soft Limit: %s | Hard Limit: %s", limits[0], limits[1]) + + # Set new soft limit to minimum of requested and hard limit + new_soft_limit = min(limits[1], self.nofile) + logger.debug("Setting file descriptor soft limit to %s", new_soft_limit) + try: + resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft_limit, limits[1])) + except ValueError as exception: + logger.warning("Failed to set file descriptor soft limit for the current ANTA process: %s", exc_to_str(exception)) + + self._file_descriptor_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[0] + + @property + def file_descriptor_limit(self) -> PositiveInt: + """The maximum number of file descriptors available to the process.""" + return self._file_descriptor_limit diff --git a/anta/tests/__init__.py b/anta/tests/__init__.py index ec0b1ec9c..15362fc80 100644 --- a/anta/tests/__init__.py +++ b/anta/tests/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to all ANTA tests.""" diff --git a/anta/tests/aaa.py b/anta/tests/aaa.py index d6d0689e4..be79109ee 100644 --- a/anta/tests/aaa.py +++ b/anta/tests/aaa.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to the EOS various AAA tests.""" @@ -12,6 +12,7 @@ from anta.custom_types import AAAAuthMethod from anta.models import AntaCommand, AntaTest +from anta.tools import get_value if TYPE_CHECKING: from anta.models import AntaTemplate @@ -35,8 +36,6 @@ class VerifyTacacsSourceIntf(AntaTest): ``` """ - name = "VerifyTacacsSourceIntf" - description = "Verifies TACACS source-interface for a specified VRF." categories: ClassVar[list[str]] = ["aaa"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)] @@ -53,12 +52,12 @@ def test(self) -> None: """Main test function for VerifyTacacsSourceIntf.""" command_output = self.instance_commands[0].json_output try: - if command_output["srcIntf"][self.inputs.vrf] == self.inputs.intf: + if (src_interface := command_output["srcIntf"][self.inputs.vrf]) == self.inputs.intf: self.result.is_success() else: - self.result.is_failure(f"Wrong source-interface configured in VRF {self.inputs.vrf}") + self.result.is_failure(f"VRF: {self.inputs.vrf} - Source interface mismatch - Expected: {self.inputs.intf} Actual: {src_interface}") except KeyError: - self.result.is_failure(f"Source-interface {self.inputs.intf} is not configured in VRF {self.inputs.vrf}") + self.result.is_failure(f"VRF: {self.inputs.vrf} Source Interface: {self.inputs.intf} - Not configured") class VerifyTacacsServers(AntaTest): @@ -81,8 +80,6 @@ class VerifyTacacsServers(AntaTest): ``` """ - name = "VerifyTacacsServers" - description = "Verifies TACACS servers are configured for a specified VRF." categories: ClassVar[list[str]] = ["aaa"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)] @@ -106,13 +103,14 @@ def test(self) -> None: str(server) for server in self.inputs.servers if not any( - str(server) == tacacs_server["serverInfo"]["hostname"] and self.inputs.vrf == tacacs_server["serverInfo"]["vrf"] for tacacs_server in tacacs_servers + str(server) == get_value(tacacs_server, "serverInfo.hostname") and self.inputs.vrf == get_value(tacacs_server, "serverInfo.vrf", default="default") + for tacacs_server in tacacs_servers ) ] if not not_configured: self.result.is_success() else: - self.result.is_failure(f"TACACS servers {not_configured} are not configured in VRF {self.inputs.vrf}") + self.result.is_failure(f"TACACS servers {', '.join(not_configured)} are not configured in VRF {self.inputs.vrf}") class VerifyTacacsServerGroups(AntaTest): @@ -134,8 +132,6 @@ class VerifyTacacsServerGroups(AntaTest): ``` """ - name = "VerifyTacacsServerGroups" - description = "Verifies if the provided TACACS server group(s) are configured." categories: ClassVar[list[str]] = ["aaa"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)] @@ -157,7 +153,7 @@ def test(self) -> None: if not not_configured: self.result.is_success() else: - self.result.is_failure(f"TACACS server group(s) {not_configured} are not configured") + self.result.is_failure(f"TACACS server group(s) {', '.join(not_configured)} are not configured") class VerifyAuthenMethods(AntaTest): @@ -173,19 +169,17 @@ class VerifyAuthenMethods(AntaTest): ```yaml anta.tests.aaa: - VerifyAuthenMethods: - methods: - - local - - none - - logging - types: - - login - - enable - - dot1x + methods: + - local + - none + - logging + types: + - login + - enable + - dot1x ``` """ - name = "VerifyAuthenMethods" - description = "Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x)." categories: ClassVar[list[str]] = ["aaa"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authentication", revision=1)] @@ -212,14 +206,14 @@ def test(self) -> None: self.result.is_failure("AAA authentication methods are not configured for login console") return if v["login"]["methods"] != self.inputs.methods: - self.result.is_failure(f"AAA authentication methods {self.inputs.methods} are not matching for login console") + self.result.is_failure(f"AAA authentication methods {', '.join(self.inputs.methods)} are not matching for login console") return not_matching.extend(auth_type for methods in v.values() if methods["methods"] != self.inputs.methods) if not not_matching: self.result.is_success() else: - self.result.is_failure(f"AAA authentication methods {self.inputs.methods} are not matching for {not_matching}") + self.result.is_failure(f"AAA authentication methods {', '.join(self.inputs.methods)} are not matching for {', '.join(not_matching)}") class VerifyAuthzMethods(AntaTest): @@ -245,8 +239,6 @@ class VerifyAuthzMethods(AntaTest): ``` """ - name = "VerifyAuthzMethods" - description = "Verifies the AAA authorization method lists for different authorization types (commands, exec)." categories: ClassVar[list[str]] = ["aaa"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authorization", revision=1)] @@ -273,7 +265,7 @@ def test(self) -> None: if not not_matching: self.result.is_success() else: - self.result.is_failure(f"AAA authorization methods {self.inputs.methods} are not matching for {not_matching}") + self.result.is_failure(f"AAA authorization methods {', '.join(self.inputs.methods)} are not matching for {', '.join(not_matching)}") class VerifyAcctDefaultMethods(AntaTest): @@ -301,8 +293,6 @@ class VerifyAcctDefaultMethods(AntaTest): ``` """ - name = "VerifyAcctDefaultMethods" - description = "Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x)." categories: ClassVar[list[str]] = ["aaa"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)] @@ -331,12 +321,12 @@ def test(self) -> None: if methods["defaultMethods"] != self.inputs.methods: not_matching.append(acct_type) if not_configured: - self.result.is_failure(f"AAA default accounting is not configured for {not_configured}") + self.result.is_failure(f"AAA default accounting is not configured for {', '.join(not_configured)}") return if not not_matching: self.result.is_success() else: - self.result.is_failure(f"AAA accounting default methods {self.inputs.methods} are not matching for {not_matching}") + self.result.is_failure(f"AAA accounting default methods {', '.join(self.inputs.methods)} are not matching for {', '.join(not_matching)}") class VerifyAcctConsoleMethods(AntaTest): @@ -364,8 +354,6 @@ class VerifyAcctConsoleMethods(AntaTest): ``` """ - name = "VerifyAcctConsoleMethods" - description = "Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x)." categories: ClassVar[list[str]] = ["aaa"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)] @@ -394,9 +382,9 @@ def test(self) -> None: if methods["consoleMethods"] != self.inputs.methods: not_matching.append(acct_type) if not_configured: - self.result.is_failure(f"AAA console accounting is not configured for {not_configured}") + self.result.is_failure(f"AAA console accounting is not configured for {', '.join(not_configured)}") return if not not_matching: self.result.is_success() else: - self.result.is_failure(f"AAA accounting console methods {self.inputs.methods} are not matching for {not_matching}") + self.result.is_failure(f"AAA accounting console methods {', '.join(self.inputs.methods)} are not matching for {', '.join(not_matching)}") diff --git a/anta/tests/avt.py b/anta/tests/avt.py new file mode 100644 index 000000000..2173510fe --- /dev/null +++ b/anta/tests/avt.py @@ -0,0 +1,195 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module related to Adaptive virtual topology tests.""" + +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from typing import ClassVar + +from anta.decorators import skip_on_platforms +from anta.input_models.avt import AVTPath +from anta.models import AntaCommand, AntaTemplate, AntaTest +from anta.tools import get_value + + +class VerifyAVTPathHealth(AntaTest): + """Verifies the status of all Adaptive Virtual Topology (AVT) paths for all VRFs. + + Expected Results + ---------------- + * Success: The test will pass if all AVT paths for all VRFs are active and valid. + * Failure: The test will fail if the AVT path is not configured or if any AVT path under any VRF is either inactive or invalid. + + Examples + -------- + ```yaml + anta.tests.avt: + - VerifyAVTPathHealth: + ``` + """ + + description = "Verifies the status of all AVT paths for all VRFs." + categories: ClassVar[list[str]] = ["avt"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show adaptive-virtual-topology path")] + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyAVTPathHealth.""" + # Initialize the test result as success + self.result.is_success() + + # Get the command output + command_output = self.instance_commands[0].json_output.get("vrfs", {}) + + # Check if AVT is configured + if not command_output: + self.result.is_failure("Adaptive virtual topology paths are not configured") + return + + # Iterate over each VRF + for vrf, vrf_data in command_output.items(): + # Iterate over each AVT path + for profile, avt_path in vrf_data.get("avts", {}).items(): + for path, flags in avt_path.get("avtPaths", {}).items(): + # Get the status of the AVT path + valid = flags["flags"]["valid"] + active = flags["flags"]["active"] + + # Check the status of the AVT path + if not valid and not active: + self.result.is_failure(f"VRF: {vrf} Profile: {profile} AVT path: {path} - Invalid and not active") + elif not valid: + self.result.is_failure(f"VRF: {vrf} Profile: {profile} AVT path: {path} - Invalid") + elif not active: + self.result.is_failure(f"VRF: {vrf} Profile: {profile} AVT path: {path} - Not active") + + +class VerifyAVTSpecificPath(AntaTest): + """Verifies the Adaptive Virtual Topology (AVT) path. + + This test performs the following checks for each specified LLDP neighbor: + + 1. Confirming that the AVT paths are associated with the specified VRF. + 2. Verifying that each AVT path is active and valid. + 3. Ensuring that the AVT path matches the specified type (direct/multihop) if provided. + + Expected Results + ---------------- + * Success: The test will pass if all of the following conditions are met: + - All AVT paths for the specified VRF are active, valid, and match the specified path type (direct/multihop), if provided. + - If multiple paths are configured, the test will pass only if all paths meet these criteria. + * Failure: The test will fail if any of the following conditions are met: + - No AVT paths are configured for the specified VRF. + - Any configured path is inactive, invalid, or does not match the specified type. + + Examples + -------- + ```yaml + anta.tests.avt: + - VerifyAVTSpecificPath: + avt_paths: + - avt_name: CONTROL-PLANE-PROFILE + vrf: default + destination: 10.101.255.2 + next_hop: 10.101.255.1 + path_type: direct + ``` + """ + + categories: ClassVar[list[str]] = ["avt"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show adaptive-virtual-topology path", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyAVTSpecificPath test.""" + + avt_paths: list[AVTPath] + """List of AVT paths to verify.""" + AVTPaths: ClassVar[type[AVTPath]] = AVTPath + """To maintain backward compatibility.""" + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyAVTSpecificPath.""" + # Assume the test is successful until a failure is detected + self.result.is_success() + + command_output = self.instance_commands[0].json_output + for avt_path in self.inputs.avt_paths: + if (path_output := get_value(command_output, f"vrfs.{avt_path.vrf}.avts.{avt_path.avt_name}.avtPaths")) is None: + self.result.is_failure(f"{avt_path} - No AVT path configured") + return + + path_found = path_type_found = False + + # Check each AVT path + for path, path_data in path_output.items(): + dest = path_data.get("destination") + nexthop = path_data.get("nexthopAddr") + path_type = "direct" if get_value(path_data, "flags.directPath") else "multihop" + + if not avt_path.path_type: + path_found = all([dest == str(avt_path.destination), nexthop == str(avt_path.next_hop)]) + + else: + path_type_found = all([dest == str(avt_path.destination), nexthop == str(avt_path.next_hop), path_type == avt_path.path_type]) + if path_type_found: + path_found = True + # Check the path status and type against the expected values + valid = get_value(path_data, "flags.valid") + active = get_value(path_data, "flags.active") + if not all([valid, active]): + self.result.is_failure(f"{avt_path} - Incorrect path {path} - Valid: {valid} Active: {active}") + + # If no matching path found, mark the test as failed + if not path_found: + if avt_path.path_type and not path_type_found: + self.result.is_failure(f"{avt_path} Path Type: {avt_path.path_type} - Path not found") + else: + self.result.is_failure(f"{avt_path} - Path not found") + + +class VerifyAVTRole(AntaTest): + """Verifies the Adaptive Virtual Topology (AVT) role of a device. + + Expected Results + ---------------- + * Success: The test will pass if the AVT role of the device matches the expected role. + * Failure: The test will fail if the AVT is not configured or if the AVT role does not match the expected role. + + Examples + -------- + ```yaml + anta.tests.avt: + - VerifyAVTRole: + role: edge + ``` + """ + + description = "Verifies the AVT role of a device." + categories: ClassVar[list[str]] = ["avt"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show adaptive-virtual-topology path")] + + class Input(AntaTest.Input): + """Input model for the VerifyAVTRole test.""" + + role: str + """Expected AVT role of the device.""" + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyAVTRole.""" + # Initialize the test result as success + self.result.is_success() + + # Get the command output + command_output = self.instance_commands[0].json_output + + # Check if the AVT role matches the expected role + if self.inputs.role != command_output.get("role"): + self.result.is_failure(f"AVT role mismatch - Expected: {self.inputs.role} Actual: {command_output.get('role')}") diff --git a/anta/tests/bfd.py b/anta/tests/bfd.py index f19e9cc92..a05bef410 100644 --- a/anta/tests/bfd.py +++ b/anta/tests/bfd.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to BFD tests.""" @@ -9,25 +9,51 @@ from datetime import datetime, timezone from ipaddress import IPv4Address -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, TypeVar -from pydantic import BaseModel, Field +from pydantic import Field, field_validator -from anta.custom_types import BfdInterval, BfdMultiplier +from anta.input_models.bfd import BFDPeer from anta.models import AntaCommand, AntaTest from anta.tools import get_value if TYPE_CHECKING: from anta.models import AntaTemplate +# Using a TypeVar for the BFDPeer model since mypy thinks it's a ClassVar and not a valid type when used in field validators +T = TypeVar("T", bound=BFDPeer) + + +def _get_bfd_peer_stats(peer: BFDPeer, command_output: dict[str, Any]) -> dict[str, Any] | None: + """Retrieve BFD peer stats for the given peer from the command output. + + Parameters + ---------- + peer + The BFDPeer object to look up. + command_output + Parsed output of the command. + + Returns + ------- + dict | None + The peer stats dictionary if found, otherwise None. + """ + af = "ipv4Neighbors" if isinstance(peer.peer_address, IPv4Address) else "ipv6Neighbors" + intf = "" if peer.interface is None else peer.interface + return get_value(command_output, f"vrfs..{peer.vrf}..{af}..{peer.peer_address!s}..peerStats..{intf}", separator="..") + class VerifyBFDSpecificPeers(AntaTest): - """Verifies if the IPv4 BFD peer's sessions are UP and remote disc is non-zero in the specified VRF. + """Verifies the state of BFD peer sessions. + + !!! warning + Seamless BFD (S-BFD) is **not** supported. Expected Results ---------------- - * Success: The test will pass if IPv4 BFD peers are up and remote disc is non-zero in the specified VRF. - * Failure: The test will fail if IPv4 BFD peers are not found, the status is not UP or remote disc is zero in the specified VRF. + * Success: The test will pass if all specified BFD peers are `up` and remote discriminators (disc) are non-zero. + * Failure: The test will fail if any specified BFD peer is not found, not `up` or remote disc is zero. Examples -------- @@ -35,74 +61,64 @@ class VerifyBFDSpecificPeers(AntaTest): anta.tests.bfd: - VerifyBFDSpecificPeers: bfd_peers: + # Multi-hop session in VRF default - peer_address: 192.0.255.8 - vrf: default + # Multi-hop session in VRF DEV - peer_address: 192.0.255.7 - vrf: default + vrf: DEV + # Single-hop session on local transport interface Ethernet3 in VRF PROD + - peer_address: 192.168.10.2 + vrf: PROD + interface: Ethernet3 + # IPv6 peers also supported + - peer_address: fd00:dc:1::1 ``` """ - name = "VerifyBFDSpecificPeers" - description = "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF." categories: ClassVar[list[str]] = ["bfd"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers", revision=4)] + # Using revision 1 as latest revision introduces additional nesting for type + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers", revision=1)] + inputs: VerifyBFDSpecificPeers.Input class Input(AntaTest.Input): """Input model for the VerifyBFDSpecificPeers test.""" bfd_peers: list[BFDPeer] - """List of IPv4 BFD peers.""" - - class BFDPeer(BaseModel): - """Model for an IPv4 BFD peer.""" - - peer_address: IPv4Address - """IPv4 address of a BFD peer.""" - vrf: str = "default" - """Optional VRF for BFD peer. If not provided, it defaults to `default`.""" + """List of BFD peers.""" + BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer + """To maintain backward compatibility.""" @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBFDSpecificPeers.""" - failures: dict[Any, Any] = {} + self.result.is_success() + + output = self.instance_commands[0].json_output - # Iterating over BFD peers for bfd_peer in self.inputs.bfd_peers: - peer = str(bfd_peer.peer_address) - vrf = bfd_peer.vrf - bfd_output = get_value( - self.instance_commands[0].json_output, - f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..", - separator="..", - ) - - # Check if BFD peer configured - if not bfd_output: - failures[peer] = {vrf: "Not Configured"} + # Check if BFD peer is found + if (peer_stats := _get_bfd_peer_stats(bfd_peer, output)) is None: + self.result.is_failure(f"{bfd_peer} - Not found") continue # Check BFD peer status and remote disc - if not (bfd_output.get("status") == "up" and bfd_output.get("remoteDisc") != 0): - failures[peer] = { - vrf: { - "status": bfd_output.get("status"), - "remote_disc": bfd_output.get("remoteDisc"), - } - } - - if not failures: - self.result.is_success() - else: - self.result.is_failure(f"Following BFD peers are not configured, status is not up or remote disc is zero:\n{failures}") + state = peer_stats["status"] + remote_disc = peer_stats["remoteDisc"] + if not (state == "up" and remote_disc != 0): + self.result.is_failure(f"{bfd_peer} - Session not properly established - State: {state} Remote Discriminator: {remote_disc}") class VerifyBFDPeersIntervals(AntaTest): - """Verifies the timers of the IPv4 BFD peers in the specified VRF. + """Verifies the operational timers of BFD peer sessions. + + !!! warning + Seamless BFD (S-BFD) is **not** supported. Expected Results ---------------- - * Success: The test will pass if the timers of the IPv4 BFD peers are correct in the specified VRF. - * Failure: The test will fail if the IPv4 BFD peers are not found or their timers are incorrect in the specified VRF. + * Success: The test will pass if all specified BFD peer sessions are operating with the proper timers. + * Failure: The test will fail if any specified BFD peer session is not found or not operating with the proper timers. + Examples -------- @@ -110,104 +126,107 @@ class VerifyBFDPeersIntervals(AntaTest): anta.tests.bfd: - VerifyBFDPeersIntervals: bfd_peers: + # Multi-hop session in VRF default - peer_address: 192.0.255.8 - vrf: default + tx_interval: 3600 + rx_interval: 3600 + multiplier: 3 + # Multi-hop session in VRF DEV + - peer_address: 192.0.255.7 + vrf: DEV + tx_interval: 3600 + rx_interval: 3600 + multiplier: 3 + # Single-hop session on local transport interface Ethernet3 in VRF PROD + - peer_address: 192.168.10.2 + vrf: PROD + interface: Ethernet3 tx_interval: 1200 rx_interval: 1200 multiplier: 3 - - peer_address: 192.0.255.7 - vrf: default + detection_time: 3600 # Optional + # IPv6 peers also supported + - peer_address: fd00:dc:1::1 tx_interval: 1200 rx_interval: 1200 multiplier: 3 + detection_time: 3600 # Optional ``` """ - name = "VerifyBFDPeersIntervals" - description = "Verifies the timers of the IPv4 BFD peers in the specified VRF." categories: ClassVar[list[str]] = ["bfd"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=4)] + # Using revision 1 as latest revision introduces additional nesting for type + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=1)] + inputs: VerifyBFDPeersIntervals.Input class Input(AntaTest.Input): """Input model for the VerifyBFDPeersIntervals test.""" bfd_peers: list[BFDPeer] """List of BFD peers.""" - - class BFDPeer(BaseModel): - """Model for an IPv4 BFD peer.""" - - peer_address: IPv4Address - """IPv4 address of a BFD peer.""" - vrf: str = "default" - """Optional VRF for BFD peer. If not provided, it defaults to `default`.""" - tx_interval: BfdInterval - """Tx interval of BFD peer in milliseconds.""" - rx_interval: BfdInterval - """Rx interval of BFD peer in milliseconds.""" - multiplier: BfdMultiplier - """Multiplier of BFD peer.""" + BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer + """To maintain backward compatibility.""" + + @field_validator("bfd_peers") + @classmethod + def validate_bfd_peers(cls, bfd_peers: list[T]) -> list[T]: + """Validate that 'tx_interval', 'rx_interval' and 'multiplier' fields are provided in each BFD peer.""" + for peer in bfd_peers: + missing_fileds = [] + if peer.tx_interval is None: + missing_fileds.append("tx_interval") + if peer.rx_interval is None: + missing_fileds.append("rx_interval") + if peer.multiplier is None: + missing_fileds.append("multiplier") + if missing_fileds: + msg = f"{peer} {', '.join(missing_fileds)} field(s) are missing in the input" + raise ValueError(msg) + return bfd_peers @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBFDPeersIntervals.""" - failures: dict[Any, Any] = {} - - # Iterating over BFD peers - for bfd_peers in self.inputs.bfd_peers: - peer = str(bfd_peers.peer_address) - vrf = bfd_peers.vrf - - # Converting milliseconds intervals into actual value - tx_interval = bfd_peers.tx_interval * 1000 - rx_interval = bfd_peers.rx_interval * 1000 - multiplier = bfd_peers.multiplier - bfd_output = get_value( - self.instance_commands[0].json_output, - f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..", - separator="..", - ) - - # Check if BFD peer configured - if not bfd_output: - failures[peer] = {vrf: "Not Configured"} + self.result.is_success() + + output = self.instance_commands[0].json_output + + for bfd_peer in self.inputs.bfd_peers: + # Check if BFD peer is found + if (peer_stats := _get_bfd_peer_stats(bfd_peer, output)) is None: + self.result.is_failure(f"{bfd_peer} - Not found") continue - bfd_details = bfd_output.get("peerStatsDetail", {}) - intervals_ok = ( - bfd_details.get("operTxInterval") == tx_interval and bfd_details.get("operRxInterval") == rx_interval and bfd_details.get("detectMult") == multiplier - ) + # Convert interval timers into milliseconds to be consistent with the inputs + act_tx_interval = get_value(peer_stats, "peerStatsDetail.operTxInterval") // 1000 + act_rx_interval = get_value(peer_stats, "peerStatsDetail.operRxInterval") // 1000 + act_detect_time = get_value(peer_stats, "peerStatsDetail.detectTime") // 1000 + act_detect_mult = get_value(peer_stats, "peerStatsDetail.detectMult") - # Check timers of BFD peer - if not intervals_ok: - failures[peer] = { - vrf: { - "tx_interval": bfd_details.get("operTxInterval"), - "rx_interval": bfd_details.get("operRxInterval"), - "multiplier": bfd_details.get("detectMult"), - } - } + if act_tx_interval != bfd_peer.tx_interval: + self.result.is_failure(f"{bfd_peer} - Incorrect Transmit interval - Expected: {bfd_peer.tx_interval} Actual: {act_tx_interval}") - # Check if any failures - if not failures: - self.result.is_success() - else: - self.result.is_failure(f"Following BFD peers are not configured or timers are not correct:\n{failures}") + if act_rx_interval != bfd_peer.rx_interval: + self.result.is_failure(f"{bfd_peer} - Incorrect Receive interval - Expected: {bfd_peer.rx_interval} Actual: {act_rx_interval}") + if act_detect_mult != bfd_peer.multiplier: + self.result.is_failure(f"{bfd_peer} - Incorrect Multiplier - Expected: {bfd_peer.multiplier} Actual: {act_detect_mult}") -class VerifyBFDPeersHealth(AntaTest): - """Verifies the health of IPv4 BFD peers across all VRFs. + if bfd_peer.detection_time and act_detect_time != bfd_peer.detection_time: + self.result.is_failure(f"{bfd_peer} - Incorrect Detection Time - Expected: {bfd_peer.detection_time} Actual: {act_detect_time}") - It checks that no BFD peer is in the down state and that the discriminator value of the remote system is not zero. - Optionally, it can also verify that BFD peers have not been down before a specified threshold of hours. +class VerifyBFDPeersHealth(AntaTest): + """Verifies the health of BFD peers across all VRFs. + + !!! warning + Seamless BFD (S-BFD) is **not** supported. Expected Results ---------------- - * Success: The test will pass if all IPv4 BFD peers are up, the discriminator value of each remote system is non-zero, - and the last downtime of each peer is above the defined threshold. - * Failure: The test will fail if any IPv4 BFD peer is down, the discriminator value of any remote system is zero, - or the last downtime of any peer is below the defined threshold. + * Success: The test will pass if all BFD peers are `up`, remote discriminators (disc) are non-zero + and last downtime is above `down_threshold` (if provided). + * Failure: The test will fail if any BFD peer is not `up`, remote disc is zero or last downtime is below `down_threshold` (if provided). Examples -------- @@ -218,14 +237,13 @@ class VerifyBFDPeersHealth(AntaTest): ``` """ - name = "VerifyBFDPeersHealth" - description = "Verifies the health of all IPv4 BFD peers." categories: ClassVar[list[str]] = ["bfd"] - # revision 1 as later revision introduces additional nesting for type + # Using revision 1 as latest revision introduces additional nesting for type commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="show bfd peers", revision=1), AntaCommand(command="show clock", revision=1), ] + inputs: VerifyBFDPeersHealth.Input class Input(AntaTest.Input): """Input model for the VerifyBFDPeersHealth test.""" @@ -236,52 +254,115 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBFDPeersHealth.""" - # Initialize failure strings - down_failures = [] - up_failures = [] + self.result.is_success() # Extract the current timestamp and command output clock_output = self.instance_commands[1].json_output current_timestamp = clock_output["utcTime"] bfd_output = self.instance_commands[0].json_output - # set the initial result - self.result.is_success() - - # Check if any IPv4 BFD peer is configured - ipv4_neighbors_exist = any(vrf_data["ipv4Neighbors"] for vrf_data in bfd_output["vrfs"].values()) - if not ipv4_neighbors_exist: - self.result.is_failure("No IPv4 BFD peers are configured for any VRF.") + # Check if any IPv4 or IPv6 BFD peer is configured + if not any(vrf_data["ipv4Neighbors"] | vrf_data["ipv6Neighbors"] for vrf_data in bfd_output["vrfs"].values()): + self.result.is_failure("No IPv4 or IPv6 BFD peers configured for any VRF") return - # Iterate over IPv4 BFD peers for vrf, vrf_data in bfd_output["vrfs"].items(): - for peer, neighbor_data in vrf_data["ipv4Neighbors"].items(): - for peer_data in neighbor_data["peerStats"].values(): - peer_status = peer_data["status"] - remote_disc = peer_data["remoteDisc"] - remote_disc_info = f" with remote disc {remote_disc}" if remote_disc == 0 else "" - last_down = peer_data["lastDown"] - hours_difference = ( - datetime.fromtimestamp(current_timestamp, tz=timezone.utc) - datetime.fromtimestamp(last_down, tz=timezone.utc) - ).total_seconds() / 3600 - - # Check if peer status is not up - if peer_status != "up": - down_failures.append(f"{peer} is {peer_status} in {vrf} VRF{remote_disc_info}.") + # Merging the IPv4 and IPv6 peers into a single dict + all_peers = vrf_data["ipv4Neighbors"] | vrf_data["ipv6Neighbors"] + for peer_ip, peer_data in all_peers.items(): + for interface, peer_stats in peer_data["peerStats"].items(): + identifier = f"Peer: {peer_ip} VRF: {vrf} Interface: {interface}" if interface else f"Peer: {peer_ip} VRF: {vrf}" + peer_status = peer_stats["status"] + remote_disc = peer_stats["remoteDisc"] + + if not (peer_status == "up" and remote_disc != 0): + self.result.is_failure(f"{identifier} - Session not properly established - State: {peer_status} Remote Discriminator: {remote_disc}") # Check if the last down is within the threshold - elif self.inputs.down_threshold and hours_difference < self.inputs.down_threshold: - up_failures.append(f"{peer} in {vrf} VRF was down {round(hours_difference)} hours ago{remote_disc_info}.") - - # Check if remote disc is 0 - elif remote_disc == 0: - up_failures.append(f"{peer} in {vrf} VRF has remote disc {remote_disc}.") - - # Check if there are any failures - if down_failures: - down_failures_str = "\n".join(down_failures) - self.result.is_failure(f"Following BFD peers are not up:\n{down_failures_str}") - if up_failures: - up_failures_str = "\n".join(up_failures) - self.result.is_failure(f"\nFollowing BFD peers were down:\n{up_failures_str}") + if self.inputs.down_threshold is not None: + last_down = peer_stats["lastDown"] + hours_difference = ( + datetime.fromtimestamp(current_timestamp, tz=timezone.utc) - datetime.fromtimestamp(last_down, tz=timezone.utc) + ).total_seconds() / 3600 + if hours_difference < self.inputs.down_threshold: + self.result.is_failure( + f"{identifier} - Session failure detected within the expected uptime threshold ({round(hours_difference)} hours ago)" + ) + + +class VerifyBFDPeersRegProtocols(AntaTest): + """Verifies the registered protocols of BFD peer sessions. + + !!! warning + Seamless BFD (S-BFD) is **not** supported. + + Expected Results + ---------------- + * Success: The test will pass if all specified BFD peers have the proper registered protocols. + * Failure: The test will fail if any specified BFD peer is not found or doesn't have the proper registered protocols. + + Examples + -------- + ```yaml + anta.tests.bfd: + - VerifyBFDPeersRegProtocols: + bfd_peers: + # Multi-hop session in VRF default + - peer_address: 192.0.255.8 + protocols: [ bgp ] + # Multi-hop session in VRF DEV + - peer_address: 192.0.255.7 + vrf: DEV + protocols: [ bgp, vxlan ] + # Single-hop session on local transport interface Ethernet3 in VRF PROD + - peer_address: 192.168.10.2 + vrf: PROD + interface: Ethernet3 + protocols: [ ospf ] + detection_time: 3600 # Optional + # IPv6 peers also supported + - peer_address: fd00:dc:1::1 + protocols: [ isis ] + ``` + """ + + categories: ClassVar[list[str]] = ["bfd"] + # Using revision 1 as latest revision introduces additional nesting for type + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=1)] + inputs: VerifyBFDPeersRegProtocols.Input + + class Input(AntaTest.Input): + """Input model for the VerifyBFDPeersRegProtocols test.""" + + bfd_peers: list[BFDPeer] + """List of BFD peers.""" + BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer + """To maintain backward compatibility.""" + + @field_validator("bfd_peers") + @classmethod + def validate_bfd_peers(cls, bfd_peers: list[T]) -> list[T]: + """Validate that 'protocols' field is provided in each BFD peer.""" + for peer in bfd_peers: + if peer.protocols is None: + msg = f"{peer} 'protocols' field missing in the input" + raise ValueError(msg) + return bfd_peers + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBFDPeersRegProtocols.""" + self.result.is_success() + + output = self.instance_commands[0].json_output + + for bfd_peer in self.inputs.bfd_peers: + # Check if BFD peer is found + if (peer_stats := _get_bfd_peer_stats(bfd_peer, output)) is None: + self.result.is_failure(f"{bfd_peer} - Not found") + continue + + # Check registered protocols + difference = sorted(set(bfd_peer.protocols) - set(get_value(peer_stats, "peerStatsDetail.apps", default=[]))) + if difference: + self.result.is_failure(f"{bfd_peer} - {', '.join(difference)} protocol{'s' if len(difference) > 1 else ''} not registered") diff --git a/anta/tests/configuration.py b/anta/tests/configuration.py index 7f3f0bde1..a1c57a1be 100644 --- a/anta/tests/configuration.py +++ b/anta/tests/configuration.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to the device configuration tests.""" @@ -7,8 +7,10 @@ # mypy: disable-error-code=attr-defined from __future__ import annotations +import re from typing import TYPE_CHECKING, ClassVar +from anta.custom_types import RegexString from anta.models import AntaCommand, AntaTest if TYPE_CHECKING: @@ -31,8 +33,6 @@ class VerifyZeroTouch(AntaTest): ``` """ - name = "VerifyZeroTouch" - description = "Verifies ZeroTouch is disabled" categories: ClassVar[list[str]] = ["configuration"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show zerotouch", revision=1)] @@ -62,8 +62,6 @@ class VerifyRunningConfigDiffs(AntaTest): ``` """ - name = "VerifyRunningConfigDiffs" - description = "Verifies there is no difference between the running-config and the startup-config" categories: ClassVar[list[str]] = ["configuration"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config diffs", ofmt="text")] @@ -75,3 +73,56 @@ def test(self) -> None: self.result.is_success() else: self.result.is_failure(command_output) + + +class VerifyRunningConfigLines(AntaTest): + """Verifies the given regular expression patterns are present in the running-config. + + !!! warning + Since this uses regular expression searches on the whole running-config, it can + drastically impact performance and should only be used if no other test is available. + + If possible, try using another ANTA test that is more specific. + + Expected Results + ---------------- + * Success: The test will pass if all the patterns are found in the running-config. + * Failure: The test will fail if any of the patterns are NOT found in the running-config. + + Examples + -------- + ```yaml + anta.tests.configuration: + - VerifyRunningConfigLines: + regex_patterns: + - "^enable password.*$" + - "bla bla" + ``` + """ + + description = "Search the Running-Config for the given RegEx patterns." + categories: ClassVar[list[str]] = ["configuration"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config", ofmt="text")] + + class Input(AntaTest.Input): + """Input model for the VerifyRunningConfigLines test.""" + + regex_patterns: list[RegexString] + """List of regular expressions.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyRunningConfigLines.""" + failure_msgs = [] + command_output = self.instance_commands[0].text_output + + for pattern in self.inputs.regex_patterns: + re_search = re.compile(pattern, flags=re.MULTILINE) + + if not re_search.search(command_output): + failure_msgs.append(f"'{pattern}'") + + if not failure_msgs: + self.result.is_success() + else: + self.result.is_failure("Following patterns were not found: " + ", ".join(failure_msgs)) diff --git a/anta/tests/connectivity.py b/anta/tests/connectivity.py index 06cf8eaeb..17e7eaf24 100644 --- a/anta/tests/connectivity.py +++ b/anta/tests/connectivity.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to various connectivity tests.""" @@ -7,14 +7,16 @@ # mypy: disable-error-code=attr-defined from __future__ import annotations -from ipaddress import IPv4Address -from typing import ClassVar +from typing import ClassVar, TypeVar -from pydantic import BaseModel +from pydantic import field_validator -from anta.custom_types import Interface +from anta.input_models.connectivity import Host, LLDPNeighbor, Neighbor from anta.models import AntaCommand, AntaTemplate, AntaTest +# Using a TypeVar for the Host model since mypy thinks it's a ClassVar and not a valid type when used in field validators +T = TypeVar("T", bound=Host) + class VerifyReachability(AntaTest): """Test network reachability to one or many destination IP(s). @@ -33,66 +35,90 @@ class VerifyReachability(AntaTest): - source: Management0 destination: 1.1.1.1 vrf: MGMT - - source: Management0 - destination: 8.8.8.8 + df_bit: True + size: 100 + reachable: true + - destination: 8.8.8.8 vrf: MGMT + df_bit: True + size: 100 + - source: fd12:3456:789a:1::1 + destination: fd12:3456:789a:1::2 + vrf: default + df_bit: True + size: 100 + reachable: false ``` """ - name = "VerifyReachability" - description = "Test the network reachability to one or many destination IP(s)." categories: ClassVar[list[str]] = ["connectivity"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="ping vrf {vrf} {destination} source {source} repeat {repeat}", revision=1)] + # Template uses '{size}{df_bit}' without space since df_bit includes leading space when enabled + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ + AntaTemplate(template="ping vrf {vrf} {destination}{source} size {size}{df_bit} repeat {repeat}", revision=1) + ] class Input(AntaTest.Input): """Input model for the VerifyReachability test.""" hosts: list[Host] """List of host to ping.""" - - class Host(BaseModel): - """Model for a remote host to ping.""" - - destination: IPv4Address - """IPv4 address to ping.""" - source: IPv4Address | Interface - """IPv4 address source IP or egress interface to use.""" - vrf: str = "default" - """VRF context. Defaults to `default`.""" - repeat: int = 2 - """Number of ping repetition. Defaults to 2.""" + Host: ClassVar[type[Host]] = Host + """To maintain backward compatibility.""" + + @field_validator("hosts") + @classmethod + def validate_hosts(cls, hosts: list[T]) -> list[T]: + """Validate the 'destination' and 'source' IP address family in each host.""" + for host in hosts: + if host.source and not isinstance(host.source, str) and host.destination.version != host.source.version: + msg = f"{host} IP address family for destination does not match source" + raise ValueError(msg) + return hosts def render(self, template: AntaTemplate) -> list[AntaCommand]: """Render the template for each host in the input list.""" - return [template.render(destination=host.destination, source=host.source, vrf=host.vrf, repeat=host.repeat) for host in self.inputs.hosts] + return [ + template.render( + destination=host.destination, + source=f" source {host.source}" if host.source else "", + vrf=host.vrf, + repeat=host.repeat, + size=host.size, + df_bit=" df-bit" if host.df_bit else "", + ) + for host in self.inputs.hosts + ] @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyReachability.""" - failures = [] - for command in self.instance_commands: - src = command.params.source - dst = command.params.destination - repeat = command.params.repeat + self.result.is_success() - if f"{repeat} received" not in command.json_output["messages"][0]: - failures.append((str(src), str(dst))) + for command, host in zip(self.instance_commands, self.inputs.hosts): + # Verifies the network is reachable + if host.reachable and f"{host.repeat} received" not in command.json_output["messages"][0]: + self.result.is_failure(f"{host} - Unreachable") - if not failures: - self.result.is_success() - else: - self.result.is_failure(f"Connectivity test failed for the following source-destination pairs: {failures}") + # Verifies the network is unreachable. + if not host.reachable and f"{host.repeat} received" in command.json_output["messages"][0]: + self.result.is_failure(f"{host} - Destination is expected to be unreachable but found reachable") class VerifyLLDPNeighbors(AntaTest): - """Verifies that the provided LLDP neighbors are present and connected with the correct configuration. + """Verifies the connection status of the specified LLDP (Link Layer Discovery Protocol) neighbors. + + This test performs the following checks for each specified LLDP neighbor: + + 1. Confirming matching ports on both local and neighboring devices. + 2. Ensuring compatibility of device names and interface identifiers. + 3. Verifying neighbor configurations match expected values per interface; extra neighbors are ignored. Expected Results ---------------- - * Success: The test will pass if each of the provided LLDP neighbors is present and connected to the specified port and device. + * Success: The test will pass if all the provided LLDP neighbors are present and correctly connected to the specified port and device. * Failure: The test will fail if any of the following conditions are met: - - The provided LLDP neighbor is not found. - - The system name or port of the LLDP neighbor does not match the provided information. + - The provided LLDP neighbor is not found in the LLDP table. + - The system name or port of the LLDP neighbor does not match the expected information. Examples -------- @@ -109,60 +135,37 @@ class VerifyLLDPNeighbors(AntaTest): ``` """ - name = "VerifyLLDPNeighbors" - description = "Verifies that the provided LLDP neighbors are connected properly." categories: ClassVar[list[str]] = ["connectivity"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lldp neighbors detail", revision=1)] class Input(AntaTest.Input): """Input model for the VerifyLLDPNeighbors test.""" - neighbors: list[Neighbor] + neighbors: list[LLDPNeighbor] """List of LLDP neighbors.""" - - class Neighbor(BaseModel): - """Model for an LLDP neighbor.""" - - port: Interface - """LLDP port.""" - neighbor_device: str - """LLDP neighbor device.""" - neighbor_port: Interface - """LLDP neighbor port.""" + Neighbor: ClassVar[type[Neighbor]] = Neighbor + """To maintain backward compatibility.""" @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyLLDPNeighbors.""" - failures: dict[str, list[str]] = {} + self.result.is_success() output = self.instance_commands[0].json_output["lldpNeighbors"] - for neighbor in self.inputs.neighbors: if neighbor.port not in output: - failures.setdefault("Port(s) not configured", []).append(neighbor.port) + self.result.is_failure(f"{neighbor} - Port not found") continue if len(lldp_neighbor_info := output[neighbor.port]["lldpNeighborInfo"]) == 0: - failures.setdefault("No LLDP neighbor(s) on port(s)", []).append(neighbor.port) + self.result.is_failure(f"{neighbor} - No LLDP neighbors") continue - if not any( + # Check if the system name and neighbor port matches + match_found = any( info["systemName"] == neighbor.neighbor_device and info["neighborInterfaceInfo"]["interfaceId_v2"] == neighbor.neighbor_port for info in lldp_neighbor_info - ): - neighbors = "\n ".join( - [ - f"{neighbor[0]}_{neighbor[1]}" - for neighbor in [(info["systemName"], info["neighborInterfaceInfo"]["interfaceId_v2"]) for info in lldp_neighbor_info] - ] - ) - failures.setdefault("Wrong LLDP neighbor(s) on port(s)", []).append(f"{neighbor.port}\n {neighbors}") - - if not failures: - self.result.is_success() - else: - failure_messages = [] - for failure_type, ports in failures.items(): - ports_str = "\n ".join(ports) - failure_messages.append(f"{failure_type}:\n {ports_str}") - self.result.is_failure("\n".join(failure_messages)) + ) + if not match_found: + failure_msg = [f"{info['systemName']}/{info['neighborInterfaceInfo']['interfaceId_v2']}" for info in lldp_neighbor_info] + self.result.is_failure(f"{neighbor} - Wrong LLDP neighbors: {', '.join(failure_msg)}") diff --git a/anta/tests/cvx.py b/anta/tests/cvx.py new file mode 100644 index 000000000..d80f9b90a --- /dev/null +++ b/anta/tests/cvx.py @@ -0,0 +1,290 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module related to the CVX tests.""" + +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar, Literal + +from anta.custom_types import PositiveInteger +from anta.models import AntaCommand, AntaTest +from anta.tools import get_value + +if TYPE_CHECKING: + from anta.models import AntaTemplate +from anta.input_models.cvx import CVXPeers + + +class VerifyMcsClientMounts(AntaTest): + """Verify if all MCS client mounts are in mountStateMountComplete. + + Expected Results + ---------------- + * Success: The test will pass if the MCS mount status on MCS Clients are mountStateMountComplete. + * Failure: The test will fail even if one switch's MCS client mount status is not mountStateMountComplete. + + Examples + -------- + ```yaml + anta.tests.cvx: + - VerifyMcsClientMounts: + ``` + """ + + categories: ClassVar[list[str]] = ["cvx"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management cvx mounts", revision=1)] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyMcsClientMounts.""" + command_output = self.instance_commands[0].json_output + self.result.is_success() + mount_states = command_output["mountStates"] + mcs_mount_state_detected = False + for mount_state in mount_states: + if not mount_state["type"].startswith("Mcs"): + continue + mcs_mount_state_detected = True + if (state := mount_state["state"]) != "mountStateMountComplete": + self.result.is_failure(f"MCS Client mount states are not valid - Expected: mountStateMountComplete Actual: {state}") + + if not mcs_mount_state_detected: + self.result.is_failure("MCS Client mount states are not present") + + +class VerifyManagementCVX(AntaTest): + """Verifies the management CVX global status. + + Expected Results + ---------------- + * Success: The test will pass if the management CVX global status matches the expected status. + * Failure: The test will fail if the management CVX global status does not match the expected status. + + + Examples + -------- + ```yaml + anta.tests.cvx: + - VerifyManagementCVX: + enabled: true + ``` + """ + + categories: ClassVar[list[str]] = ["cvx"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management cvx", revision=3)] + + class Input(AntaTest.Input): + """Input model for the VerifyManagementCVX test.""" + + enabled: bool + """Whether management CVX must be enabled (True) or disabled (False).""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyManagementCVX.""" + command_output = self.instance_commands[0].json_output + self.result.is_success() + if (cluster_state := get_value(command_output, "clusterStatus.enabled")) != self.inputs.enabled: + if cluster_state is None: + self.result.is_failure("Management CVX status - Not configured") + return + cluster_state = "enabled" if cluster_state else "disabled" + self.inputs.enabled = "enabled" if self.inputs.enabled else "disabled" + self.result.is_failure(f"Management CVX status is not valid: Expected: {self.inputs.enabled} Actual: {cluster_state}") + + +class VerifyMcsServerMounts(AntaTest): + """Verify if all MCS server mounts are in a MountComplete state. + + Expected Results + ---------------- + * Success: The test will pass if all the MCS mount status on MCS server are mountStateMountComplete. + * Failure: The test will fail even if any MCS server mount status is not mountStateMountComplete. + + Examples + -------- + ```yaml + anta.tests.cvx: + + - VerifyMcsServerMounts: + connections_count: 100 + ``` + """ + + categories: ClassVar[list[str]] = ["cvx"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show cvx mounts", revision=1)] + + mcs_path_types: ClassVar[list[str]] = ["Mcs::ApiConfigRedundancyStatus", "Mcs::ActiveFlows", "Mcs::Client::Status"] + """The list of expected MCS path types to verify.""" + + class Input(AntaTest.Input): + """Input model for the VerifyMcsServerMounts test.""" + + connections_count: int + """The expected number of active CVX Connections with mountStateMountComplete""" + + def validate_mount_states(self, mount: dict[str, Any], hostname: str) -> None: + """Validate the mount states of a given mount.""" + mount_states = mount["mountStates"][0] + + if (num_path_states := len(mount_states["pathStates"])) != (expected_num := len(self.mcs_path_types)): + self.result.is_failure(f"Host: {hostname} - Incorrect number of mount path states - Expected: {expected_num} Actual: {num_path_states}") + + for path in mount_states["pathStates"]: + if (path_type := path.get("type")) not in self.mcs_path_types: + self.result.is_failure(f"Host: {hostname} - Unexpected MCS path type - Expected: {', '.join(self.mcs_path_types)} Actual: {path_type}") + if (path_state := path.get("state")) != "mountStateMountComplete": + self.result.is_failure( + f"Host: {hostname} Path Type: {path_type} - MCS server mount state is not valid - Expected: mountStateMountComplete Actual:{path_state}" + ) + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyMcsServerMounts.""" + command_output = self.instance_commands[0].json_output + self.result.is_success() + active_count = 0 + + if not (connections := command_output.get("connections")): + self.result.is_failure("CVX connections are not available") + return + + for connection in connections: + mounts = connection.get("mounts", []) + hostname = connection["hostname"] + + mcs_mounts = [mount for mount in mounts if mount["service"] == "Mcs"] + + if not mounts: + self.result.is_failure(f"Host: {hostname} - No mount status found") + continue + + if not mcs_mounts: + self.result.is_failure(f"Host: {hostname} - MCS mount state not detected") + else: + for mount in mcs_mounts: + self.validate_mount_states(mount, hostname) + active_count += 1 + + if active_count != self.inputs.connections_count: + self.result.is_failure(f"Incorrect CVX successful connections count - Expected: {self.inputs.connections_count} Actual: {active_count}") + + +class VerifyActiveCVXConnections(AntaTest): + """Verifies the number of active CVX Connections. + + Expected Results + ---------------- + * Success: The test will pass if number of connections is equal to the expected number of connections. + * Failure: The test will fail otherwise. + + Examples + -------- + ```yaml + anta.tests.cvx: + - VerifyActiveCVXConnections: + connections_count: 100 + ``` + """ + + categories: ClassVar[list[str]] = ["cvx"] + # TODO: @gmuloc - cover "% Unavailable command (controller not ready)" + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show cvx connections brief", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyActiveCVXConnections test.""" + + connections_count: PositiveInteger + """The expected number of active CVX Connections.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyActiveCVXConnections.""" + command_output = self.instance_commands[0].json_output + self.result.is_success() + + if not (connections := command_output.get("connections")): + self.result.is_failure("CVX connections are not available") + return + + active_count = len([connection for connection in connections if connection.get("oobConnectionActive")]) + + if self.inputs.connections_count != active_count: + self.result.is_failure(f"Incorrect CVX active connections count - Expected: {self.inputs.connections_count} Actual: {active_count}") + + +class VerifyCVXClusterStatus(AntaTest): + """Verifies the CVX Server Cluster status. + + Expected Results + ---------------- + * Success: The test will pass if all of the following conditions is met: + - CVX Enabled state is true + - Cluster Mode is true + - Role is either Master or Standby. + - peer_status matches defined state + * Failure: The test will fail if any of the success conditions is not met. + + Examples + -------- + ```yaml + anta.tests.cvx: + - VerifyCVXClusterStatus: + role: Master + peer_status: + - peer_name : cvx-red-2 + registration_state: Registration complete + - peer_name: cvx-red-3 + registration_state: Registration error + ``` + """ + + categories: ClassVar[list[str]] = ["cvx"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show cvx", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyCVXClusterStatus test.""" + + role: Literal["Master", "Standby", "Disconnected"] = "Master" + peer_status: list[CVXPeers] + + @AntaTest.anta_test + def test(self) -> None: + """Run the main test for VerifyCVXClusterStatus.""" + command_output = self.instance_commands[0].json_output + self.result.is_success() + + # Validate Server enabled status + if not command_output.get("enabled"): + self.result.is_failure("CVX Server status is not enabled") + + # Validate cluster status and mode + if not (cluster_status := command_output.get("clusterStatus")) or not command_output.get("clusterMode"): + self.result.is_failure("CVX Server is not a cluster") + return + + # Check cluster role + if (cluster_role := cluster_status.get("role")) != self.inputs.role: + self.result.is_failure(f"CVX Role is not valid: Expected: {self.inputs.role} Actual: {cluster_role}") + return + + # Validate peer status + peer_cluster = cluster_status.get("peerStatus", {}) + + # Check peer count + if (num_of_peers := len(peer_cluster)) != (expected_num_of_peers := len(self.inputs.peer_status)): + self.result.is_failure(f"Unexpected number of peers - Expected: {expected_num_of_peers} Actual: {num_of_peers}") + + # Check each peer + for peer in self.inputs.peer_status: + # Retrieve the peer status from the peer cluster + if (eos_peer_status := get_value(peer_cluster, peer.peer_name, separator="..")) is None: + self.result.is_failure(f"{peer.peer_name} - Not present") + continue + + # Validate the registration state of the peer + if (peer_reg_state := eos_peer_status.get("registrationState")) != peer.registration_state: + self.result.is_failure(f"{peer.peer_name} - Invalid registration state - Expected: {peer.registration_state} Actual: {peer_reg_state}") diff --git a/anta/tests/evpn.py b/anta/tests/evpn.py new file mode 100644 index 000000000..a72ca03c3 --- /dev/null +++ b/anta/tests/evpn.py @@ -0,0 +1,185 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module related to EVPN tests.""" + +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from typing import Any, ClassVar + +from anta.input_models.evpn import EVPNPath, EVPNRoute, EVPNType5Prefix +from anta.models import AntaCommand, AntaTemplate, AntaTest + + +class VerifyEVPNType5Routes(AntaTest): + """Verifies EVPN Type-5 routes for given IP prefixes and VNIs. + + It supports multiple levels of verification based on the provided input: + + 1. **Prefix/VNI only:** Verifies there is at least one 'active' and 'valid' path across all + Route Distinguishers (RDs) learning the given prefix and VNI. + 2. **Specific Routes (RD/Domain):** Verifies that routes matching the specified RDs and domains + exist for the prefix/VNI. For each specified route, it checks if at least one of its paths + is 'active' and 'valid'. + 3. **Specific Paths (Nexthop/Route Targets):** Verifies that specific paths exist within a + specified route (RD/Domain). For each specified path criteria (nexthop and optional route targets), + it finds all matching paths received from the peer and checks if at least one of these + matching paths is 'active' and 'valid'. The route targets check ensures all specified RTs + are present in the path's extended communities (subset check). + + Expected Results + ---------------- + * Success: + - If only prefix/VNI is provided: The prefix/VNI exists in the EVPN table + and has at least one active and valid path across all RDs. + - If specific routes are provided: All specified routes (by RD/Domain) are found, + and each has at least one active and valid path (if paths are not specified for the route). + - If specific paths are provided: All specified routes are found, and for each specified path criteria (nexthop/RTs), + at least one matching path exists and is active and valid. + * Failure: + - No EVPN Type-5 routes are found for the given prefix/VNI. + - A specified route (RD/Domain) is not found. + - No active and valid path is found when required (either globally for the prefix, per specified route, or per specified path criteria). + - A specified path criteria (nexthop/RTs) does not match any received paths for the route. + + Examples + -------- + ```yaml + anta.tests.evpn: + - VerifyEVPNType5Routes: + prefixes: + # At least one active/valid path across all RDs + - address: 192.168.10.0/24 + vni: 10 + # Specific routes each has at least one active/valid path + - address: 192.168.20.0/24 + vni: 20 + routes: + - rd: "10.0.0.1:20" + domain: local + - rd: "10.0.0.2:20" + domain: remote + # At least one active/valid path matching the nexthop + - address: 192.168.30.0/24 + vni: 30 + routes: + - rd: "10.0.0.1:30" + domain: local + paths: + - nexthop: 10.1.1.1 + # At least one active/valid path matching nexthop and specific RTs + - address: 192.168.40.0/24 + vni: 40 + routes: + - rd: "10.0.0.1:40" + domain: local + paths: + - nexthop: 10.1.1.1 + route_targets: + - "40:40" + ``` + """ + + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show bgp evpn route-type ip-prefix {address} vni {vni}", revision=2)] + + class Input(AntaTest.Input): + """Input model for the VerifyEVPNType5Routes test.""" + + prefixes: list[EVPNType5Prefix] + """List of EVPN Type-5 prefixes to verify.""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each EVPN Type-5 prefix in the input list.""" + return [template.render(address=str(prefix.address), vni=prefix.vni) for prefix in self.inputs.prefixes] + + # NOTE: The following static methods can be moved at the module level if needed for other EVPN tests + @staticmethod + def _get_all_paths(evpn_routes_data: dict[str, Any]) -> list[dict[str, Any]]: + """Extract all 'evpnRoutePaths' from the entire 'evpnRoutes' dictionary.""" + all_paths = [] + for route_data in evpn_routes_data.values(): + all_paths.extend(route_data["evpnRoutePaths"]) + return all_paths + + @staticmethod + def _find_route(evpn_routes_data: dict[str, Any], rd_to_find: str, domain_to_find: str) -> dict[str, Any] | None: + """Find the specific route block for a given RD and domain.""" + for route_data in evpn_routes_data.values(): + if route_data["routeKeyDetail"].get("rd") == rd_to_find and route_data["routeKeyDetail"].get("domain") == domain_to_find: + return route_data + return None + + @staticmethod + def _find_paths(paths: list[dict[str, Any]], nexthop: str, route_targets: list[str] | None = None) -> list[dict[str, Any]]: + """Find all matching paths for a given nexthop and RTs.""" + route_targets = [f"Route-Target-AS:{rt}" for rt in route_targets] if route_targets is not None else [] + return [path for path in paths if path["nextHop"] == nexthop and set(route_targets).issubset(set(path["routeDetail"]["extCommunities"]))] + + @staticmethod + def _has_active_valid_path(paths: list[dict[str, Any]]) -> bool: + """Check if any path in the list is active and valid.""" + return any(path["routeType"]["active"] and path["routeType"]["valid"] for path in paths) + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyEVPNType5Routes.""" + self.result.is_success() + + for command, prefix_input in zip(self.instance_commands, self.inputs.prefixes): + # Verify that the prefix is in the BGP EVPN table + evpn_routes_data = command.json_output.get("evpnRoutes") + if not evpn_routes_data: + self.result.is_failure(f"{prefix_input} - No EVPN Type-5 routes found") + continue + + # Delegate verification logic for this prefix + self._verify_routes_for_prefix(prefix_input, evpn_routes_data) + + def _verify_routes_for_prefix(self, prefix_input: EVPNType5Prefix, evpn_routes_data: dict[str, Any]) -> None: + """Verify EVPN routes for an input prefix.""" + # Case: routes not provided for the prefix, check that at least one EVPN Type-5 route + # has at least one active and valid path across all learned routes from all RDs combined + if prefix_input.routes is None: + all_paths = self._get_all_paths(evpn_routes_data) + if not self._has_active_valid_path(all_paths): + self.result.is_failure(f"{prefix_input} - No active and valid path found across all RDs") + return + + # Case: routes *is* provided, check each specified route + for route_input in prefix_input.routes: + # Try to find a route with matching RD and domain + route_data = self._find_route(evpn_routes_data, route_input.rd, route_input.domain) + if route_data is None: + self.result.is_failure(f"{prefix_input} {route_input} - Route not found") + continue + + # Route found, now check its paths based on route_input criteria + self._verify_paths_for_route(prefix_input, route_input, route_data) + + def _verify_paths_for_route(self, prefix_input: EVPNType5Prefix, route_input: EVPNRoute, route_data: dict[str, Any]) -> None: + """Verify paths for a specific EVPN route (route_data) based on route_input criteria.""" + route_paths = route_data["evpnRoutePaths"] + + # Case: paths not provided for the route, check that at least one path is active/valid + if route_input.paths is None: + if not self._has_active_valid_path(route_paths): + self.result.is_failure(f"{prefix_input} {route_input} - No active and valid path found") + return + + # Case: paths *is* provided, check each specified path criteria + for path_input in route_input.paths: + self._verify_single_path(prefix_input, route_input, path_input, route_paths) + + def _verify_single_path(self, prefix_input: EVPNType5Prefix, route_input: EVPNRoute, path_input: EVPNPath, available_paths: list[dict[str, Any]]) -> None: + """Verify if at least one active/valid path exists among available_paths matching the path_input criteria.""" + # Try to find all paths matching nexthop and RTs criteria from the available paths for this route + matching_paths = self._find_paths(available_paths, path_input.nexthop, path_input.route_targets) + if not matching_paths: + self.result.is_failure(f"{prefix_input} {route_input} {path_input} - Path not found") + return + + # Check that at least one matching path is active/valid + if not self._has_active_valid_path(matching_paths): + self.result.is_failure(f"{prefix_input} {route_input} {path_input} - No active and valid path found") diff --git a/anta/tests/field_notices.py b/anta/tests/field_notices.py index 34ea1959e..ed8022fa3 100644 --- a/anta/tests/field_notices.py +++ b/anta/tests/field_notices.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to field notices tests.""" @@ -34,12 +34,11 @@ class VerifyFieldNotice44Resolution(AntaTest): ``` """ - name = "VerifyFieldNotice44Resolution" description = "Verifies that the device is using the correct Aboot version per FN0044." categories: ClassVar[list[str]] = ["field notices"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)] - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyFieldNotice44Resolution.""" @@ -97,27 +96,28 @@ def test(self) -> None: for variant in variants: model = model.replace(variant, "") if model not in devices: - self.result.is_skipped("device is not impacted by FN044") + self.result.is_skipped("Device is not impacted by FN044") return for component in command_output["details"]["components"]: if component["name"] == "Aboot": aboot_version = component["version"].split("-")[2] + break + else: + self.result.is_failure("Aboot component not found") + return + self.result.is_success() incorrect_aboot_version = ( - aboot_version.startswith("4.0.") - and int(aboot_version.split(".")[2]) < 7 - or aboot_version.startswith("4.1.") - and int(aboot_version.split(".")[2]) < 1 + (aboot_version.startswith("4.0.") and int(aboot_version.split(".")[2]) < 7) + or (aboot_version.startswith("4.1.") and int(aboot_version.split(".")[2]) < 1) or ( - aboot_version.startswith("6.0.") - and int(aboot_version.split(".")[2]) < 9 - or aboot_version.startswith("6.1.") - and int(aboot_version.split(".")[2]) < 7 + (aboot_version.startswith("6.0.") and int(aboot_version.split(".")[2]) < 9) + or (aboot_version.startswith("6.1.") and int(aboot_version.split(".")[2]) < 7) ) ) if incorrect_aboot_version: - self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})") + self.result.is_failure(f"Device is running incorrect version of aboot {aboot_version}") class VerifyFieldNotice72Resolution(AntaTest): @@ -138,12 +138,11 @@ class VerifyFieldNotice72Resolution(AntaTest): ``` """ - name = "VerifyFieldNotice72Resolution" description = "Verifies if the device is exposed to FN0072, and if the issue has been mitigated." categories: ClassVar[list[str]] = ["field notices"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)] - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyFieldNotice72Resolution.""" @@ -191,5 +190,4 @@ def test(self) -> None: self.result.is_success("FN72 is mitigated") return # We should never hit this point - self.result.is_error("Error in running test - FixedSystemvrm1 not found") - return + self.result.is_failure("Error in running test - Component FixedSystemvrm1 not found in 'show version'") diff --git a/anta/tests/flow_tracking.py b/anta/tests/flow_tracking.py new file mode 100644 index 000000000..57b42549b --- /dev/null +++ b/anta/tests/flow_tracking.py @@ -0,0 +1,141 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module related to the flow tracking tests.""" + +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from typing import ClassVar + +from anta.decorators import skip_on_platforms +from anta.input_models.flow_tracking import FlowTracker +from anta.models import AntaCommand, AntaTemplate, AntaTest +from anta.tools import get_value + + +def validate_exporters(exporters: list[dict[str, str]], tracker_info: dict[str, str]) -> list[str]: + """Validate the exporter configurations against the tracker info. + + Parameters + ---------- + exporters + The list of expected exporter configurations. + tracker_info + The actual tracker info from the command output. + + Returns + ------- + list + List of failure messages for any exporter configuration that does not match. + """ + failure_messages = [] + for exporter in exporters: + exporter_name = exporter.name + actual_exporter_info = tracker_info["exporters"].get(exporter_name) + if not actual_exporter_info: + failure_messages.append(f"{exporter} - Not configured") + continue + local_interface = actual_exporter_info["localIntf"] + template_interval = actual_exporter_info["templateInterval"] + + if local_interface != exporter.local_interface: + failure_messages.append(f"{exporter} - Incorrect local interface - Expected: {exporter.local_interface} Actual: {local_interface}") + + if template_interval != exporter.template_interval: + failure_messages.append(f"{exporter} - Incorrect template interval - Expected: {exporter.template_interval} Actual: {template_interval}") + return failure_messages + + +class VerifyHardwareFlowTrackerStatus(AntaTest): + """Verifies the hardware flow tracking state. + + This test performs the following checks: + + 1. Confirms that hardware flow tracking is running. + 2. For each specified flow tracker: + - Confirms that the tracker is active. + - Optionally, checks the tracker interval/timeout configuration. + - Optionally, verifies the tracker exporter configuration + + Expected Results + ---------------- + * Success: The test will pass if all of the following conditions are met: + - Hardware flow tracking is running. + - For each specified flow tracker: + - The flow tracker is active. + - The tracker interval/timeout matches the expected values, if provided. + - The exporter configuration matches the expected values, if provided. + * Failure: The test will fail if any of the following conditions are met: + - Hardware flow tracking is not running. + - For any specified flow tracker: + - The flow tracker is not active. + - The tracker interval/timeout does not match the expected values, if provided. + - The exporter configuration does not match the expected values, if provided. + + Examples + -------- + ```yaml + anta.tests.flow_tracking: + - VerifyHardwareFlowTrackerStatus: + trackers: + - name: FLOW-TRACKER + record_export: + on_inactive_timeout: 70000 + on_interval: 300000 + exporters: + - name: CV-TELEMETRY + local_interface: Loopback0 + template_interval: 3600000 + ``` + """ + + categories: ClassVar[list[str]] = ["flow tracking"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show flow tracking hardware", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyHardwareFlowTrackerStatus test.""" + + trackers: list[FlowTracker] + """List of flow trackers to verify.""" + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyHardwareFlowTrackerStatus.""" + self.result.is_success() + + command_output = self.instance_commands[0].json_output + # Check if hardware flow tracking is configured + if not command_output.get("running"): + self.result.is_failure("Hardware flow tracking is not running") + return + + for tracker in self.inputs.trackers: + # Check if the input hardware tracker is configured + if not (tracker_info := get_value(command_output["trackers"], f"{tracker.name}")): + self.result.is_failure(f"{tracker} - Not found") + continue + + # Check if the input hardware tracker is active + if not tracker_info.get("active"): + self.result.is_failure(f"{tracker} - Disabled") + continue + + # Check the input hardware tracker timeouts + if tracker.record_export: + inactive_interval = tracker.record_export.on_inactive_timeout + on_interval = tracker.record_export.on_interval + act_inactive = tracker_info.get("inactiveTimeout") + act_interval = tracker_info.get("activeInterval") + if not all([inactive_interval == act_inactive, on_interval == act_interval]): + self.result.is_failure( + f"{tracker} {tracker.record_export} - Incorrect timers - Inactive Timeout: {act_inactive} OnActive Interval: {act_interval}" + ) + + # Check the input hardware tracker exporters configuration + if tracker.exporters: + failure_messages = validate_exporters(tracker.exporters, tracker_info) + for message in failure_messages: + self.result.is_failure(f"{tracker} {message}") diff --git a/anta/tests/greent.py b/anta/tests/greent.py index b7632422b..345f01b76 100644 --- a/anta/tests/greent.py +++ b/anta/tests/greent.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to GreenT (Postcard Telemetry) tests.""" @@ -25,11 +25,11 @@ class VerifyGreenTCounters(AntaTest): -------- ```yaml anta.tests.greent: - - VerifyGreenT: + - VerifyGreenTCounters: ``` + """ - name = "VerifyGreenTCounters" description = "Verifies if the GreenT counters are incremented." categories: ClassVar[list[str]] = ["greent"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard counters", revision=1)] @@ -57,12 +57,12 @@ class VerifyGreenT(AntaTest): -------- ```yaml anta.tests.greent: - - VerifyGreenTCounters: + - VerifyGreenT: ``` + """ - name = "VerifyGreenT" - description = "Verifies if a GreenT policy is created." + description = "Verifies if a GreenT policy other than the default is created." categories: ClassVar[list[str]] = ["greent"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard policy profile", revision=1)] diff --git a/anta/tests/hardware.py b/anta/tests/hardware.py index 569c180d7..58f2b36dc 100644 --- a/anta/tests/hardware.py +++ b/anta/tests/hardware.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to the hardware or environment tests.""" @@ -7,8 +7,9 @@ # mypy: disable-error-code=attr-defined from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, ClassVar, Literal +from anta.custom_types import PositiveInteger, PowerSupplyFanStatus, PowerSupplyStatus from anta.decorators import skip_on_platforms from anta.models import AntaCommand, AntaTest @@ -36,8 +37,6 @@ class VerifyTransceiversManufacturers(AntaTest): ``` """ - name = "VerifyTransceiversManufacturers" - description = "Verifies if all transceivers come from approved manufacturers." categories: ClassVar[list[str]] = ["hardware"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show inventory", revision=2)] @@ -47,18 +46,18 @@ class Input(AntaTest.Input): manufacturers: list[str] """List of approved transceivers manufacturers.""" - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyTransceiversManufacturers.""" + self.result.is_success() command_output = self.instance_commands[0].json_output - wrong_manufacturers = { - interface: value["mfgName"] for interface, value in command_output["xcvrSlots"].items() if value["mfgName"] not in self.inputs.manufacturers - } - if not wrong_manufacturers: - self.result.is_success() - else: - self.result.is_failure(f"Some transceivers are from unapproved manufacturers: {wrong_manufacturers}") + for interface, value in command_output["xcvrSlots"].items(): + if value["mfgName"] not in self.inputs.manufacturers: + self.result.is_failure( + f"Interface: {interface} - Transceiver is from unapproved manufacturers - Expected: {', '.join(self.inputs.manufacturers)}" + f" Actual: {value['mfgName']}" + ) class VerifyTemperature(AntaTest): @@ -77,21 +76,18 @@ class VerifyTemperature(AntaTest): ``` """ - name = "VerifyTemperature" - description = "Verifies the device temperature." categories: ClassVar[list[str]] = ["hardware"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature", revision=1)] - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyTemperature.""" + self.result.is_success() command_output = self.instance_commands[0].json_output temperature_status = command_output.get("systemStatus", "") - if temperature_status == "temperatureOk": - self.result.is_success() - else: - self.result.is_failure(f"Device temperature exceeds acceptable limits. Current system status: '{temperature_status}'") + if temperature_status != "temperatureOk": + self.result.is_failure(f"Device temperature exceeds acceptable limits - Expected: temperatureOk Actual: {temperature_status}") class VerifyTransceiversTemperature(AntaTest): @@ -110,29 +106,21 @@ class VerifyTransceiversTemperature(AntaTest): ``` """ - name = "VerifyTransceiversTemperature" - description = "Verifies the transceivers temperature." categories: ClassVar[list[str]] = ["hardware"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature transceiver", revision=1)] - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyTransceiversTemperature.""" + self.result.is_success() command_output = self.instance_commands[0].json_output sensors = command_output.get("tempSensors", "") - wrong_sensors = { - sensor["name"]: { - "hwStatus": sensor["hwStatus"], - "alertCount": sensor["alertCount"], - } - for sensor in sensors - if sensor["hwStatus"] != "ok" or sensor["alertCount"] != 0 - } - if not wrong_sensors: - self.result.is_success() - else: - self.result.is_failure(f"The following sensors are operating outside the acceptable temperature range or have raised alerts: {wrong_sensors}") + for sensor in sensors: + if sensor["hwStatus"] != "ok": + self.result.is_failure(f"Sensor: {sensor['name']} - Invalid hardware state - Expected: ok Actual: {sensor['hwStatus']}") + if sensor["alertCount"] != 0: + self.result.is_failure(f"Sensor: {sensor['name']} - Incorrect alert counter - Expected: 0 Actual: {sensor['alertCount']}") class VerifyEnvironmentSystemCooling(AntaTest): @@ -151,12 +139,10 @@ class VerifyEnvironmentSystemCooling(AntaTest): ``` """ - name = "VerifyEnvironmentSystemCooling" - description = "Verifies the system cooling status." categories: ClassVar[list[str]] = ["hardware"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", revision=1)] - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyEnvironmentSystemCooling.""" @@ -164,7 +150,7 @@ def test(self) -> None: sys_status = command_output.get("systemStatus", "") self.result.is_success() if sys_status != "coolingOk": - self.result.is_failure(f"Device system cooling is not OK: '{sys_status}'") + self.result.is_failure(f"Device system cooling status invalid - Expected: coolingOk Actual: {sys_status}") class VerifyEnvironmentCooling(AntaTest): @@ -185,18 +171,16 @@ class VerifyEnvironmentCooling(AntaTest): ``` """ - name = "VerifyEnvironmentCooling" - description = "Verifies the status of power supply fans and all fan trays." categories: ClassVar[list[str]] = ["hardware"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", revision=1)] class Input(AntaTest.Input): """Input model for the VerifyEnvironmentCooling test.""" - states: list[str] + states: list[PowerSupplyFanStatus] """List of accepted states of fan status.""" - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyEnvironmentCooling.""" @@ -206,21 +190,26 @@ def test(self) -> None: for power_supply in command_output.get("powerSupplySlots", []): for fan in power_supply.get("fans", []): if (state := fan["status"]) not in self.inputs.states: - self.result.is_failure(f"Fan {fan['label']} on PowerSupply {power_supply['label']} is: '{state}'") + self.result.is_failure( + f"Power Slot: {power_supply['label']} Fan: {fan['label']} - Invalid state - Expected: {', '.join(self.inputs.states)} Actual: {state}" + ) # Then go through fan trays for fan_tray in command_output.get("fanTraySlots", []): for fan in fan_tray.get("fans", []): if (state := fan["status"]) not in self.inputs.states: - self.result.is_failure(f"Fan {fan['label']} on Fan Tray {fan_tray['label']} is: '{state}'") + self.result.is_failure( + f"Fan Tray: {fan_tray['label']} Fan: {fan['label']} - Invalid state - Expected: {', '.join(self.inputs.states)} Actual: {state}" + ) class VerifyEnvironmentPower(AntaTest): - """Verifies the power supplies status. + """Verifies the power supplies state and input voltage. Expected Results ---------------- - * Success: The test will pass if the power supplies status are within the accepted states list. - * Failure: The test will fail if some power supplies status is not within the accepted states list. + * Success: The test will pass if all power supplies are in an accepted state and their input voltage is greater than or equal to `min_input_voltage` + (if provided). + * Failure: The test will fail if any power supply is in an unaccepted state or its input voltage is less than `min_input_voltage` (if provided). Examples -------- @@ -232,34 +221,37 @@ class VerifyEnvironmentPower(AntaTest): ``` """ - name = "VerifyEnvironmentPower" - description = "Verifies the power supplies status." categories: ClassVar[list[str]] = ["hardware"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment power", revision=1)] class Input(AntaTest.Input): """Input model for the VerifyEnvironmentPower test.""" - states: list[str] - """List of accepted states list of power supplies status.""" + states: list[PowerSupplyStatus] + """List of accepted states for power supplies.""" + min_input_voltage: PositiveInteger | None = None + """Optional minimum input voltage (Volts) to verify.""" - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyEnvironmentPower.""" + self.result.is_success() command_output = self.instance_commands[0].json_output power_supplies = command_output.get("powerSupplies", "{}") - wrong_power_supplies = { - powersupply: {"state": value["state"]} for powersupply, value in dict(power_supplies).items() if value["state"] not in self.inputs.states - } - if not wrong_power_supplies: - self.result.is_success() - else: - self.result.is_failure(f"The following power supplies status are not in the accepted states list: {wrong_power_supplies}") + for power_supply, value in dict(power_supplies).items(): + if (state := value["state"]) not in self.inputs.states: + self.result.is_failure(f"Power Slot: {power_supply} - Invalid power supplies state - Expected: {', '.join(self.inputs.states)} Actual: {state}") + + # Verify if the power supply voltage is greater than the minimum input voltage + if self.inputs.min_input_voltage and value["inputVoltage"] < self.inputs.min_input_voltage: + self.result.is_failure( + f"Power Supply: {power_supply} - Input voltage mismatch - Expected: > {self.inputs.min_input_voltage} Actual: {value['inputVoltage']}" + ) class VerifyAdverseDrops(AntaTest): - """Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches (Arad/Jericho chips). + """Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches. Expected Results ---------------- @@ -274,18 +266,66 @@ class VerifyAdverseDrops(AntaTest): ``` """ - name = "VerifyAdverseDrops" - description = "Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches." categories: ClassVar[list[str]] = ["hardware"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware counter drop", revision=1)] - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyAdverseDrops.""" + self.result.is_success() command_output = self.instance_commands[0].json_output total_adverse_drop = command_output.get("totalAdverseDrops", "") - if total_adverse_drop == 0: - self.result.is_success() - else: - self.result.is_failure(f"Device totalAdverseDrops counter is: '{total_adverse_drop}'") + if total_adverse_drop != 0: + self.result.is_failure(f"Incorrect total adverse drops counter - Expected: 0 Actual: {total_adverse_drop}") + + +class VerifySupervisorRedundancy(AntaTest): + """Verifies the redundancy protocol configured on the active supervisor. + + Expected Results + ---------------- + * Success: The test will pass if the expected redundancy protocol is configured and operational, and if switchover is ready. + * Failure: The test will fail if the expected redundancy protocol is not configured, not operational, or if switchover is not ready. + * Skipped: The test will be skipped if the peer supervisor card is not inserted. + + Examples + -------- + ```yaml + anta.tests.hardware: + - VerifySupervisorRedundancy: + ``` + """ + + categories: ClassVar[list[str]] = ["hardware"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show redundancy status", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifySupervisorRedundancy test.""" + + redundency_proto: Literal["sso", "rpr", "simplex"] = "sso" + """Configured redundancy protocol.""" + + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifySupervisorRedundancy.""" + self.result.is_success() + command_output = self.instance_commands[0].json_output + + # Verify peer supervisor card insertion + if command_output["peerState"] == "notInserted": + self.result.is_skipped("Peer supervisor card not inserted") + return + + # Verify that the expected redundancy protocol is configured + if (act_proto := command_output["configuredProtocol"]) != self.inputs.redundency_proto: + self.result.is_failure(f"Configured redundancy protocol mismatch - Expected {self.inputs.redundency_proto} Actual: {act_proto}") + + # Verify that the expected redundancy protocol configured and operational + elif (act_proto := command_output["operationalProtocol"]) != self.inputs.redundency_proto: + self.result.is_failure(f"Operational redundancy protocol mismatch - Expected {self.inputs.redundency_proto} Actual: {act_proto}") + + # Verify that the expected redundancy protocol configured, operational and switchover ready + elif not command_output["switchoverReady"]: + self.result.is_failure(f"Redundancy protocol switchover status mismatch - Expected: True Actual: {command_output['switchoverReady']}") diff --git a/anta/tests/interfaces.py b/anta/tests/interfaces.py index 5e0f0838e..d2423e912 100644 --- a/anta/tests/interfaces.py +++ b/anta/tests/interfaces.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to the device interfaces tests.""" @@ -7,30 +7,35 @@ # mypy: disable-error-code=attr-defined from __future__ import annotations -import re -from ipaddress import IPv4Network -from typing import Any, ClassVar, Literal +from typing import ClassVar, TypeVar -from pydantic import BaseModel, Field +from pydantic import Field, field_validator from pydantic_extra_types.mac_address import MacAddress -from anta.custom_types import Interface, Percent, PositiveInteger +from anta.custom_types import Interface, InterfaceType, Percent, PortChannelInterface, PositiveInteger from anta.decorators import skip_on_platforms +from anta.input_models.interfaces import InterfaceDetail, InterfaceState from anta.models import AntaCommand, AntaTemplate, AntaTest -from anta.tools import get_item, get_value +from anta.tools import custom_division, format_data, get_item, get_value, is_interface_ignored + +BPS_GBPS_CONVERSIONS = 1000000000 + +# Using a TypeVar for the InterfaceState model since mypy thinks it's a ClassVar and not a valid type when used in field validators +T = TypeVar("T", bound=InterfaceState) class VerifyInterfaceUtilization(AntaTest): """Verifies that the utilization of interfaces is below a certain threshold. Load interval (default to 5 minutes) is defined in device configuration. - This test has been implemented for full-duplex interfaces only. + + !!! warning + This test has been implemented for full-duplex interfaces only. Expected Results ---------------- - * Success: The test will pass if all interfaces have a usage below the threshold. - * Failure: The test will fail if one or more interfaces have a usage above the threshold. - * Error: The test will error out if the device has at least one non full-duplex interface. + * Success: The test will pass if all or specified interfaces are full duplex and have a usage below the threshold. + * Failure: The test will fail if any interface is non full-duplex or has a usage above the threshold. Examples -------- @@ -38,52 +43,74 @@ class VerifyInterfaceUtilization(AntaTest): anta.tests.interfaces: - VerifyInterfaceUtilization: threshold: 70.0 + ignored_interfaces: + - Ethernet1 + - Port-Channel1 + interfaces: + - Ethernet10 + - Loopback0 ``` """ - name = "VerifyInterfaceUtilization" - description = "Verifies that the utilization of interfaces is below a certain threshold." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="show interfaces counters rates", revision=1), - AntaCommand(command="show interfaces", revision=1), + AntaCommand(command="show interfaces status", revision=1), ] class Input(AntaTest.Input): """Input model for the VerifyInterfaceUtilization test.""" threshold: Percent = 75.0 - """Interface utilization threshold above which the test will fail. Defaults to 75%.""" + """Interface utilization threshold above which the test will fail.""" + interfaces: list[Interface] | None = None + """A list of interfaces to be tested. If not provided, all interfaces (excluding any in `ignored_interfaces`) are tested.""" + ignored_interfaces: list[InterfaceType | Interface] | None = None + """A list of interfaces or interface types like Management which will ignore all Management interfaces.""" @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyInterfaceUtilization.""" - duplex_full = "duplexFull" - failed_interfaces: dict[str, dict[str, float]] = {} - rates = self.instance_commands[0].json_output - interfaces = self.instance_commands[1].json_output + self.result.is_success() + + interfaces_counters_rates = self.instance_commands[0].json_output + interfaces_status = self.instance_commands[1].json_output + + test_has_input_interfaces = bool(self.inputs.interfaces) + interfaces_to_check = self.inputs.interfaces if test_has_input_interfaces else interfaces_counters_rates["interfaces"].keys() + + for intf in interfaces_to_check: + # Verification is skipped if the interface is in the ignored interfaces list + if is_interface_ignored(intf, self.inputs.ignored_interfaces): + continue + + # If specified interface is not configured, test fails + intf_counters = get_value(interfaces_counters_rates, f"interfaces..{intf}", separator="..") + intf_status = get_value(interfaces_status, f"interfaceStatuses..{intf}", separator="..") + if intf_counters is None or intf_status is None: + self.result.is_failure(f"Interface: {intf} - Not found") + continue - for intf, rate in rates["interfaces"].items(): # The utilization logic has been implemented for full-duplex interfaces only - if ((duplex := (interface := interfaces["interfaces"][intf]).get("duplex", None)) is not None and duplex != duplex_full) or ( - (members := interface.get("memberInterfaces", None)) is not None and any(stats["duplex"] != duplex_full for stats in members.values()) - ): - self.result.is_error(f"Interface {intf} or one of its member interfaces is not Full-Duplex. VerifyInterfaceUtilization has not been implemented.") - return - - if (bandwidth := interfaces["interfaces"][intf]["bandwidth"]) == 0: - self.logger.debug("Interface %s has been ignored due to null bandwidth value", intf) + if (intf_duplex := intf_status["duplex"]) != "duplexFull": + self.result.is_failure(f"Interface: {intf} - Test not implemented for non-full-duplex interfaces - Expected: duplexFull Actual: {intf_duplex}") continue + if (intf_bandwidth := intf_status["bandwidth"]) == 0: + if test_has_input_interfaces: + # Test fails on user-provided interfaces + self.result.is_failure(f"Interface: {intf} - Cannot get interface utilization due to null bandwidth value") + else: + self.logger.debug("Interface %s has been ignored due to null bandwidth value", intf) + continue + + # If one or more interfaces have a usage above the threshold, test fails for bps_rate in ("inBpsRate", "outBpsRate"): - usage = rate[bps_rate] / bandwidth * 100 + usage = intf_counters[bps_rate] / intf_bandwidth * 100 if usage > self.inputs.threshold: - failed_interfaces.setdefault(intf, {})[bps_rate] = usage - - if not failed_interfaces: - self.result.is_success() - else: - self.result.is_failure(f"The following interfaces have a usage > {self.inputs.threshold}%: {failed_interfaces}") + self.result.is_failure( + f"Interface: {intf} BPS Rate: {bps_rate} - Usage exceeds the threshold - Expected: <{self.inputs.threshold}% Actual: {usage}%" + ) class VerifyInterfaceErrors(AntaTest): @@ -102,23 +129,36 @@ class VerifyInterfaceErrors(AntaTest): ``` """ - name = "VerifyInterfaceErrors" - description = "Verifies there are no interface error counters." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces counters errors", revision=1)] + class Input(AntaTest.Input): + """Input model for the VerifyInterfaceErrors test.""" + + interfaces: list[Interface] | None = None + """A list of interfaces to be tested. If not provided, all interfaces (excluding any in `ignored_interfaces`) are tested.""" + ignored_interfaces: list[InterfaceType | Interface] | None = None + """A list of interfaces or interface types like Management which will ignore all Management interfaces.""" + @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyInterfaceErrors.""" + self.result.is_success() command_output = self.instance_commands[0].json_output - wrong_interfaces: list[dict[str, dict[str, int]]] = [] - for interface, counters in command_output["interfaceErrorCounters"].items(): - if any(value > 0 for value in counters.values()) and all(interface not in wrong_interface for wrong_interface in wrong_interfaces): - wrong_interfaces.append({interface: counters}) - if not wrong_interfaces: - self.result.is_success() - else: - self.result.is_failure(f"The following interface(s) have non-zero error counters: {wrong_interfaces}") + interfaces = self.inputs.interfaces if self.inputs.interfaces else command_output["interfaceErrorCounters"].keys() + for interface in interfaces: + # Verification is skipped if the interface is in the ignored interfaces list. + if is_interface_ignored(interface, self.inputs.ignored_interfaces): + continue + + # If specified interface is not configured, test fails + if (intf_counters := get_value(command_output, f"interfaceErrorCounters..{interface}", separator="..")) is None: + self.result.is_failure(f"Interface: {interface} - Not found") + continue + + counters_data = [f"{counter}: {value}" for counter, value in intf_counters.items() if value > 0] + if counters_data: + self.result.is_failure(f"Interface: {interface} - Non-zero error counter(s) - {', '.join(counters_data)}") class VerifyInterfaceDiscards(AntaTest): @@ -126,7 +166,7 @@ class VerifyInterfaceDiscards(AntaTest): Expected Results ---------------- - * Success: The test will pass if all interfaces have discard counters equal to zero. + * Success: The test will pass if all or specified interfaces have discard counters equal to zero. * Failure: The test will fail if one or more interfaces have non-zero discard counters. Examples @@ -134,25 +174,46 @@ class VerifyInterfaceDiscards(AntaTest): ```yaml anta.tests.interfaces: - VerifyInterfaceDiscards: + interfaces: + - Ethernet + - Port-Channel1 + ignored_interfaces: + - Vxlan1 + - Loopback0 ``` """ - name = "VerifyInterfaceDiscards" - description = "Verifies there are no interface discard counters." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces counters discards", revision=1)] + class Input(AntaTest.Input): + """Input model for the VerifyInterfaceDiscards test.""" + + interfaces: list[Interface] | None = None + """A list of interfaces to be tested. If not provided, all interfaces (excluding any in `ignored_interfaces`) are tested.""" + ignored_interfaces: list[InterfaceType | Interface] | None = None + """A list of interfaces or interface types like Management which will ignore all Management interfaces.""" + @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyInterfaceDiscards.""" + self.result.is_success() command_output = self.instance_commands[0].json_output - wrong_interfaces: list[dict[str, dict[str, int]]] = [] - for interface, outer_v in command_output["interfaces"].items(): - wrong_interfaces.extend({interface: outer_v} for value in outer_v.values() if value > 0) - if not wrong_interfaces: - self.result.is_success() - else: - self.result.is_failure(f"The following interfaces have non 0 discard counter(s): {wrong_interfaces}") + interfaces = self.inputs.interfaces if self.inputs.interfaces else command_output["interfaces"].keys() + + for interface in interfaces: + # Verification is skipped if the interface is in the ignored interfaces list. + if is_interface_ignored(interface, self.inputs.ignored_interfaces): + continue + + # If specified interface is not configured, test fails + if (intf_details := get_value(command_output, f"interfaces..{interface}", separator="..")) is None: + self.result.is_failure(f"Interface: {interface} - Not found") + continue + + counters_data = [f"{counter}: {value}" for counter, value in intf_details.items() if value > 0] + if counters_data: + self.result.is_failure(f"Interface: {interface} - Non-zero discard counter(s): {', '.join(counters_data)}") class VerifyInterfaceErrDisabled(AntaTest): @@ -171,33 +232,40 @@ class VerifyInterfaceErrDisabled(AntaTest): ``` """ - name = "VerifyInterfaceErrDisabled" - description = "Verifies there are no interfaces in the errdisabled state." categories: ClassVar[list[str]] = ["interfaces"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces status", revision=1)] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces status errdisabled", revision=1)] @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyInterfaceErrDisabled.""" + self.result.is_success() command_output = self.instance_commands[0].json_output - errdisabled_interfaces = [interface for interface, value in command_output["interfaceStatuses"].items() if value["linkStatus"] == "errdisabled"] - if errdisabled_interfaces: - self.result.is_failure(f"The following interfaces are in error disabled state: {errdisabled_interfaces}") - else: - self.result.is_success() + if not (interface_details := get_value(command_output, "interfaceStatuses")): + return + + for interface, value in interface_details.items(): + if causes := value.get("causes"): + msg = f"Interface: {interface} - Error disabled - Causes: {', '.join(causes)}" + self.result.is_failure(msg) + continue + self.result.is_failure(f"Interface: {interface} - Error disabled") class VerifyInterfacesStatus(AntaTest): - """Verifies if the provided list of interfaces are all in the expected state. + """Verifies the operational states of specified interfaces to ensure they match expected configurations. - - If line protocol status is provided, prioritize checking against both status and line protocol status - - If line protocol status is not provided and interface status is "up", expect both status and line protocol to be "up" - - If interface status is not "up", check only the interface status without considering line protocol status + This test performs the following checks for each specified interface: + + 1. If `line_protocol_status` is defined, both `status` and `line_protocol_status` are verified for the specified interface. + 2. If `line_protocol_status` is not provided but the `status` is "up", it is assumed that both the status and line protocol should be "up". + 3. If the interface `status` is not "up", only the interface's status is validated, with no line protocol check performed. Expected Results ---------------- - * Success: The test will pass if the provided interfaces are all in the expected state. - * Failure: The test will fail if any interface is not in the expected state. + * Success: If the interface status and line protocol status matches the expected operational state for all specified interfaces. + * Failure: If any of the following occur: + - The specified interface is not configured. + - The specified interface status and line protocol status does not match the expected operational state for any interface. Examples -------- @@ -216,8 +284,6 @@ class VerifyInterfacesStatus(AntaTest): ``` """ - name = "VerifyInterfacesStatus" - description = "Verifies the status of the provided interfaces." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces description", revision=1)] @@ -226,30 +292,27 @@ class Input(AntaTest.Input): interfaces: list[InterfaceState] """List of interfaces with their expected state.""" - - class InterfaceState(BaseModel): - """Model for an interface state.""" - - name: Interface - """Interface to validate.""" - status: Literal["up", "down", "adminDown"] - """Expected status of the interface.""" - line_protocol_status: Literal["up", "down", "testing", "unknown", "dormant", "notPresent", "lowerLayerDown"] | None = None - """Expected line protocol status of the interface.""" + InterfaceState: ClassVar[type[InterfaceState]] = InterfaceState + + @field_validator("interfaces") + @classmethod + def validate_interfaces(cls, interfaces: list[T]) -> list[T]: + """Validate that 'status' field is provided in each interface.""" + for interface in interfaces: + if interface.status is None: + msg = f"{interface} 'status' field missing in the input" + raise ValueError(msg) + return interfaces @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyInterfacesStatus.""" - command_output = self.instance_commands[0].json_output - self.result.is_success() - intf_not_configured = [] - intf_wrong_state = [] - + command_output = self.instance_commands[0].json_output for interface in self.inputs.interfaces: if (intf_status := get_value(command_output["interfaceDescriptions"], interface.name, separator="..")) is None: - intf_not_configured.append(interface.name) + self.result.is_failure(f"{interface.name} - Not configured") continue status = "up" if intf_status["interfaceStatus"] in {"up", "connected"} else intf_status["interfaceStatus"] @@ -257,19 +320,16 @@ def test(self) -> None: # If line protocol status is provided, prioritize checking against both status and line protocol status if interface.line_protocol_status: - if interface.status != status or interface.line_protocol_status != proto: - intf_wrong_state.append(f"{interface.name} is {status}/{proto}") + if any([interface.status != status, interface.line_protocol_status != proto]): + actual_state = f"Expected: {interface.status}/{interface.line_protocol_status}, Actual: {status}/{proto}" + self.result.is_failure(f"{interface.name} - Status mismatch - {actual_state}") # If line protocol status is not provided and interface status is "up", expect both status and proto to be "up" # If interface status is not "up", check only the interface status without considering line protocol status - elif (interface.status == "up" and (status != "up" or proto != "up")) or (interface.status != status): - intf_wrong_state.append(f"{interface.name} is {status}/{proto}") - - if intf_not_configured: - self.result.is_failure(f"The following interface(s) are not configured: {intf_not_configured}") - - if intf_wrong_state: - self.result.is_failure(f"The following interface(s) are not in the expected state: {intf_wrong_state}") + elif all([interface.status == "up", status != "up" or proto != "up"]): + self.result.is_failure(f"{interface.name} - Status mismatch - Expected: up/up, Actual: {status}/{proto}") + elif interface.status != status: + self.result.is_failure(f"{interface.name} - Status mismatch - Expected: {interface.status}, Actual: {status}") class VerifyStormControlDrops(AntaTest): @@ -285,37 +345,56 @@ class VerifyStormControlDrops(AntaTest): ```yaml anta.tests.interfaces: - VerifyStormControlDrops: + interfaces: + - Ethernet1 + - Ethernet2 + ignored_interfaces: + - Vxlan1 + - Loopback0 ``` """ - name = "VerifyStormControlDrops" - description = "Verifies there are no interface storm-control drop counters." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show storm-control", revision=1)] - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + class Input(AntaTest.Input): + """Input model for the VerifyStormControlDrops test.""" + + interfaces: list[Interface] | None = None + """A list of interfaces to be tested. If not provided, all interfaces (excluding any in `ignored_interfaces`) are tested.""" + ignored_interfaces: list[InterfaceType | Interface] | None = None + """A list of interfaces or interface types like Management which will ignore all Management interfaces.""" + + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyStormControlDrops.""" command_output = self.instance_commands[0].json_output - storm_controlled_interfaces: dict[str, dict[str, Any]] = {} - for interface, interface_dict in command_output["interfaces"].items(): - for traffic_type, traffic_type_dict in interface_dict["trafficTypes"].items(): + self.result.is_success() + interfaces = self.inputs.interfaces if self.inputs.interfaces else command_output["interfaces"].keys() + + for interface in interfaces: + # Verification is skipped if the interface is in the ignored interfaces list. + if is_interface_ignored(interface, self.inputs.ignored_interfaces): + continue + + # If specified interface is not configured, test fails + if (intf_details := get_value(command_output, f"interfaces..{interface}", separator="..")) is None: + self.result.is_failure(f"Interface: {interface} - Not found") + continue + + for traffic_type, traffic_type_dict in intf_details["trafficTypes"].items(): if "drop" in traffic_type_dict and traffic_type_dict["drop"] != 0: - storm_controlled_interface_dict = storm_controlled_interfaces.setdefault(interface, {}) - storm_controlled_interface_dict.update({traffic_type: traffic_type_dict["drop"]}) - if not storm_controlled_interfaces: - self.result.is_success() - else: - self.result.is_failure(f"The following interfaces have none 0 storm-control drop counters {storm_controlled_interfaces}") + storm_controlled_interfaces = f"{traffic_type}: {traffic_type_dict['drop']}" + self.result.is_failure(f"Interface: {interface} - Non-zero storm-control drop counter(s) - {storm_controlled_interfaces}") class VerifyPortChannels(AntaTest): - """Verifies there are no inactive ports in all port channels. + """Verifies there are no inactive ports in port channels. Expected Results ---------------- - * Success: The test will pass if there are no inactive ports in all port channels. + * Success: The test will pass if there are no inactive ports in all or specified port channels. * Failure: The test will fail if there is at least one inactive port in a port channel. Examples @@ -323,30 +402,50 @@ class VerifyPortChannels(AntaTest): ```yaml anta.tests.interfaces: - VerifyPortChannels: + ignored_interfaces: + - Port-Channel1 + - Port-Channel2 + interfaces: + - Port-Channel11 + - Port-Channel22 ``` """ - name = "VerifyPortChannels" - description = "Verifies there are no inactive ports in all port channels." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show port-channel", revision=1)] + class Input(AntaTest.Input): + """Input model for the VerifyPortChannels test.""" + + interfaces: list[PortChannelInterface] | None = None + """A list of port-channel interfaces to be tested. If not provided, all port-channel interfaces (excluding any in `ignored_interfaces`) are tested.""" + ignored_interfaces: list[PortChannelInterface] | None = None + """A list of port-channel interfaces to ignore.""" + @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyPortChannels.""" + self.result.is_success() command_output = self.instance_commands[0].json_output - po_with_inactive_ports: list[dict[str, str]] = [] - for portchannel, portchannel_dict in command_output["portChannels"].items(): - if len(portchannel_dict["inactivePorts"]) != 0: - po_with_inactive_ports.extend({portchannel: portchannel_dict["inactivePorts"]}) - if not po_with_inactive_ports: - self.result.is_success() - else: - self.result.is_failure(f"The following port-channels have inactive port(s): {po_with_inactive_ports}") + port_channels = self.inputs.interfaces if self.inputs.interfaces else command_output["portChannels"].keys() + + for port_channel in port_channels: + # Verification is skipped if the interface is in the ignored interfaces list. + if is_interface_ignored(port_channel, self.inputs.ignored_interfaces): + continue + + # If specified interface is not configured, test fails + if (port_channel_details := get_value(command_output, f"portChannels..{port_channel}", separator="..")) is None: + self.result.is_failure(f"Interface: {port_channel} - Not found") + continue + + # Verify that the no inactive ports in all port channels. + if inactive_ports := port_channel_details["inactivePorts"]: + self.result.is_failure(f"{port_channel} - Inactive port(s) - {', '.join(inactive_ports.keys())}") class VerifyIllegalLACP(AntaTest): - """Verifies there are no illegal LACP packets in all port channels. + """Verifies there are no illegal LACP packets in port channels. Expected Results ---------------- @@ -358,27 +457,47 @@ class VerifyIllegalLACP(AntaTest): ```yaml anta.tests.interfaces: - VerifyIllegalLACP: + ignored_interfaces: + - Port-Channel1 + - Port-Channel2 + interfaces: + - Port-Channel10 + - Port-Channel12 ``` """ - name = "VerifyIllegalLACP" - description = "Verifies there are no illegal LACP packets in all port channels." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lacp counters all-ports", revision=1)] + class Input(AntaTest.Input): + """Input model for the VerifyIllegalLACP test.""" + + interfaces: list[PortChannelInterface] | None = None + """A list of port-channel interfaces to be tested. If not provided, all port-channel interfaces (excluding any in `ignored_interfaces`) are tested.""" + ignored_interfaces: list[PortChannelInterface] | None = None + """A list of port-channel interfaces to ignore.""" + @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyIllegalLACP.""" + self.result.is_success() command_output = self.instance_commands[0].json_output - po_with_illegal_lacp: list[dict[str, dict[str, int]]] = [] - for portchannel, portchannel_dict in command_output["portChannels"].items(): - po_with_illegal_lacp.extend( - {portchannel: interface} for interface, interface_dict in portchannel_dict["interfaces"].items() if interface_dict["illegalRxCount"] != 0 - ) - if not po_with_illegal_lacp: - self.result.is_success() - else: - self.result.is_failure(f"The following port-channels have received illegal LACP packets on the following ports: {po_with_illegal_lacp}") + port_channels = self.inputs.interfaces if self.inputs.interfaces else command_output["portChannels"].keys() + + for port_channel in port_channels: + # Verification is skipped if the interface is in the ignored interfaces list. + if is_interface_ignored(port_channel, self.inputs.ignored_interfaces): + continue + + # If specified port-channel is not configured, test fails + if (port_channel_details := get_value(command_output, f"portChannels..{port_channel}", separator="..")) is None: + self.result.is_failure(f"Interface: {port_channel} - Not found") + continue + + for interface, interface_details in port_channel_details["interfaces"].items(): + # Verify that the no illegal LACP packets in all port channels. + if interface_details["illegalRxCount"] != 0: + self.result.is_failure(f"{port_channel} Interface: {interface} - Illegal LACP packets found") class VerifyLoopbackCount(AntaTest): @@ -398,7 +517,6 @@ class VerifyLoopbackCount(AntaTest): ``` """ - name = "VerifyLoopbackCount" description = "Verifies the number of loopback interfaces and their status." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface brief", revision=1)] @@ -412,23 +530,20 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyLoopbackCount.""" + self.result.is_success() command_output = self.instance_commands[0].json_output loopback_count = 0 - down_loopback_interfaces = [] - for interface in command_output["interfaces"]: - interface_dict = command_output["interfaces"][interface] + for interface, interface_details in command_output["interfaces"].items(): if "Loopback" in interface: loopback_count += 1 - if not (interface_dict["lineProtocolStatus"] == "up" and interface_dict["interfaceStatus"] == "connected"): - down_loopback_interfaces.append(interface) - if loopback_count == self.inputs.number and len(down_loopback_interfaces) == 0: - self.result.is_success() - else: - self.result.is_failure() - if loopback_count != self.inputs.number: - self.result.is_failure(f"Found {loopback_count} Loopbacks when expecting {self.inputs.number}") - elif len(down_loopback_interfaces) != 0: - self.result.is_failure(f"The following Loopbacks are not up: {down_loopback_interfaces}") + if (status := interface_details["lineProtocolStatus"]) != "up": + self.result.is_failure(f"Interface: {interface} - Invalid line protocol status - Expected: up Actual: {status}") + + if (status := interface_details["interfaceStatus"]) != "connected": + self.result.is_failure(f"Interface: {interface} - Invalid interface status - Expected: connected Actual: {status}") + + if loopback_count != self.inputs.number: + self.result.is_failure(f"Loopback interface(s) count mismatch: Expected {self.inputs.number} Actual: {loopback_count}") class VerifySVI(AntaTest): @@ -447,32 +562,25 @@ class VerifySVI(AntaTest): ``` """ - name = "VerifySVI" - description = "Verifies the status of all SVIs." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface brief", revision=1)] @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySVI.""" + self.result.is_success() command_output = self.instance_commands[0].json_output - down_svis = [] - for interface in command_output["interfaces"]: - interface_dict = command_output["interfaces"][interface] - if "Vlan" in interface and not (interface_dict["lineProtocolStatus"] == "up" and interface_dict["interfaceStatus"] == "connected"): - down_svis.append(interface) - if len(down_svis) == 0: - self.result.is_success() - else: - self.result.is_failure(f"The following SVIs are not up: {down_svis}") + for interface, int_data in command_output["interfaces"].items(): + if "Vlan" in interface and (status := int_data["lineProtocolStatus"]) != "up": + self.result.is_failure(f"SVI: {interface} - Invalid line protocol status - Expected: up Actual: {status}") + if "Vlan" in interface and int_data["interfaceStatus"] != "connected": + self.result.is_failure(f"SVI: {interface} - Invalid interface status - Expected: connected Actual: {int_data['interfaceStatus']}") class VerifyL3MTU(AntaTest): - """Verifies the global layer 3 Maximum Transfer Unit (MTU) for all L3 interfaces. + """Verifies the L3 MTU of routed interfaces. - Test that L3 interfaces are configured with the correct MTU. It supports Ethernet, Port Channel and VLAN interfaces. - - You can define a global MTU to check, or an MTU per interface and you can also ignored some interfaces. + Test that layer 3 (routed) interfaces are configured with the correct MTU. Expected Results ---------------- @@ -486,13 +594,14 @@ class VerifyL3MTU(AntaTest): - VerifyL3MTU: mtu: 1500 ignored_interfaces: - - Vxlan1 + - Management # Ignore all Management interfaces + - Ethernet2.100 + - Ethernet1/1 specific_mtu: - - Ethernet1: 2500 + - Ethernet10: 9200 ``` """ - name = "VerifyL3MTU" description = "Verifies the global L3 MTU of all L3 interfaces." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces", revision=1)] @@ -501,38 +610,35 @@ class Input(AntaTest.Input): """Input model for the VerifyL3MTU test.""" mtu: int = 1500 - """Default MTU we should have configured on all non-excluded interfaces. Defaults to 1500.""" - ignored_interfaces: list[str] = Field(default=["Management", "Loopback", "Vxlan", "Tunnel"]) - """A list of L3 interfaces to ignore""" - specific_mtu: list[dict[str, int]] = Field(default=[]) - """A list of dictionary of L3 interfaces with their specific MTU configured""" + """Expected L3 MTU configured on all non-excluded interfaces.""" + ignored_interfaces: list[InterfaceType | Interface] = Field(default=["Dps", "Fabric", "Loopback", "Management", "Recirc-Channel", "Tunnel", "Vxlan"]) + """A list of L3 interfaces or interfaces types like Loopback, Tunnel which will ignore all Loopback and Tunnel interfaces. + + Takes precedence over the `specific_mtu` field.""" + specific_mtu: list[dict[Interface, int]] = Field(default=[]) + """A list of dictionary of L3 interfaces with their expected L3 MTU configured.""" @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyL3MTU.""" - # Parameter to save incorrect interface settings - wrong_l3mtu_intf: list[dict[str, int]] = [] + self.result.is_success() command_output = self.instance_commands[0].json_output - # Set list of interfaces with specific settings - specific_interfaces: list[str] = [] - if self.inputs.specific_mtu: - for d in self.inputs.specific_mtu: - specific_interfaces.extend(d) - for interface, values in command_output["interfaces"].items(): - if re.findall(r"[a-z]+", interface, re.IGNORECASE)[0] not in self.inputs.ignored_interfaces and values["forwardingModel"] == "routed": - if interface in specific_interfaces: - wrong_l3mtu_intf.extend({interface: values["mtu"]} for custom_data in self.inputs.specific_mtu if values["mtu"] != custom_data[interface]) - # Comparison with generic setting - elif values["mtu"] != self.inputs.mtu: - wrong_l3mtu_intf.append({interface: values["mtu"]}) - if wrong_l3mtu_intf: - self.result.is_failure(f"Some interfaces do not have correct MTU configured:\n{wrong_l3mtu_intf}") - else: - self.result.is_success() + specific_interfaces = {intf: mtu for intf_mtu in self.inputs.specific_mtu for intf, mtu in intf_mtu.items()} + + for interface, details in command_output["interfaces"].items(): + # Verification is skipped if the interface is in the ignored interfaces list + if is_interface_ignored(interface, self.inputs.ignored_interfaces) or details["forwardingModel"] != "routed": + continue + + actual_mtu = details["mtu"] + expected_mtu = specific_interfaces.get(interface, self.inputs.mtu) + + if (actual_mtu := details["mtu"]) != expected_mtu: + self.result.is_failure(f"Interface: {interface} - Incorrect MTU - Expected: {expected_mtu} Actual: {actual_mtu}") class VerifyIPProxyARP(AntaTest): - """Verifies if Proxy-ARP is enabled for the provided list of interface(s). + """Verifies if Proxy ARP is enabled. Expected Results ---------------- @@ -550,40 +656,34 @@ class VerifyIPProxyARP(AntaTest): ``` """ - name = "VerifyIPProxyARP" - description = "Verifies if Proxy ARP is enabled." categories: ClassVar[list[str]] = ["interfaces"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {intf}", revision=2)] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface", revision=2)] class Input(AntaTest.Input): """Input model for the VerifyIPProxyARP test.""" - interfaces: list[str] + interfaces: list[Interface] """List of interfaces to be tested.""" - def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Render the template for each interface in the input list.""" - return [template.render(intf=intf) for intf in self.inputs.interfaces] - @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyIPProxyARP.""" - disabled_intf = [] - for command in self.instance_commands: - intf = command.params.intf - if not command.json_output["interfaces"][intf]["proxyArp"]: - disabled_intf.append(intf) - if disabled_intf: - self.result.is_failure(f"The following interface(s) have Proxy-ARP disabled: {disabled_intf}") - else: - self.result.is_success() + self.result.is_success() + command_output = self.instance_commands[0].json_output + + for interface in self.inputs.interfaces: + if (interface_detail := get_value(command_output, f"interfaces..{interface}", separator="..")) is None: + self.result.is_failure(f"Interface: {interface} - Not found") + continue + + if not interface_detail["proxyArp"]: + self.result.is_failure(f"Interface: {interface} - Proxy-ARP disabled") class VerifyL2MTU(AntaTest): - """Verifies the global layer 2 Maximum Transfer Unit (MTU) for all L2 interfaces. + """Verifies the L2 MTU of bridged interfaces. - Test that L2 interfaces are configured with the correct MTU. It supports Ethernet, Port Channel and VLAN interfaces. - You can define a global MTU to check and also an MTU per interface and also ignored some interfaces. + Test that layer 2 (bridged) interfaces are configured with the correct MTU. Expected Results ---------------- @@ -595,16 +695,15 @@ class VerifyL2MTU(AntaTest): ```yaml anta.tests.interfaces: - VerifyL2MTU: - mtu: 1500 + mtu: 9214 ignored_interfaces: - - Management1 - - Vxlan1 + - Ethernet2/1 + - Port-Channel # Ignore all Port-Channel interfaces specific_mtu: - Ethernet1/1: 1500 ``` """ - name = "VerifyL2MTU" description = "Verifies the global L2 MTU of all L2 interfaces." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces", revision=1)] @@ -613,39 +712,35 @@ class Input(AntaTest.Input): """Input model for the VerifyL2MTU test.""" mtu: int = 9214 - """Default MTU we should have configured on all non-excluded interfaces. Defaults to 9214.""" - ignored_interfaces: list[str] = Field(default=["Management", "Loopback", "Vxlan", "Tunnel"]) - """A list of L2 interfaces to ignore. Defaults to ["Management", "Loopback", "Vxlan", "Tunnel"]""" - specific_mtu: list[dict[str, int]] = Field(default=[]) - """A list of dictionary of L2 interfaces with their specific MTU configured""" + """Expected L2 MTU configured on all non-excluded interfaces.""" + ignored_interfaces: list[InterfaceType | Interface] = Field(default=["Dps", "Fabric", "Loopback", "Management", "Recirc-Channel", "Tunnel", "Vlan", "Vxlan"]) + """A list of L2 interfaces or interface types like Ethernet, Port-Channel which will ignore all Ethernet and Port-Channel interfaces. + + Takes precedence over the `specific_mtu` field.""" + specific_mtu: list[dict[Interface, int]] = Field(default=[]) + """A list of dictionary of L2 interfaces with their expected L2 MTU configured.""" @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyL2MTU.""" - # Parameter to save incorrect interface settings - wrong_l2mtu_intf: list[dict[str, int]] = [] - command_output = self.instance_commands[0].json_output - # Set list of interfaces with specific settings - specific_interfaces: list[str] = [] - if self.inputs.specific_mtu: - for d in self.inputs.specific_mtu: - specific_interfaces.extend(d) - for interface, values in command_output["interfaces"].items(): - catch_interface = re.findall(r"^[e,p][a-zA-Z]+[-,a-zA-Z]*\d+\/*\d*", interface, re.IGNORECASE) - if len(catch_interface) and catch_interface[0] not in self.inputs.ignored_interfaces and values["forwardingModel"] == "bridged": - if interface in specific_interfaces: - wrong_l2mtu_intf.extend({interface: values["mtu"]} for custom_data in self.inputs.specific_mtu if values["mtu"] != custom_data[interface]) - # Comparison with generic setting - elif values["mtu"] != self.inputs.mtu: - wrong_l2mtu_intf.append({interface: values["mtu"]}) - if wrong_l2mtu_intf: - self.result.is_failure(f"Some L2 interfaces do not have correct MTU configured:\n{wrong_l2mtu_intf}") - else: - self.result.is_success() + self.result.is_success() + interface_output = self.instance_commands[0].json_output["interfaces"] + specific_interfaces = {intf: mtu for intf_mtu in self.inputs.specific_mtu for intf, mtu in intf_mtu.items()} + + for interface, details in interface_output.items(): + # Verification is skipped if the interface is in the ignored interfaces list + if is_interface_ignored(interface, self.inputs.ignored_interfaces) or details["forwardingModel"] != "bridged": + continue + + actual_mtu = details["mtu"] + expected_mtu = specific_interfaces.get(interface, self.inputs.mtu) + + if (actual_mtu := details["mtu"]) != expected_mtu: + self.result.is_failure(f"Interface: {interface} - Incorrect MTU - Expected: {expected_mtu} Actual: {actual_mtu}") class VerifyInterfaceIPv4(AntaTest): - """Verifies if an interface is configured with a correct primary and list of optional secondary IPv4 addresses. + """Verifies the interface IPv4 addresses. Expected Results ---------------- @@ -659,86 +754,68 @@ class VerifyInterfaceIPv4(AntaTest): - VerifyInterfaceIPv4: interfaces: - name: Ethernet2 - primary_ip: 172.30.11.0/31 + primary_ip: 172.30.11.1/31 secondary_ips: - - 10.10.10.0/31 + - 10.10.10.1/31 - 10.10.10.10/31 ``` """ - name = "VerifyInterfaceIPv4" - description = "Verifies the interface IPv4 addresses." categories: ClassVar[list[str]] = ["interfaces"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {interface}", revision=2)] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface", revision=2)] class Input(AntaTest.Input): """Input model for the VerifyInterfaceIPv4 test.""" - interfaces: list[InterfaceDetail] + interfaces: list[InterfaceState] """List of interfaces with their details.""" - - class InterfaceDetail(BaseModel): - """Model for an interface detail.""" - - name: Interface - """Name of the interface.""" - primary_ip: IPv4Network - """Primary IPv4 address in CIDR notation.""" - secondary_ips: list[IPv4Network] | None = None - """Optional list of secondary IPv4 addresses in CIDR notation.""" - - def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Render the template for each interface in the input list.""" - return [template.render(interface=interface.name) for interface in self.inputs.interfaces] + InterfaceDetail: ClassVar[type[InterfaceDetail]] = InterfaceDetail + + @field_validator("interfaces") + @classmethod + def validate_interfaces(cls, interfaces: list[T]) -> list[T]: + """Validate that 'primary_ip' field is provided in each interface.""" + for interface in interfaces: + if interface.primary_ip is None: + msg = f"{interface} 'primary_ip' field missing in the input" + raise ValueError(msg) + return interfaces @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyInterfaceIPv4.""" self.result.is_success() - for command in self.instance_commands: - intf = command.params.interface - for interface in self.inputs.interfaces: - if interface.name == intf: - input_interface_detail = interface - input_primary_ip = str(input_interface_detail.primary_ip) - failed_messages = [] - - # Check if the interface has an IP address configured - if not (interface_output := get_value(command.json_output, f"interfaces.{intf}.interfaceAddress")): - self.result.is_failure(f"For interface `{intf}`, IP address is not configured.") + command_output = self.instance_commands[0].json_output + + for interface in self.inputs.interfaces: + if (interface_detail := get_value(command_output, f"interfaces..{interface.name}", separator="..")) is None: + self.result.is_failure(f"{interface} - Not found") continue - primary_ip = get_value(interface_output, "primaryIp") + if (ip_address := get_value(interface_detail, "interfaceAddress.primaryIp")) is None: + self.result.is_failure(f"{interface} - IP address is not configured") + continue # Combine IP address and subnet for primary IP - actual_primary_ip = f"{primary_ip['address']}/{primary_ip['maskLen']}" + actual_primary_ip = f"{ip_address['address']}/{ip_address['maskLen']}" # Check if the primary IP address matches the input - if actual_primary_ip != input_primary_ip: - failed_messages.append(f"The expected primary IP address is `{input_primary_ip}`, but the actual primary IP address is `{actual_primary_ip}`.") + if actual_primary_ip != str(interface.primary_ip): + self.result.is_failure(f"{interface} - IP address mismatch - Expected: {interface.primary_ip} Actual: {actual_primary_ip}") - if (param_secondary_ips := input_interface_detail.secondary_ips) is not None: - input_secondary_ips = sorted([str(network) for network in param_secondary_ips]) - secondary_ips = get_value(interface_output, "secondaryIpsOrderedList") + if interface.secondary_ips: + if not (secondary_ips := get_value(interface_detail, "interfaceAddress.secondaryIpsOrderedList")): + self.result.is_failure(f"{interface} - Secondary IP address is not configured") + continue - # Combine IP address and subnet for secondary IPs actual_secondary_ips = sorted([f"{secondary_ip['address']}/{secondary_ip['maskLen']}" for secondary_ip in secondary_ips]) + input_secondary_ips = sorted([str(ip) for ip in interface.secondary_ips]) - # Check if the secondary IP address is configured - if not actual_secondary_ips: - failed_messages.append( - f"The expected secondary IP addresses are `{input_secondary_ips}`, but the actual secondary IP address is not configured." + if actual_secondary_ips != input_secondary_ips: + self.result.is_failure( + f"{interface} - Secondary IP address mismatch - Expected: {', '.join(input_secondary_ips)} Actual: {', '.join(actual_secondary_ips)}" ) - # Check if the secondary IP addresses match the input - elif actual_secondary_ips != input_secondary_ips: - failed_messages.append( - f"The expected secondary IP addresses are `{input_secondary_ips}`, but the actual secondary IP addresses are `{actual_secondary_ips}`." - ) - - if failed_messages: - self.result.is_failure(f"For interface `{intf}`, " + " ".join(failed_messages)) - class VerifyIpVirtualRouterMac(AntaTest): """Verifies the IP virtual router MAC address. @@ -757,8 +834,6 @@ class VerifyIpVirtualRouterMac(AntaTest): ``` """ - name = "VerifyIpVirtualRouterMac" - description = "Verifies the IP virtual router MAC address." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip virtual-router", revision=2)] @@ -771,10 +846,186 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyIpVirtualRouterMac.""" + self.result.is_success() command_output = self.instance_commands[0].json_output["virtualMacs"] - mac_address_found = get_item(command_output, "macAddress", self.inputs.mac_address) + if get_item(command_output, "macAddress", self.inputs.mac_address) is None: + self.result.is_failure(f"IP virtual router MAC address: {self.inputs.mac_address} - Not configured") + + +class VerifyInterfacesSpeed(AntaTest): + """Verifies the speed, lanes, auto-negotiation status, and mode as full duplex for interfaces. + + - If the auto-negotiation status is set to True, verifies that auto-negotiation is successful, the mode is full duplex and the speed/lanes match the input. + - If the auto-negotiation status is set to False, verifies that the mode is full duplex and the speed/lanes match the input. + + Expected Results + ---------------- + * Success: The test will pass if an interface is configured correctly with the specified speed, lanes, auto-negotiation status, and mode as full duplex. + * Failure: The test will fail if an interface is not found, if the speed, lanes, and auto-negotiation status do not match the input, or mode is not full duplex. + + Examples + -------- + ```yaml + anta.tests.interfaces: + - VerifyInterfacesSpeed: + interfaces: + - name: Ethernet2 + auto: False + speed: 10 + - name: Eth3 + auto: True + speed: 100 + lanes: 1 + - name: Eth2 + auto: False + speed: 2.5 + ``` + """ + + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces")] + + class Input(AntaTest.Input): + """Inputs for the VerifyInterfacesSpeed test.""" + + interfaces: list[InterfaceState] + """List of interfaces with their expected state.""" + InterfaceDetail: ClassVar[type[InterfaceDetail]] = InterfaceDetail + + @field_validator("interfaces") + @classmethod + def validate_interfaces(cls, interfaces: list[T]) -> list[T]: + """Validate that 'speed' field is provided in each interface.""" + for interface in interfaces: + if interface.speed is None: + msg = f"{interface} 'speed' field missing in the input" + raise ValueError(msg) + return interfaces + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyInterfacesSpeed.""" + self.result.is_success() + command_output = self.instance_commands[0].json_output + + # Iterate over all the interfaces + for interface in self.inputs.interfaces: + if (interface_detail := get_value(command_output, f"interfaces..{interface.name}", separator="..")) is None: + self.result.is_failure(f"{interface} - Not found") + continue + + # Verifies the bandwidth + if (speed := interface_detail.get("bandwidth")) != interface.speed * BPS_GBPS_CONVERSIONS: + self.result.is_failure( + f"{interface} - Bandwidth mismatch - Expected: {interface.speed}Gbps Actual: {custom_division(speed, BPS_GBPS_CONVERSIONS)}Gbps" + ) + + # Verifies the duplex mode + if (duplex := interface_detail.get("duplex")) != "duplexFull": + self.result.is_failure(f"{interface} - Duplex mode mismatch - Expected: duplexFull Actual: {duplex}") + + # Verifies the auto-negotiation as success if specified + if interface.auto and (auto_negotiation := interface_detail.get("autoNegotiate")) != "success": + self.result.is_failure(f"{interface} - Auto-negotiation mismatch - Expected: success Actual: {auto_negotiation}") + + # Verifies the communication lanes if specified + if interface.lanes and (lanes := interface_detail.get("lanes")) != interface.lanes: + self.result.is_failure(f"{interface} - Data lanes count mismatch - Expected: {interface.lanes} Actual: {lanes}") + + +class VerifyLACPInterfacesStatus(AntaTest): + """Verifies the Link Aggregation Control Protocol (LACP) status of the interface. + + This test performs the following checks for each specified interface: + + 1. Verifies that the interface is a member of the LACP port channel. + 2. Verifies LACP port states and operational status: + - Activity: Active LACP mode (initiates) + - Timeout: Short (Fast Mode), Long (Slow Mode - default) + - Aggregation: Port aggregable + - Synchronization: Port in sync with partner + - Collecting: Incoming frames aggregating + - Distributing: Outgoing frames aggregating + + Expected Results + ---------------- + * Success: Interface is bundled and all LACP states match expected values for both actor and partner + * Failure: If any of the following occur: + - Interface or port channel is not configured. + - Interface is not bundled in port channel. + - Actor or partner port LACP states don't match expected configuration. + - LACP rate (timeout) mismatch when fast mode is configured. + + Examples + -------- + ```yaml + anta.tests.interfaces: + - VerifyLACPInterfacesStatus: + interfaces: + - name: Ethernet1 + portchannel: Port-Channel100 + ``` + """ + + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lacp interface", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyLACPInterfacesStatus test.""" + + interfaces: list[InterfaceState] + """List of interfaces with their expected state.""" + InterfaceState: ClassVar[type[InterfaceState]] = InterfaceState + + @field_validator("interfaces") + @classmethod + def validate_interfaces(cls, interfaces: list[T]) -> list[T]: + """Validate that 'portchannel' field is provided in each interface.""" + for interface in interfaces: + if interface.portchannel is None: + msg = f"{interface} 'portchannel' field missing in the input" + raise ValueError(msg) + return interfaces + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyLACPInterfacesStatus.""" + self.result.is_success() + + # Member port verification parameters. + member_port_details = ["activity", "aggregation", "synchronization", "collecting", "distributing", "timeout"] + + command_output = self.instance_commands[0].json_output + for interface in self.inputs.interfaces: + # Verify if a PortChannel is configured with the provided interface + if not (interface_details := get_value(command_output, f"portChannels..{interface.portchannel}..interfaces..{interface.name}", separator="..")): + self.result.is_failure(f"{interface} - Not configured") + continue + + # Verify the interface is bundled in port channel. + actor_port_status = interface_details.get("actorPortStatus") + if actor_port_status != "bundled": + self.result.is_failure(f"{interface} - Not bundled - Port Status: {actor_port_status}") + continue + + # Collecting actor and partner port details + actor_port_details = interface_details.get("actorPortState", {}) + partner_port_details = interface_details.get("partnerPortState", {}) + + # Collecting actual interface details + actual_interface_output = { + "actor_port_details": {param: actor_port_details.get(param, "NotFound") for param in member_port_details}, + "partner_port_details": {param: partner_port_details.get(param, "NotFound") for param in member_port_details}, + } + + # Forming expected interface details + expected_details = {param: param != "timeout" for param in member_port_details} + # Updating the short LACP timeout, if expected. + if interface.lacp_rate_fast: + expected_details["timeout"] = True + + if (act_port_details := actual_interface_output["actor_port_details"]) != expected_details: + self.result.is_failure(f"{interface} - Actor port details mismatch - {format_data(act_port_details)}") - if mac_address_found is None: - self.result.is_failure(f"IP virtual router MAC address `{self.inputs.mac_address}` is not configured.") - else: - self.result.is_success() + if (part_port_details := actual_interface_output["partner_port_details"]) != expected_details: + self.result.is_failure(f"{interface} - Partner port details mismatch - {format_data(part_port_details)}") diff --git a/anta/tests/lanz.py b/anta/tests/lanz.py index dcdab69db..0ace171b0 100644 --- a/anta/tests/lanz.py +++ b/anta/tests/lanz.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to LANZ tests.""" @@ -30,12 +30,11 @@ class VerifyLANZ(AntaTest): ``` """ - name = "VerifyLANZ" description = "Verifies if LANZ is enabled." categories: ClassVar[list[str]] = ["lanz"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show queue-monitor length status", revision=1)] - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyLANZ.""" diff --git a/anta/tests/logging.py b/anta/tests/logging.py index b05b0a0dd..cbc19bf9c 100644 --- a/anta/tests/logging.py +++ b/anta/tests/logging.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to the EOS various logging tests. @@ -14,25 +14,28 @@ from ipaddress import IPv4Address from typing import TYPE_CHECKING, ClassVar -from anta.models import AntaCommand, AntaTest +from anta.custom_types import LogSeverityLevel +from anta.input_models.logging import LoggingQuery +from anta.models import AntaCommand, AntaTemplate, AntaTest if TYPE_CHECKING: import logging - from anta.models import AntaTemplate - def _get_logging_states(logger: logging.Logger, command_output: str) -> str: """Parse `show logging` output and gets operational logging states used in the tests in this module. - Args: - ---- - logger: The logger object. - command_output: The `show logging` output. + Parameters + ---------- + logger + The logger object. + command_output + The `show logging` output. Returns ------- - str: The operational logging states. + str + The operational logging states. """ log_states = command_output.partition("\n\nExternal configuration:")[0] @@ -40,6 +43,35 @@ def _get_logging_states(logger: logging.Logger, command_output: str) -> str: return log_states +class VerifySyslogLogging(AntaTest): + """Verifies if syslog logging is enabled. + + Expected Results + ---------------- + * Success: The test will pass if syslog logging is enabled. + * Failure: The test will fail if syslog logging is disabled. + + Examples + -------- + ```yaml + anta.tests.logging: + - VerifySyslogLogging: + ``` + """ + + categories: ClassVar[list[str]] = ["logging"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifySyslogLogging.""" + self.result.is_success() + log_output = self.instance_commands[0].text_output + + if "Syslog logging: enabled" not in _get_logging_states(self.logger, log_output): + self.result.is_failure("Syslog logging is disabled") + + class VerifyLoggingPersistent(AntaTest): """Verifies if logging persistent is enabled and logs are saved in flash. @@ -56,8 +88,6 @@ class VerifyLoggingPersistent(AntaTest): ``` """ - name = "VerifyLoggingPersistent" - description = "Verifies if logging persistent is enabled and logs are saved in flash." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="show logging", ofmt="text"), @@ -97,13 +127,11 @@ class VerifyLoggingSourceIntf(AntaTest): ``` """ - name = "VerifyLoggingSourceInt" - description = "Verifies logging source-interface for a specified VRF." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")] class Input(AntaTest.Input): - """Input model for the VerifyLoggingSourceInt test.""" + """Input model for the VerifyLoggingSourceIntf test.""" interface: str """Source-interface to use as source IP of log messages.""" @@ -112,13 +140,13 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: - """Main test function for VerifyLoggingSourceInt.""" + """Main test function for VerifyLoggingSourceIntf.""" output = self.instance_commands[0].text_output pattern = rf"Logging source-interface '{self.inputs.interface}'.*VRF {self.inputs.vrf}" if re.search(pattern, _get_logging_states(self.logger, output)): self.result.is_success() else: - self.result.is_failure(f"Source-interface '{self.inputs.interface}' is not configured in VRF {self.inputs.vrf}") + self.result.is_failure(f"Source-interface: {self.inputs.interface} VRF: {self.inputs.vrf} - Not configured") class VerifyLoggingHosts(AntaTest): @@ -141,8 +169,6 @@ class VerifyLoggingHosts(AntaTest): ``` """ - name = "VerifyLoggingHosts" - description = "Verifies logging hosts (syslog servers) for a specified VRF." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")] @@ -167,33 +193,51 @@ def test(self) -> None: if not not_configured: self.result.is_success() else: - self.result.is_failure(f"Syslog servers {not_configured} are not configured in VRF {self.inputs.vrf}") + self.result.is_failure(f"Syslog servers {', '.join(not_configured)} are not configured in VRF {self.inputs.vrf}") class VerifyLoggingLogsGeneration(AntaTest): """Verifies if logs are generated. + This test performs the following checks: + + 1. Sends a test log message at the specified severity log level. + 2. Retrieves the most recent logs (last 30 seconds). + 3. Verifies that the test message was successfully logged. + Expected Results ---------------- - * Success: The test will pass if logs are generated. - * Failure: The test will fail if logs are NOT generated. + * Success: If logs are being generated and the test message is found in recent logs. + * Failure: If any of the following occur: + - The test message is not found in recent logs. + - The logging system is not capturing new messages. + - No logs are being generated. Examples -------- ```yaml anta.tests.logging: - VerifyLoggingLogsGeneration: + severity_level: informational ``` """ - name = "VerifyLoggingLogsGeneration" - description = "Verifies if logs are generated." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ - AntaCommand(command="send log level informational message ANTA VerifyLoggingLogsGeneration validation", ofmt="text"), - AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False), + AntaTemplate(template="send log level {severity_level} message ANTA VerifyLoggingLogsGeneration validation", ofmt="text"), + AntaTemplate(template="show logging {severity_level} last 30 seconds | grep ANTA", ofmt="text", use_cache=False), ] + class Input(AntaTest.Input): + """Input model for the VerifyLoggingLogsGeneration test.""" + + severity_level: LogSeverityLevel = "informational" + """Log severity level. Defaults to informational.""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for log severity level in the input.""" + return [template.render(severity_level=self.inputs.severity_level)] + @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyLoggingLogsGeneration.""" @@ -210,28 +254,47 @@ def test(self) -> None: class VerifyLoggingHostname(AntaTest): """Verifies if logs are generated with the device FQDN. + This test performs the following checks: + + 1. Retrieves the device's configured FQDN. + 2. Sends a test log message at the specified severity log level. + 3. Retrieves the most recent logs (last 30 seconds). + 4. Verifies that the test message includes the complete FQDN of the device. + Expected Results ---------------- - * Success: The test will pass if logs are generated with the device FQDN. - * Failure: The test will fail if logs are NOT generated with the device FQDN. + * Success: If logs are generated with the device's complete FQDN. + * Failure: If any of the following occur: + - The test message is not found in recent logs. + - The log message does not include the device's FQDN. + - The FQDN in the log message doesn't match the configured FQDN. Examples -------- ```yaml anta.tests.logging: - VerifyLoggingHostname: + severity_level: informational ``` """ - name = "VerifyLoggingHostname" - description = "Verifies if logs are generated with the device FQDN." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="show hostname", revision=1), - AntaCommand(command="send log level informational message ANTA VerifyLoggingHostname validation", ofmt="text"), - AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False), + AntaTemplate(template="send log level {severity_level} message ANTA VerifyLoggingHostname validation", ofmt="text"), + AntaTemplate(template="show logging {severity_level} last 30 seconds | grep ANTA", ofmt="text", use_cache=False), ] + class Input(AntaTest.Input): + """Input model for the VerifyLoggingHostname test.""" + + severity_level: LogSeverityLevel = "informational" + """Log severity level. Defaults to informational.""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for log severity level in the input.""" + return [template.render(severity_level=self.inputs.severity_level)] + @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyLoggingHostname.""" @@ -254,32 +317,52 @@ def test(self) -> None: class VerifyLoggingTimestamp(AntaTest): """Verifies if logs are generated with the appropriate timestamp. + This test performs the following checks: + + 1. Sends a test log message at the specified severity log level. + 2. Retrieves the most recent logs (last 30 seconds). + 3. Verifies that the test message is present with a high-resolution RFC3339 timestamp format. + - Example format: `2024-01-25T15:30:45.123456+00:00`. + - Includes microsecond precision. + - Contains timezone offset. + Expected Results ---------------- - * Success: The test will pass if logs are generated with the appropriate timestamp. - * Failure: The test will fail if logs are NOT generated with the appropriate timestamp. + * Success: If logs are generated with the correct high-resolution RFC3339 timestamp format. + * Failure: If any of the following occur: + - The test message is not found in recent logs. + - The timestamp format does not match the expected RFC3339 format. Examples -------- ```yaml anta.tests.logging: - VerifyLoggingTimestamp: + severity_level: informational ``` """ - name = "VerifyLoggingTimestamp" - description = "Verifies if logs are generated with the riate timestamp." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ - AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation", ofmt="text"), - AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False), + AntaTemplate(template="send log level {severity_level} message ANTA VerifyLoggingTimestamp validation", ofmt="text"), + AntaTemplate(template="show logging {severity_level} last 30 seconds | grep ANTA", ofmt="text", use_cache=False), ] + class Input(AntaTest.Input): + """Input model for the VerifyLoggingTimestamp test.""" + + severity_level: LogSeverityLevel = "informational" + """Log severity level. Defaults to informational.""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for log severity level in the input.""" + return [template.render(severity_level=self.inputs.severity_level)] + @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyLoggingTimestamp.""" log_pattern = r"ANTA VerifyLoggingTimestamp validation" - timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}-\d{2}:\d{2}" + timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}[+-]\d{2}:\d{2}" output = self.instance_commands[1].text_output lines = output.strip().split("\n")[::-1] last_line_with_pattern = "" @@ -309,8 +392,6 @@ class VerifyLoggingAccounting(AntaTest): ``` """ - name = "VerifyLoggingAccounting" - description = "Verifies if AAA accounting logs are generated." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")] @@ -341,8 +422,6 @@ class VerifyLoggingErrors(AntaTest): ``` """ - name = "VerifyLoggingErrors" - description = "Verifies there are no syslog messages with a severity of ERRORS or higher." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging threshold errors", ofmt="text")] @@ -354,4 +433,54 @@ def test(self) -> None: if len(command_output) == 0: self.result.is_success() else: - self.result.is_failure("Device has reported syslog messages with a severity of ERRORS or higher") + self.result.is_failure(f"Device has reported syslog messages with a severity of ERRORS or higher:\n{command_output}") + + +class VerifyLoggingEntries(AntaTest): + """Verifies that the expected log string is present in the last specified log messages. + + Expected Results + ---------------- + * Success: The test will pass if the expected log string for the mentioned severity level is present in the last specified log messages. + * Failure: The test will fail if the specified log string is not present in the last specified log messages. + + Examples + -------- + ```yaml + anta.tests.logging: + - VerifyLoggingEntries: + logging_entries: + - regex_match: ".*ACCOUNTING-5-EXEC: cvpadmin ssh.*" + last_number_messages: 30 + severity_level: alerts + - regex_match: ".*SPANTREE-6-INTERFACE_ADD:.*" + last_number_messages: 10 + severity_level: critical + ``` + """ + + categories: ClassVar[list[str]] = ["logging"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ + AntaTemplate(template="show logging {last_number_messages} {severity_level}", ofmt="text", use_cache=False) + ] + + class Input(AntaTest.Input): + """Input model for the VerifyLoggingEntries test.""" + + logging_entries: list[LoggingQuery] + """List of logging entries and regex match.""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for last number messages and log severity level in the input.""" + return [template.render(last_number_messages=entry.last_number_messages, severity_level=entry.severity_level) for entry in self.inputs.logging_entries] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyLoggingEntries.""" + self.result.is_success() + for command_output, logging_entry in zip(self.instance_commands, self.inputs.logging_entries): + output = command_output.text_output + if not re.search(logging_entry.regex_match, output): + self.result.is_failure( + f"Pattern: `{logging_entry.regex_match}` - Not found in last {logging_entry.last_number_messages} {logging_entry.severity_level} log entries" + ) diff --git a/anta/tests/mlag.py b/anta/tests/mlag.py index 1d17ab642..7217d574e 100644 --- a/anta/tests/mlag.py +++ b/anta/tests/mlag.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to Multi-chassis Link Aggregation (MLAG) tests.""" @@ -22,10 +22,8 @@ class VerifyMlagStatus(AntaTest): Expected Results ---------------- - * Success: The test will pass if the MLAG state is 'active', negotiation status is 'connected', - peer-link status and local interface status are 'up'. - * Failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected', - peer-link status or local interface status are not 'up'. + * Success: The test will pass if the MLAG state is 'active', negotiation status is 'connected', peer-link status and local interface status are 'up'. + * Failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected', peer-link status or local interface status are not 'up'. * Skipped: The test will be skipped if MLAG is 'disabled'. Examples @@ -36,29 +34,31 @@ class VerifyMlagStatus(AntaTest): ``` """ - name = "VerifyMlagStatus" - description = "Verifies the health status of the MLAG configuration." categories: ClassVar[list[str]] = ["mlag"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)] @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyMlagStatus.""" + self.result.is_success() command_output = self.instance_commands[0].json_output + + # Skipping the test if MLAG is disabled if command_output["state"] == "disabled": self.result.is_skipped("MLAG is disabled") return - keys_to_verify = ["state", "negStatus", "localIntfStatus", "peerLinkStatus"] - verified_output = {key: get_value(command_output, key) for key in keys_to_verify} - if ( - verified_output["state"] == "active" - and verified_output["negStatus"] == "connected" - and verified_output["localIntfStatus"] == "up" - and verified_output["peerLinkStatus"] == "up" - ): - self.result.is_success() - else: - self.result.is_failure(f"MLAG status is not OK: {verified_output}") + + # Verifies the negotiation status + if (neg_status := command_output["negStatus"]) != "connected": + self.result.is_failure(f"MLAG negotiation status mismatch - Expected: connected Actual: {neg_status}") + + # Verifies the local interface interface status + if (intf_state := command_output["localIntfStatus"]) != "up": + self.result.is_failure(f"Operational state of the MLAG local interface is not correct - Expected: up Actual: {intf_state}") + + # Verifies the peerLinkStatus + if (peer_link_state := command_output["peerLinkStatus"]) != "up": + self.result.is_failure(f"Operational state of the MLAG peer link is not correct - Expected: up Actual: {peer_link_state}") class VerifyMlagInterfaces(AntaTest): @@ -78,22 +78,25 @@ class VerifyMlagInterfaces(AntaTest): ``` """ - name = "VerifyMlagInterfaces" - description = "Verifies there are no inactive or active-partial MLAG ports." categories: ClassVar[list[str]] = ["mlag"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)] @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyMlagInterfaces.""" + self.result.is_success() command_output = self.instance_commands[0].json_output + + # Skipping the test if MLAG is disabled if command_output["state"] == "disabled": self.result.is_skipped("MLAG is disabled") return - if command_output["mlagPorts"]["Inactive"] == 0 and command_output["mlagPorts"]["Active-partial"] == 0: - self.result.is_success() - else: - self.result.is_failure(f"MLAG status is not OK: {command_output['mlagPorts']}") + + # Verifies the Inactive and Active-partial ports + inactive_ports = command_output["mlagPorts"]["Inactive"] + partial_active_ports = command_output["mlagPorts"]["Active-partial"] + if inactive_ports != 0 or partial_active_ports != 0: + self.result.is_failure(f"MLAG status is not ok - Inactive Ports: {inactive_ports} Partial Active Ports: {partial_active_ports}") class VerifyMlagConfigSanity(AntaTest): @@ -114,27 +117,27 @@ class VerifyMlagConfigSanity(AntaTest): ``` """ - name = "VerifyMlagConfigSanity" - description = "Verifies there are no MLAG config-sanity inconsistencies." categories: ClassVar[list[str]] = ["mlag"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag config-sanity", revision=1)] @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyMlagConfigSanity.""" + self.result.is_success() command_output = self.instance_commands[0].json_output - if (mlag_status := get_value(command_output, "mlagActive")) is None: - self.result.is_error(message="Incorrect JSON response - 'mlagActive' state was not found") - return - if mlag_status is False: + + # Skipping the test if MLAG is disabled + if command_output["mlagActive"] is False: self.result.is_skipped("MLAG is disabled") return - keys_to_verify = ["globalConfiguration", "interfaceConfiguration"] - verified_output = {key: get_value(command_output, key) for key in keys_to_verify} - if not any(verified_output.values()): - self.result.is_success() - else: - self.result.is_failure(f"MLAG config-sanity returned inconsistencies: {verified_output}") + + # Verifies the globalConfiguration config-sanity + if get_value(command_output, "globalConfiguration"): + self.result.is_failure("MLAG config-sanity found in global configuration") + + # Verifies the interfaceConfiguration config-sanity + if get_value(command_output, "interfaceConfiguration"): + self.result.is_failure("MLAG config-sanity found in interface configuration") class VerifyMlagReloadDelay(AntaTest): @@ -156,8 +159,6 @@ class VerifyMlagReloadDelay(AntaTest): ``` """ - name = "VerifyMlagReloadDelay" - description = "Verifies the MLAG reload-delay parameters." categories: ClassVar[list[str]] = ["mlag"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)] @@ -172,17 +173,21 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyMlagReloadDelay.""" + self.result.is_success() command_output = self.instance_commands[0].json_output + + # Skipping the test if MLAG is disabled if command_output["state"] == "disabled": self.result.is_skipped("MLAG is disabled") return - keys_to_verify = ["reloadDelay", "reloadDelayNonMlag"] - verified_output = {key: get_value(command_output, key) for key in keys_to_verify} - if verified_output["reloadDelay"] == self.inputs.reload_delay and verified_output["reloadDelayNonMlag"] == self.inputs.reload_delay_non_mlag: - self.result.is_success() - else: - self.result.is_failure(f"The reload-delay parameters are not configured properly: {verified_output}") + # Verifies the reloadDelay + if (reload_delay := get_value(command_output, "reloadDelay")) != self.inputs.reload_delay: + self.result.is_failure(f"MLAG reload-delay mismatch - Expected: {self.inputs.reload_delay}s Actual: {reload_delay}s") + + # Verifies the reloadDelayNonMlag + if (non_mlag_reload_delay := get_value(command_output, "reloadDelayNonMlag")) != self.inputs.reload_delay_non_mlag: + self.result.is_failure(f"Delay for non-MLAG ports mismatch - Expected: {self.inputs.reload_delay_non_mlag}s Actual: {non_mlag_reload_delay}s") class VerifyMlagDualPrimary(AntaTest): @@ -206,7 +211,6 @@ class VerifyMlagDualPrimary(AntaTest): ``` """ - name = "VerifyMlagDualPrimary" description = "Verifies the MLAG dual-primary detection parameters." categories: ClassVar[list[str]] = ["mlag"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)] @@ -226,25 +230,37 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyMlagDualPrimary.""" + self.result.is_success() errdisabled_action = "errdisableAllInterfaces" if self.inputs.errdisabled else "none" command_output = self.instance_commands[0].json_output + + # Skipping the test if MLAG is disabled if command_output["state"] == "disabled": self.result.is_skipped("MLAG is disabled") return + + # Verifies the dualPrimaryDetectionState if command_output["dualPrimaryDetectionState"] == "disabled": self.result.is_failure("Dual-primary detection is disabled") return - keys_to_verify = ["detail.dualPrimaryDetectionDelay", "detail.dualPrimaryAction", "dualPrimaryMlagRecoveryDelay", "dualPrimaryNonMlagRecoveryDelay"] - verified_output = {key: get_value(command_output, key) for key in keys_to_verify} - if ( - verified_output["detail.dualPrimaryDetectionDelay"] == self.inputs.detection_delay - and verified_output["detail.dualPrimaryAction"] == errdisabled_action - and verified_output["dualPrimaryMlagRecoveryDelay"] == self.inputs.recovery_delay - and verified_output["dualPrimaryNonMlagRecoveryDelay"] == self.inputs.recovery_delay_non_mlag - ): - self.result.is_success() - else: - self.result.is_failure(f"The dual-primary parameters are not configured properly: {verified_output}") + + # Verifies the dualPrimaryAction + if (primary_action := get_value(command_output, "detail.dualPrimaryAction")) != errdisabled_action: + self.result.is_failure(f"Dual-primary action mismatch - Expected: {errdisabled_action} Actual: {primary_action}") + + # Verifies the dualPrimaryDetectionDelay + if (detection_delay := get_value(command_output, "detail.dualPrimaryDetectionDelay")) != self.inputs.detection_delay: + self.result.is_failure(f"Dual-primary detection delay mismatch - Expected: {self.inputs.detection_delay} Actual: {detection_delay}") + + # Verifies the dualPrimaryMlagRecoveryDelay + if (recovery_delay := get_value(command_output, "dualPrimaryMlagRecoveryDelay")) != self.inputs.recovery_delay: + self.result.is_failure(f"Dual-primary MLAG recovery delay mismatch - Expected: {self.inputs.recovery_delay} Actual: {recovery_delay}") + + # Verifies the dualPrimaryNonMlagRecoveryDelay + if (recovery_delay_non_mlag := get_value(command_output, "dualPrimaryNonMlagRecoveryDelay")) != self.inputs.recovery_delay_non_mlag: + self.result.is_failure( + f"Dual-primary non MLAG recovery delay mismatch - Expected: {self.inputs.recovery_delay_non_mlag} Actual: {recovery_delay_non_mlag}" + ) class VerifyMlagPrimaryPriority(AntaTest): @@ -265,7 +281,6 @@ class VerifyMlagPrimaryPriority(AntaTest): ``` """ - name = "VerifyMlagPrimaryPriority" description = "Verifies the configuration of the MLAG primary priority." categories: ClassVar[list[str]] = ["mlag"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)] @@ -291,10 +306,8 @@ def test(self) -> None: # Check MLAG state if mlag_state != "primary": - self.result.is_failure("The device is not set as MLAG primary.") + self.result.is_failure("The device is not set as MLAG primary") # Check primary priority if primary_priority != self.inputs.primary_priority: - self.result.is_failure( - f"The primary priority does not match expected. Expected `{self.inputs.primary_priority}`, but found `{primary_priority}` instead.", - ) + self.result.is_failure(f"MLAG primary priority mismatch - Expected: {self.inputs.primary_priority} Actual: {primary_priority}") diff --git a/anta/tests/multicast.py b/anta/tests/multicast.py index 554bd5759..0f8a278cc 100644 --- a/anta/tests/multicast.py +++ b/anta/tests/multicast.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to multicast and IGMP tests.""" @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, ClassVar -from anta.custom_types import Vlan +from anta.custom_types import VlanId from anta.models import AntaCommand, AntaTest if TYPE_CHECKING: @@ -35,15 +35,13 @@ class VerifyIGMPSnoopingVlans(AntaTest): ``` """ - name = "VerifyIGMPSnoopingVlans" - description = "Verifies the IGMP snooping status for the provided VLANs." categories: ClassVar[list[str]] = ["multicast"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)] class Input(AntaTest.Input): """Input model for the VerifyIGMPSnoopingVlans test.""" - vlans: dict[Vlan, bool] + vlans: dict[VlanId, bool] """Dictionary with VLAN ID and whether IGMP snooping must be enabled (True) or disabled (False).""" @AntaTest.anta_test @@ -53,12 +51,12 @@ def test(self) -> None: self.result.is_success() for vlan, enabled in self.inputs.vlans.items(): if str(vlan) not in command_output["vlans"]: - self.result.is_failure(f"Supplied vlan {vlan} is not present on the device.") + self.result.is_failure(f"Supplied vlan {vlan} is not present on the device") continue - + expected_state = "enabled" if enabled else "disabled" igmp_state = command_output["vlans"][str(vlan)]["igmpSnoopingState"] - if igmp_state != "enabled" if enabled else igmp_state != "disabled": - self.result.is_failure(f"IGMP state for vlan {vlan} is {igmp_state}") + if igmp_state != expected_state: + self.result.is_failure(f"VLAN{vlan} - Incorrect IGMP state - Expected: {expected_state} Actual: {igmp_state}") class VerifyIGMPSnoopingGlobal(AntaTest): @@ -78,8 +76,6 @@ class VerifyIGMPSnoopingGlobal(AntaTest): ``` """ - name = "VerifyIGMPSnoopingGlobal" - description = "Verifies the IGMP snooping global configuration." categories: ClassVar[list[str]] = ["multicast"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)] @@ -95,5 +91,6 @@ def test(self) -> None: command_output = self.instance_commands[0].json_output self.result.is_success() igmp_state = command_output["igmpSnoopingState"] - if igmp_state != "enabled" if self.inputs.enabled else igmp_state != "disabled": - self.result.is_failure(f"IGMP state is not valid: {igmp_state}") + expected_state = "enabled" if self.inputs.enabled else "disabled" + if igmp_state != expected_state: + self.result.is_failure(f"IGMP state is not valid - Expected: {expected_state} Actual: {igmp_state}") diff --git a/anta/tests/path_selection.py b/anta/tests/path_selection.py new file mode 100644 index 000000000..0599ecd53 --- /dev/null +++ b/anta/tests/path_selection.py @@ -0,0 +1,158 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Test functions related to various router path-selection settings.""" + +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from typing import ClassVar + +from anta.decorators import skip_on_platforms +from anta.input_models.path_selection import DpsPath +from anta.models import AntaCommand, AntaTemplate, AntaTest +from anta.tools import get_value + + +class VerifyPathsHealth(AntaTest): + """Verifies the path and telemetry state of all paths under router path-selection. + + The expected states are 'IPsec established', 'Resolved' for path and 'active' for telemetry. + + Expected Results + ---------------- + * Success: The test will pass if all path states under router path-selection are either 'IPsec established' or 'Resolved' + and their telemetry state as 'active'. + * Failure: The test will fail if router path-selection is not configured or if any path state is not 'IPsec established' or 'Resolved', + or the telemetry state is 'inactive'. + + Examples + -------- + ```yaml + anta.tests.path_selection: + - VerifyPathsHealth: + ``` + """ + + categories: ClassVar[list[str]] = ["path-selection"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show path-selection paths", revision=1)] + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyPathsHealth.""" + self.result.is_success() + + command_output = self.instance_commands[0].json_output["dpsPeers"] + + # If no paths are configured for router path-selection, the test fails + if not command_output: + self.result.is_failure("No path configured for router path-selection") + return + + # Check the state of each path + for peer, peer_data in command_output.items(): + for group, group_data in peer_data["dpsGroups"].items(): + for path_data in group_data["dpsPaths"].values(): + path_state = path_data["state"] + session = path_data["dpsSessions"]["0"]["active"] + + # If the path state of any path is not 'ipsecEstablished' or 'routeResolved', the test fails + expected_state = ["ipsecEstablished", "routeResolved"] + if path_state not in expected_state: + self.result.is_failure(f"Peer: {peer} Path Group: {group} - Invalid path state - Expected: {', '.join(expected_state)} Actual: {path_state}") + + # If the telemetry state of any path is inactive, the test fails + elif not session: + self.result.is_failure(f"Peer: {peer} Path Group {group} - Telemetry state inactive") + + +class VerifySpecificPath(AntaTest): + """Verifies the DPS path and telemetry state of an IPv4 peer. + + This test performs the following checks: + + 1. Verifies that the specified peer is configured. + 2. Verifies that the specified path group is found. + 3. For each specified DPS path: + - Verifies that the expected source and destination address matches the expected. + - Verifies that the state is `ipsecEstablished` or `routeResolved`. + - Verifies that the telemetry state is `active`. + + Expected Results + ---------------- + * Success: The test will pass if the path state under router path-selection is either 'IPsecEstablished' or 'Resolved' + and telemetry state as 'active'. + * Failure: The test will fail if router path selection or the peer is not configured or if the path state is not 'IPsec established' or 'Resolved', + or the telemetry state is 'inactive'. + + Examples + -------- + ```yaml + anta.tests.path_selection: + - VerifySpecificPath: + paths: + - peer: 10.255.0.1 + path_group: internet + source_address: 100.64.3.2 + destination_address: 100.64.1.2 + ``` + """ + + categories: ClassVar[list[str]] = ["path-selection"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show path-selection paths", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifySpecificPath test.""" + + paths: list[DpsPath] + """List of router paths to verify.""" + RouterPath: ClassVar[type[DpsPath]] = DpsPath + """To maintain backward compatibility.""" + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifySpecificPath.""" + self.result.is_success() + + command_output = self.instance_commands[0].json_output + + # If the dpsPeers details are not found in the command output, the test fails. + if not (dps_peers_details := get_value(command_output, "dpsPeers")): + self.result.is_failure("Router path-selection not configured") + return + + # Iterating on each DPS peer mentioned in the inputs. + for dps_path in self.inputs.paths: + peer = str(dps_path.peer) + peer_details = dps_peers_details.get(peer, {}) + # If the peer is not configured for the path group, the test fails + if not peer_details: + self.result.is_failure(f"{dps_path} - Peer not found") + continue + + path_group = dps_path.path_group + source = str(dps_path.source_address) + destination = str(dps_path.destination_address) + path_group_details = get_value(peer_details, f"dpsGroups..{path_group}..dpsPaths", separator="..") + # If the expected path group is not found for the peer, the test fails. + if not path_group_details: + self.result.is_failure(f"{dps_path} - No DPS path found for this peer and path group") + continue + + path_data = next((path for path in path_group_details.values() if (path.get("source") == source and path.get("destination") == destination)), None) + # Source and destination address do not match, the test fails. + if not path_data: + self.result.is_failure(f"{dps_path} - No path matching the source and destination found") + continue + + path_state = path_data.get("state") + session = get_value(path_data, "dpsSessions.0.active") + + # If the state of the path is not 'ipsecEstablished' or 'routeResolved', or the telemetry state is 'inactive', the test fails + if path_state not in ["ipsecEstablished", "routeResolved"]: + self.result.is_failure(f"{dps_path} - Invalid state path - Expected: ipsecEstablished, routeResolved Actual: {path_state}") + elif not session: + self.result.is_failure(f"{dps_path} - Telemetry state inactive for this path") diff --git a/anta/tests/profiles.py b/anta/tests/profiles.py index 859c8866c..7a22b7a07 100644 --- a/anta/tests/profiles.py +++ b/anta/tests/profiles.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to ASIC profile tests.""" @@ -33,7 +33,6 @@ class VerifyUnifiedForwardingTableMode(AntaTest): ``` """ - name = "VerifyUnifiedForwardingTableMode" description = "Verifies the device is using the expected UFT mode." categories: ClassVar[list[str]] = ["profiles"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show platform trident forwarding-table partition", revision=1)] @@ -44,7 +43,7 @@ class Input(AntaTest.Input): mode: Literal[0, 1, 2, 3, 4, "flexible"] """Expected UFT mode. Valid values are 0, 1, 2, 3, 4, or "flexible".""" - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyUnifiedForwardingTableMode.""" @@ -52,7 +51,7 @@ def test(self) -> None: if command_output["uftMode"] == str(self.inputs.mode): self.result.is_success() else: - self.result.is_failure(f"Device is not running correct UFT mode (expected: {self.inputs.mode} / running: {command_output['uftMode']})") + self.result.is_failure(f"Not running the correct UFT mode - Expected: {self.inputs.mode} Actual: {command_output['uftMode']}") class VerifyTcamProfile(AntaTest): @@ -72,7 +71,6 @@ class VerifyTcamProfile(AntaTest): ``` """ - name = "VerifyTcamProfile" description = "Verifies the device TCAM profile." categories: ClassVar[list[str]] = ["profiles"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware tcam profile", revision=1)] @@ -83,7 +81,7 @@ class Input(AntaTest.Input): profile: str """Expected TCAM profile.""" - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyTcamProfile.""" diff --git a/anta/tests/ptp.py b/anta/tests/ptp.py index eabda8835..ef8c0263e 100644 --- a/anta/tests/ptp.py +++ b/anta/tests/ptp.py @@ -1,4 +1,4 @@ -# Copyright (c) 2024 Arista Networks, Inc. +# Copyright (c) 2024-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to PTP tests.""" @@ -17,13 +17,13 @@ class VerifyPtpModeStatus(AntaTest): - """Verifies that the device is configured as a Precision Time Protocol (PTP) Boundary Clock (BC). + """Verifies that the device is configured as a PTP Boundary Clock. Expected Results ---------------- * Success: The test will pass if the device is a BC. * Failure: The test will fail if the device is not a BC. - * Error: The test will error if the 'ptpMode' variable is not present in the command output. + * Skipped: The test will be skipped if PTP is not configured on the device. Examples -------- @@ -33,29 +33,27 @@ class VerifyPtpModeStatus(AntaTest): ``` """ - name = "VerifyPtpModeStatus" - description = "Verifies that the device is configured as a PTP Boundary Clock." categories: ClassVar[list[str]] = ["ptp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyPtpModeStatus.""" command_output = self.instance_commands[0].json_output if (ptp_mode := command_output.get("ptpMode")) is None: - self.result.is_error("'ptpMode' variable is not present in the command output") + self.result.is_skipped("PTP is not configured") return if ptp_mode != "ptpBoundaryClock": - self.result.is_failure(f"The device is not configured as a PTP Boundary Clock: '{ptp_mode}'") + self.result.is_failure(f"Not configured as a PTP Boundary Clock - Actual: {ptp_mode}") else: self.result.is_success() class VerifyPtpGMStatus(AntaTest): - """Verifies that the device is locked to a valid Precision Time Protocol (PTP) Grandmaster (GM). + """Verifies that the device is locked to a valid PTP Grandmaster. To test PTP failover, re-run the test with a secondary GMID configured. @@ -63,7 +61,7 @@ class VerifyPtpGMStatus(AntaTest): ---------------- * Success: The test will pass if the device is locked to the provided Grandmaster. * Failure: The test will fail if the device is not locked to the provided Grandmaster. - * Error: The test will error if the 'gmClockIdentity' variable is not present in the command output. + * Skipped: The test will be skipped if PTP is not configured on the device. Examples -------- @@ -80,37 +78,32 @@ class Input(AntaTest.Input): gmid: str """Identifier of the Grandmaster to which the device should be locked.""" - name = "VerifyPtpGMStatus" - description = "Verifies that the device is locked to a valid PTP Grandmaster." categories: ClassVar[list[str]] = ["ptp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyPtpGMStatus.""" + self.result.is_success() command_output = self.instance_commands[0].json_output if (ptp_clock_summary := command_output.get("ptpClockSummary")) is None: - self.result.is_error("'ptpClockSummary' variable is not present in the command output") + self.result.is_skipped("PTP is not configured") return - if ptp_clock_summary["gmClockIdentity"] != self.inputs.gmid: - self.result.is_failure( - f"The device is locked to the following Grandmaster: '{ptp_clock_summary['gmClockIdentity']}', which differ from the expected one.", - ) - else: - self.result.is_success() + if (act_gmid := ptp_clock_summary["gmClockIdentity"]) != self.inputs.gmid: + self.result.is_failure(f"The device is locked to the incorrect Grandmaster - Expected: {self.inputs.gmid} Actual: {act_gmid}") class VerifyPtpLockStatus(AntaTest): - """Verifies that the device was locked to the upstream Precision Time Protocol (PTP) Grandmaster (GM) in the last minute. + """Verifies that the device was locked to the upstream PTP GM in the last minute. Expected Results ---------------- * Success: The test will pass if the device was locked to the upstream GM in the last minute. * Failure: The test will fail if the device was not locked to the upstream GM in the last minute. - * Error: The test will error if the 'lastSyncTime' variable is not present in the command output. + * Skipped: The test will be skipped if PTP is not configured on the device. Examples -------- @@ -120,12 +113,10 @@ class VerifyPtpLockStatus(AntaTest): ``` """ - name = "VerifyPtpLockStatus" - description = "Verifies that the device was locked to the upstream PTP GM in the last minute." categories: ClassVar[list[str]] = ["ptp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyPtpLockStatus.""" @@ -133,25 +124,25 @@ def test(self) -> None: command_output = self.instance_commands[0].json_output if (ptp_clock_summary := command_output.get("ptpClockSummary")) is None: - self.result.is_error("'ptpClockSummary' variable is not present in the command output") + self.result.is_skipped("PTP is not configured") return time_difference = ptp_clock_summary["currentPtpSystemTime"] - ptp_clock_summary["lastSyncTime"] if time_difference >= threshold: - self.result.is_failure(f"The device lock is more than {threshold}s old: {time_difference}s") + self.result.is_failure(f"Lock is more than {threshold}s old - Actual: {time_difference}s") else: self.result.is_success() class VerifyPtpOffset(AntaTest): - """Verifies that the Precision Time Protocol (PTP) timing offset is within +/- 1000ns from the master clock. + """Verifies that the PTP timing offset is within +/- 1000ns from the master clock. Expected Results ---------------- * Success: The test will pass if the PTP timing offset is within +/- 1000ns from the master clock. * Failure: The test will fail if the PTP timing offset is greater than +/- 1000ns from the master clock. - * Skipped: The test will be skipped if PTP is not configured. + * Skipped: The test will be skipped if PTP is not configured on the device. Examples -------- @@ -161,19 +152,17 @@ class VerifyPtpOffset(AntaTest): ``` """ - name = "VerifyPtpOffset" - description = "Verifies that the PTP timing offset is within +/- 1000ns from the master clock." categories: ClassVar[list[str]] = ["ptp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp monitor", revision=1)] - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyPtpOffset.""" threshold = 1000 - offset_interfaces: dict[str, list[int]] = {} + self.result.is_success() command_output = self.instance_commands[0].json_output - + offset_interfaces: dict[str, list[int]] = {} if not command_output["ptpMonitorData"]: self.result.is_skipped("PTP is not configured") return @@ -182,14 +171,12 @@ def test(self) -> None: if abs(interface["offsetFromMaster"]) > threshold: offset_interfaces.setdefault(interface["intf"], []).append(interface["offsetFromMaster"]) - if offset_interfaces: - self.result.is_failure(f"The device timing offset from master is greater than +/- {threshold}ns: {offset_interfaces}") - else: - self.result.is_success() + for interface, data in offset_interfaces.items(): + self.result.is_failure(f"Interface: {interface} - Timing offset from master is greater than +/- {threshold}ns: Actual: {', '.join(map(str, data))}") class VerifyPtpPortModeStatus(AntaTest): - """Verifies that all interfaces are in a valid Precision Time Protocol (PTP) state. + """Verifies the PTP interfaces state. The interfaces can be in one of the following state: Master, Slave, Passive, or Disabled. @@ -206,12 +193,10 @@ class VerifyPtpPortModeStatus(AntaTest): ``` """ - name = "VerifyPtpPortModeStatus" - description = "Verifies the PTP interfaces state." categories: ClassVar[list[str]] = ["ptp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] - @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) + @skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab", "vEOS"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyPtpPortModeStatus.""" @@ -232,4 +217,4 @@ def test(self) -> None: if not invalid_interfaces: self.result.is_success() else: - self.result.is_failure(f"The following interface(s) are not in a valid PTP state: '{invalid_interfaces}'") + self.result.is_failure(f"The following interface(s) are not in a valid PTP state: {', '.join(invalid_interfaces)}") diff --git a/anta/tests/routing/__init__.py b/anta/tests/routing/__init__.py index d4b378697..85ca1ab69 100644 --- a/anta/tests/routing/__init__.py +++ b/anta/tests/routing/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Package related to routing tests.""" diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py index 3b99a0276..018946d68 100644 --- a/anta/tests/routing/bgp.py +++ b/anta/tests/routing/bgp.py @@ -1,162 +1,92 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to BGP tests.""" -# Mypy does not understand AntaTest.Input typing +# pylint: disable=too-many-lines # mypy: disable-error-code=attr-defined from __future__ import annotations -from ipaddress import IPv4Address, IPv4Network, IPv6Address -from typing import Any, ClassVar +from typing import Any, ClassVar, TypeVar -from pydantic import BaseModel, Field, PositiveInt, model_validator -from pydantic.v1.utils import deep_update -from pydantic_extra_types.mac_address import MacAddress +from pydantic import PositiveInt, field_validator -from anta.custom_types import Afi, MultiProtocolCaps, Safi, Vni +from anta.input_models.routing.bgp import BgpAddressFamily, BgpAfi, BgpNeighbor, BgpPeer, BgpRoute, BgpVrf, VxlanEndpoint from anta.models import AntaCommand, AntaTemplate, AntaTest -from anta.tools import get_item, get_value +from anta.tools import format_data, get_item, get_value +# Using a TypeVar for the BgpPeer model since mypy thinks it's a ClassVar and not a valid type when used in field validators +T = TypeVar("T", bound=BgpPeer) -def _add_bgp_failures(failures: dict[tuple[str, str | None], dict[str, Any]], afi: Afi, safi: Safi | None, vrf: str, issue: str | dict[str, Any]) -> None: - """Add a BGP failure entry to the given `failures` dictionary. +# TODO: Refactor to reduce the number of lines in this module later - Note: This function modifies `failures` in-place. - Args: - ---- - failures: The dictionary to which the failure will be added. - afi: The address family identifier. - vrf: The VRF name. - safi: The subsequent address family identifier. - issue: A description of the issue. Can be of any type. +def _check_bgp_neighbor_capability(capability_status: dict[str, bool]) -> bool: + """Check if a BGP neighbor capability is advertised, received, and enabled. - Example: - ------- - The `failures` dictionary will have the following structure: - { - ('afi1', 'safi1'): { - 'afi': 'afi1', - 'safi': 'safi1', - 'vrfs': { - 'vrf1': issue1, - 'vrf2': issue2 - } - }, - ('afi2', None): { - 'afi': 'afi2', - 'vrfs': { - 'vrf1': issue3 - } - } - } - - """ - key = (afi, safi) - - failure_entry = failures.setdefault(key, {"afi": afi, "safi": safi, "vrfs": {}}) if safi else failures.setdefault(key, {"afi": afi, "vrfs": {}}) - - failure_entry["vrfs"][vrf] = issue - - -def _check_peer_issues(peer_data: dict[str, Any] | None) -> dict[str, Any]: - """Check for issues in BGP peer data. - - Args: - ---- - peer_data: The BGP peer data dictionary nested in the `show bgp summary` command. + Parameters + ---------- + capability_status + A dictionary containing the capability status. Returns ------- - dict: Dictionary with keys indicating issues or an empty dictionary if no issues. - - Raises - ------ - ValueError: If any of the required keys ("peerState", "inMsgQueue", "outMsgQueue") are missing in `peer_data`, i.e. invalid BGP peer data. + bool + True if the capability is advertised, received, and enabled, False otherwise. - Example: + Example ------- - {"peerNotFound": True} - {"peerState": "Idle", "inMsgQueue": 2, "outMsgQueue": 0} - {} - + >>> _check_bgp_neighbor_capability({"advertised": True, "received": True, "enabled": True}) + True """ - if peer_data is None: - return {"peerNotFound": True} - - if any(key not in peer_data for key in ["peerState", "inMsgQueue", "outMsgQueue"]): - msg = "Provided BGP peer data is invalid." - raise ValueError(msg) - - if peer_data["peerState"] != "Established" or peer_data["inMsgQueue"] != 0 or peer_data["outMsgQueue"] != 0: - return {"peerState": peer_data["peerState"], "inMsgQueue": peer_data["inMsgQueue"], "outMsgQueue": peer_data["outMsgQueue"]} - - return {} - - -def _add_bgp_routes_failure( - bgp_routes: list[str], bgp_output: dict[str, Any], peer: str, vrf: str, route_type: str = "advertised_routes" -) -> dict[str, dict[str, dict[str, dict[str, list[str]]]]]: - """Identify missing BGP routes and invalid or inactive route entries. + return all(capability_status.get(state, False) for state in ("advertised", "received", "enabled")) - This function checks the BGP output from the device against the expected routes. - It identifies any missing routes as well as any routes that are invalid or inactive. The results are returned in a dictionary. +def _get_bgp_peer_data(peer: BgpPeer, command_output: dict[str, Any]) -> dict[str, Any] | None: + """Retrieve BGP peer data for the given peer from the command output. - Args: - ---- - bgp_routes: The list of expected routes. - bgp_output: The BGP output from the device. - peer: The IP address of the BGP peer. - vrf: The name of the VRF for which the routes need to be verified. - route_type: The type of BGP routes. Defaults to 'advertised_routes'. + Parameters + ---------- + peer + The BgpPeer object to look up. + command_output + Parsed output of the command. Returns ------- - dict[str, dict[str, dict[str, dict[str, list[str]]]]]: A dictionary containing the missing routes and invalid or inactive routes. - + dict | None + The peer data dictionary if found, otherwise None. """ - # Prepare the failure routes dictionary - failure_routes: dict[str, dict[str, Any]] = {} - - # Iterate over the expected BGP routes - for route in bgp_routes: - str_route = str(route) - failure = {"bgp_peers": {peer: {vrf: {route_type: {str_route: Any}}}}} - - # Check if the route is missing in the BGP output - if str_route not in bgp_output: - # If missing, add it to the failure routes dictionary - failure["bgp_peers"][peer][vrf][route_type][str_route] = "Not found" - failure_routes = deep_update(failure_routes, failure) - continue - - # Check if the route is active and valid - is_active = bgp_output[str_route]["bgpRoutePaths"][0]["routeType"]["valid"] - is_valid = bgp_output[str_route]["bgpRoutePaths"][0]["routeType"]["active"] + if peer.interface is not None: + # RFC5549 + identity = peer.interface + lookup_key = "ifName" + else: + identity = str(peer.peer_address) + lookup_key = "peerAddress" - # If the route is either inactive or invalid, add it to the failure routes dictionary - if not is_active or not is_valid: - failure["bgp_peers"][peer][vrf][route_type][str_route] = {"valid": is_valid, "active": is_active} - failure_routes = deep_update(failure_routes, failure) + peer_list = get_value(command_output, f"vrfs.{peer.vrf}.peerList", default=[]) - return failure_routes + return get_item(peer_list, lookup_key, identity) class VerifyBGPPeerCount(AntaTest): - """Verifies the count of BGP peers for a given address family. + """Verifies the count of BGP peers for given address families. - It supports multiple types of Address Families Identifiers (AFI) and Subsequent Address Family Identifiers (SAFI). + This test performs the following checks for each specified address family: - For SR-TE SAFI, the EOS command supports sr-te first then ipv4/ipv6 (AFI) which is handled automatically in this test. - - Please refer to the Input class attributes below for details. + 1. Confirms that the specified VRF is configured. + 2. Counts the number of peers that are: + - If `check_peer_state` is set to True, Counts the number of BGP peers that are in the `Established` state and + have successfully negotiated the specified AFI/SAFI + - If `check_peer_state` is set to False, skips validation of the `Established` state and AFI/SAFI negotiation. Expected Results ---------------- - * Success: If the count of BGP peers matches the expected count for each address family and VRF. - * Failure: If the count of BGP peers does not match the expected count, or if BGP is not configured for an expected VRF or address family. + * Success: If the count of BGP peers matches the expected count with `check_peer_state` enabled/disabled. + * Failure: If any of the following occur: + - The specified VRF is not configured. + - The BGP peer count does not match expected value with `check_peer_state` enabled/disabled." Examples -------- @@ -182,130 +112,79 @@ class VerifyBGPPeerCount(AntaTest): ``` """ - name = "VerifyBGPPeerCount" - description = "Verifies the count of BGP peers." categories: ClassVar[list[str]] = ["bgp"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ - AntaTemplate(template="show bgp {afi} {safi} summary vrf {vrf}", revision=3), - AntaTemplate(template="show bgp {afi} summary", revision=3), - ] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp summary vrf all", revision=1)] class Input(AntaTest.Input): """Input model for the VerifyBGPPeerCount test.""" - address_families: list[BgpAfi] - """List of BGP address families (BgpAfi).""" - - class BgpAfi(BaseModel): - """Model for a BGP address family (AFI) and subsequent address family (SAFI).""" - - afi: Afi - """BGP address family (AFI).""" - safi: Safi | None = None - """Optional BGP subsequent service family (SAFI). - - If the input `afi` is `ipv4` or `ipv6`, a valid `safi` must be provided. - """ - vrf: str = "default" - """ - Optional VRF for IPv4 and IPv6. If not provided, it defaults to `default`. - - If the input `afi` is not `ipv4` or `ipv6`, e.g. `evpn`, `vrf` must be `default`. - """ - num_peers: PositiveInt - """Number of expected BGP peer(s).""" - - @model_validator(mode="after") - def validate_inputs(self: BaseModel) -> BaseModel: - """Validate the inputs provided to the BgpAfi class. - - If afi is either ipv4 or ipv6, safi must be provided. - - If afi is not ipv4 or ipv6, safi must not be provided and vrf must be default. - """ - if self.afi in ["ipv4", "ipv6"]: - if self.safi is None: - msg = "'safi' must be provided when afi is ipv4 or ipv6" - raise ValueError(msg) - elif self.safi is not None: - msg = "'safi' must not be provided when afi is not ipv4 or ipv6" - raise ValueError(msg) - elif self.vrf != "default": - msg = "'vrf' must be default when afi is not ipv4 or ipv6" + address_families: list[BgpAddressFamily] + """List of BGP address families.""" + BgpAfi: ClassVar[type[BgpAfi]] = BgpAfi + + @field_validator("address_families") + @classmethod + def validate_address_families(cls, address_families: list[BgpAddressFamily]) -> list[BgpAddressFamily]: + """Validate that 'num_peers' field is provided in each address family.""" + for af in address_families: + if af.num_peers is None: + msg = f"{af} 'num_peers' field missing in the input" raise ValueError(msg) - return self - - def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Render the template for each BGP address family in the input list.""" - commands = [] - for afi in self.inputs.address_families: - if template == VerifyBGPPeerCount.commands[0] and afi.afi in ["ipv4", "ipv6"] and afi.safi != "sr-te": - commands.append(template.render(afi=afi.afi, safi=afi.safi, vrf=afi.vrf)) - - # For SR-TE SAFI, the EOS command supports sr-te first then ipv4/ipv6 - elif template == VerifyBGPPeerCount.commands[0] and afi.afi in ["ipv4", "ipv6"] and afi.safi == "sr-te": - commands.append(template.render(afi=afi.safi, safi=afi.afi, vrf=afi.vrf)) - elif template == VerifyBGPPeerCount.commands[1] and afi.afi not in ["ipv4", "ipv6"]: - commands.append(template.render(afi=afi.afi)) - return commands + return address_families @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBGPPeerCount.""" self.result.is_success() - failures: dict[tuple[str, Any], dict[str, Any]] = {} - - for command in self.instance_commands: - num_peers = None - peer_count = 0 - command_output = command.json_output - - afi = command.params.afi - safi = command.params.safi - afi_vrf = command.params.vrf or "default" + output = self.instance_commands[0].json_output - # Swapping AFI and SAFI in case of SR-TE - if afi == "sr-te": - afi, safi = safi, afi - - for input_entry in self.inputs.address_families: - if input_entry.afi == afi and input_entry.safi == safi and input_entry.vrf == afi_vrf: - num_peers = input_entry.num_peers - break - - if not (vrfs := command_output.get("vrfs")): - _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue="Not Configured") + for address_family in self.inputs.address_families: + # Check if the VRF is configured + if (vrf_output := get_value(output, f"vrfs.{address_family.vrf}")) is None: + self.result.is_failure(f"{address_family} - VRF not configured") continue - if afi_vrf == "all": - for vrf_data in vrfs.values(): - peer_count += len(vrf_data["peers"]) + peers_data = vrf_output.get("peers", {}).values() + if not address_family.check_peer_state: + # Count the number of peers without considering the state and negotiated AFI/SAFI check if the count matches the expected count + peer_count = sum(1 for peer_data in peers_data if address_family.eos_key in peer_data) else: - peer_count += len(command_output["vrfs"][afi_vrf]["peers"]) + # Count the number of established peers with negotiated AFI/SAFI + peer_count = sum( + 1 + for peer_data in peers_data + if peer_data.get("peerState") == "Established" and get_value(peer_data, f"{address_family.eos_key}.afiSafiState") == "negotiated" + ) - if peer_count != num_peers: - _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue=f"Expected: {num_peers}, Actual: {peer_count}") - - if failures: - self.result.is_failure(f"Failures: {list(failures.values())}") + # Check if the count matches the expected count + if address_family.num_peers != peer_count: + self.result.is_failure(f"{address_family} - Peer count mismatch - Expected: {address_family.num_peers} Actual: {peer_count}") class VerifyBGPPeersHealth(AntaTest): - """Verifies the health of BGP peers. - - It will validate that all BGP sessions are established and all message queues for these BGP sessions are empty for a given address family. + """Verifies the health of BGP peers for given address families. - It supports multiple types of Address Families Identifiers (AFI) and Subsequent Address Family Identifiers (SAFI). + This test performs the following checks for each specified address family: - For SR-TE SAFI, the EOS command supports sr-te first then ipv4/ipv6 (AFI) which is handled automatically in this test. - - Please refer to the Input class attributes below for details. + 1. Validates that the VRF is configured. + 2. Checks if there are any peers for the given AFI/SAFI. + 3. For each relevant peer: + - Verifies that the BGP session is `Established` and, if specified, has remained established for at least the duration given by `minimum_established_time`. + - Confirms that the AFI/SAFI state is `negotiated`. + - Checks that both input and output TCP message queues are empty. + Can be disabled by setting `check_tcp_queues` to `False`. Expected Results ---------------- - * Success: If all BGP sessions are established and all messages queues are empty for each address family and VRF. - * Failure: If there are issues with any of the BGP sessions, or if BGP is not configured for an expected VRF or address family. + * Success: If all checks pass for all specified address families and their peers. + * Failure: If any of the following occur: + - The specified VRF is not configured. + - No peers are found for a given AFI/SAFI. + - A peer's session state is not `Established` or if specified, has not remained established for at least the duration specified by + the `minimum_established_time`. + - The AFI/SAFI state is not 'negotiated' for any peer. + - Any TCP message queue (input or output) is not empty when `check_tcp_queues` is `True` (default). Examples -------- @@ -313,6 +192,7 @@ class VerifyBGPPeersHealth(AntaTest): anta.tests.routing: bgp: - VerifyBGPPeersHealth: + minimum_established_time: 10000 address_families: - afi: "evpn" - afi: "ipv4" @@ -321,130 +201,90 @@ class VerifyBGPPeersHealth(AntaTest): - afi: "ipv6" safi: "unicast" vrf: "DEV" + check_tcp_queues: false ``` """ - name = "VerifyBGPPeersHealth" - description = "Verifies the health of BGP peers" categories: ClassVar[list[str]] = ["bgp"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ - AntaTemplate(template="show bgp {afi} {safi} summary vrf {vrf}", revision=3), - AntaTemplate(template="show bgp {afi} summary", revision=3), - ] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] class Input(AntaTest.Input): """Input model for the VerifyBGPPeersHealth test.""" - address_families: list[BgpAfi] - """List of BGP address families (BgpAfi).""" - - class BgpAfi(BaseModel): - """Model for a BGP address family (AFI) and subsequent address family (SAFI).""" - - afi: Afi - """BGP address family (AFI).""" - safi: Safi | None = None - """Optional BGP subsequent service family (SAFI). - - If the input `afi` is `ipv4` or `ipv6`, a valid `safi` must be provided. - """ - vrf: str = "default" - """ - Optional VRF for IPv4 and IPv6. If not provided, it defaults to `default`. - - If the input `afi` is not `ipv4` or `ipv6`, e.g. `evpn`, `vrf` must be `default`. - """ - - @model_validator(mode="after") - def validate_inputs(self: BaseModel) -> BaseModel: - """Validate the inputs provided to the BgpAfi class. - - If afi is either ipv4 or ipv6, safi must be provided. - - If afi is not ipv4 or ipv6, safi must not be provided and vrf must be default. - """ - if self.afi in ["ipv4", "ipv6"]: - if self.safi is None: - msg = "'safi' must be provided when afi is ipv4 or ipv6" - raise ValueError(msg) - elif self.safi is not None: - msg = "'safi' must not be provided when afi is not ipv4 or ipv6" - raise ValueError(msg) - elif self.vrf != "default": - msg = "'vrf' must be default when afi is not ipv4 or ipv6" - raise ValueError(msg) - return self - - def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Render the template for each BGP address family in the input list.""" - commands = [] - for afi in self.inputs.address_families: - if template == VerifyBGPPeersHealth.commands[0] and afi.afi in ["ipv4", "ipv6"] and afi.safi != "sr-te": - commands.append(template.render(afi=afi.afi, safi=afi.safi, vrf=afi.vrf)) - - # For SR-TE SAFI, the EOS command supports sr-te first then ipv4/ipv6 - elif template == VerifyBGPPeersHealth.commands[0] and afi.afi in ["ipv4", "ipv6"] and afi.safi == "sr-te": - commands.append(template.render(afi=afi.safi, safi=afi.afi, vrf=afi.vrf)) - elif template == VerifyBGPPeersHealth.commands[1] and afi.afi not in ["ipv4", "ipv6"]: - commands.append(template.render(afi=afi.afi)) - return commands + minimum_established_time: PositiveInt | None = None + """Minimum established time (seconds) for all the BGP sessions.""" + address_families: list[BgpAddressFamily] + """List of BGP address families.""" + BgpAfi: ClassVar[type[BgpAfi]] = BgpAfi @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBGPPeersHealth.""" self.result.is_success() - failures: dict[tuple[str, Any], dict[str, Any]] = {} - - for command in self.instance_commands: - command_output = command.json_output + output = self.instance_commands[0].json_output - afi = command.params.afi - safi = command.params.safi + for address_family in self.inputs.address_families: + # Check if the VRF is configured + if (vrf_output := get_value(output, f"vrfs.{address_family.vrf}")) is None: + self.result.is_failure(f"{address_family} - VRF not configured") + continue - # Swapping AFI and SAFI in case of SR-TE - if afi == "sr-te": - afi, safi = safi, afi - afi_vrf = command.params.vrf or "default" + # Check if any peers are found for this AFI/SAFI + relevant_peers = [ + peer for peer in vrf_output.get("peerList", []) if get_value(peer, f"neighborCapabilities.multiprotocolCaps.{address_family.eos_key}") is not None + ] - if not (vrfs := command_output.get("vrfs")): - _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue="Not Configured") + if not relevant_peers: + self.result.is_failure(f"{address_family} - No peers found") continue - for vrf, vrf_data in vrfs.items(): - if not (peers := vrf_data.get("peers")): - _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue="No Peers") + for peer in relevant_peers: + # Check if the BGP session is established + if peer["state"] != "Established": + self.result.is_failure(f"{address_family} Peer: {peer['peerAddress']} - Incorrect session state - Expected: Established Actual: {peer['state']}") continue - peer_issues = {} - for peer, peer_data in peers.items(): - issues = _check_peer_issues(peer_data) - - if issues: - peer_issues[peer] = issues + if self.inputs.minimum_established_time and (act_time := peer["establishedTime"]) < self.inputs.minimum_established_time: + msg = f"BGP session not established for the minimum required duration - Expected: {self.inputs.minimum_established_time}s Actual: {act_time}s" + self.result.is_failure(f"{address_family} Peer: {peer['peerAddress']} - {msg}") - if peer_issues: - _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=vrf, issue=peer_issues) + # Check if the AFI/SAFI state is negotiated + capability_status = get_value(peer, f"neighborCapabilities.multiprotocolCaps.{address_family.eos_key}") + if not _check_bgp_neighbor_capability(capability_status): + self.result.is_failure(f"{address_family} Peer: {peer['peerAddress']} - AFI/SAFI state is not negotiated - {format_data(capability_status)}") - if failures: - self.result.is_failure(f"Failures: {list(failures.values())}") + # Check the TCP session message queues + if address_family.check_tcp_queues: + inq = peer["peerTcpInfo"]["inputQueueLength"] + outq = peer["peerTcpInfo"]["outputQueueLength"] + if inq != 0 or outq != 0: + self.result.is_failure(f"{address_family} Peer: {peer['peerAddress']} - Session has non-empty message queues - InQ: {inq} OutQ: {outq}") class VerifyBGPSpecificPeers(AntaTest): - """Verifies the health of specific BGP peer(s). - - It will validate that the BGP session is established and all message queues for this BGP session are empty for the given peer(s). - - It supports multiple types of Address Families Identifiers (AFI) and Subsequent Address Family Identifiers (SAFI). + """Verifies the health of specific BGP peer(s) for given address families. - For SR-TE SAFI, the EOS command supports sr-te first then ipv4/ipv6 (AFI) which is handled automatically in this test. + This test performs the following checks for each specified address family and peer: - Please refer to the Input class attributes below for details. + 1. Confirms that the specified VRF is configured. + 2. For each specified peer: + - Verifies that the peer is found in the BGP configuration. + - Verifies that the BGP session is `Established` and, if specified, has remained established for at least the duration given by `minimum_established_time`. + - Confirms that the AFI/SAFI state is `negotiated`. + - Ensures that both input and output TCP message queues are empty. + Can be disabled by setting `check_tcp_queues` to `False`. Expected Results ---------------- - * Success: If the BGP session is established and all messages queues are empty for each given peer. - * Failure: If the BGP session has issues or is not configured, or if BGP is not configured for an expected VRF or address family. + * Success: If all checks pass for all specified peers in all address families. + * Failure: If any of the following occur: + - The specified VRF is not configured. + - A specified peer is not found in the BGP configuration. + - A peer's session state is not `Established` or if specified, has not remained established for at least the duration specified by + the `minimum_established_time`. + - The AFI/SAFI state is not `negotiated` for a peer. + - Any TCP message queue (input or output) is not empty for a peer when `check_tcp_queues` is `True` (default). Examples -------- @@ -452,6 +292,7 @@ class VerifyBGPSpecificPeers(AntaTest): anta.tests.routing: bgp: - VerifyBGPSpecificPeers: + minimum_established_time: 10000 address_families: - afi: "evpn" peers: @@ -467,130 +308,184 @@ class VerifyBGPSpecificPeers(AntaTest): ``` """ - name = "VerifyBGPSpecificPeers" - description = "Verifies the health of specific BGP peer(s)." categories: ClassVar[list[str]] = ["bgp"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ - AntaTemplate(template="show bgp {afi} {safi} summary vrf {vrf}", revision=3), - AntaTemplate(template="show bgp {afi} summary", revision=3), - ] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] class Input(AntaTest.Input): """Input model for the VerifyBGPSpecificPeers test.""" - address_families: list[BgpAfi] - """List of BGP address families (BgpAfi).""" - - class BgpAfi(BaseModel): - """Model for a BGP address family (AFI) and subsequent address family (SAFI).""" - - afi: Afi - """BGP address family (AFI).""" - safi: Safi | None = None - """Optional BGP subsequent service family (SAFI). - - If the input `afi` is `ipv4` or `ipv6`, a valid `safi` must be provided. - """ - vrf: str = "default" - """ - Optional VRF for IPv4 and IPv6. If not provided, it defaults to `default`. - - `all` is NOT supported. - - If the input `afi` is not `ipv4` or `ipv6`, e.g. `evpn`, `vrf` must be `default`. - """ - peers: list[IPv4Address | IPv6Address] - """List of BGP IPv4 or IPv6 peer.""" - - @model_validator(mode="after") - def validate_inputs(self: BaseModel) -> BaseModel: - """Validate the inputs provided to the BgpAfi class. - - If afi is either ipv4 or ipv6, safi must be provided and vrf must NOT be all. - - If afi is not ipv4 or ipv6, safi must not be provided and vrf must be default. - """ - if self.afi in ["ipv4", "ipv6"]: - if self.safi is None: - msg = "'safi' must be provided when afi is ipv4 or ipv6" - raise ValueError(msg) - if self.vrf == "all": - msg = "'all' is not supported in this test. Use VerifyBGPPeersHealth test instead." - raise ValueError(msg) - elif self.safi is not None: - msg = "'safi' must not be provided when afi is not ipv4 or ipv6" - raise ValueError(msg) - elif self.vrf != "default": - msg = "'vrf' must be default when afi is not ipv4 or ipv6" + minimum_established_time: PositiveInt | None = None + """Minimum established time (seconds) for all the BGP sessions.""" + address_families: list[BgpAddressFamily] + """List of BGP address families.""" + BgpAfi: ClassVar[type[BgpAfi]] = BgpAfi + + @field_validator("address_families") + @classmethod + def validate_address_families(cls, address_families: list[BgpAddressFamily]) -> list[BgpAddressFamily]: + """Validate that 'peers' field is provided in each address family.""" + for af in address_families: + if af.peers is None: + msg = f"{af} 'peers' field missing in the input" raise ValueError(msg) - return self - - def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Render the template for each BGP address family in the input list.""" - commands = [] - - for afi in self.inputs.address_families: - if template == VerifyBGPSpecificPeers.commands[0] and afi.afi in ["ipv4", "ipv6"] and afi.safi != "sr-te": - commands.append(template.render(afi=afi.afi, safi=afi.safi, vrf=afi.vrf)) - - # For SR-TE SAFI, the EOS command supports sr-te first then ipv4/ipv6 - elif template == VerifyBGPSpecificPeers.commands[0] and afi.afi in ["ipv4", "ipv6"] and afi.safi == "sr-te": - commands.append(template.render(afi=afi.safi, safi=afi.afi, vrf=afi.vrf)) - elif template == VerifyBGPSpecificPeers.commands[1] and afi.afi not in ["ipv4", "ipv6"]: - commands.append(template.render(afi=afi.afi)) - return commands + return address_families @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBGPSpecificPeers.""" self.result.is_success() - failures: dict[tuple[str, Any], dict[str, Any]] = {} + output = self.instance_commands[0].json_output + + for address_family in self.inputs.address_families: + # Check if the VRF is configured + if (vrf_output := get_value(output, f"vrfs.{address_family.vrf}")) is None: + self.result.is_failure(f"{address_family} - VRF not configured") + continue + + for peer in address_family.peers: + peer_ip = str(peer) + + # Check if the peer is found + if (peer_data := get_item(vrf_output["peerList"], "peerAddress", peer_ip)) is None: + self.result.is_failure(f"{address_family} Peer: {peer_ip} - Not configured") + continue + + # Check if the BGP session is established + if peer_data["state"] != "Established": + self.result.is_failure(f"{address_family} Peer: {peer_ip} - Incorrect session state - Expected: Established Actual: {peer_data['state']}") + continue + + if self.inputs.minimum_established_time and (act_time := peer_data["establishedTime"]) < self.inputs.minimum_established_time: + msg = f"BGP session not established for the minimum required duration - Expected: {self.inputs.minimum_established_time}s Actual: {act_time}s" + self.result.is_failure(f"{address_family} Peer: {peer_ip} - {msg}") + + # Check if the AFI/SAFI state is negotiated + capability_status = get_value(peer_data, f"neighborCapabilities.multiprotocolCaps.{address_family.eos_key}") + if not capability_status: + self.result.is_failure(f"{address_family} Peer: {peer_ip} - AFI/SAFI state is not negotiated") + + if capability_status and not _check_bgp_neighbor_capability(capability_status): + self.result.is_failure(f"{address_family} Peer: {peer_ip} - AFI/SAFI state is not negotiated - {format_data(capability_status)}") + + # Check the TCP session message queues + inq = peer_data["peerTcpInfo"]["inputQueueLength"] + outq = peer_data["peerTcpInfo"]["outputQueueLength"] + if address_family.check_tcp_queues and (inq != 0 or outq != 0): + self.result.is_failure(f"{address_family} Peer: {peer_ip} - Session has non-empty message queues - InQ: {inq} OutQ: {outq}") + + +class VerifyBGPPeerSession(AntaTest): + """Verifies the session state of BGP peers. + + This test performs the following checks for each specified peer: + + 1. Verifies that the peer is found in its VRF in the BGP configuration. + 2. Verifies that the BGP session is `Established` and, if specified, has remained established for at least the duration given by `minimum_established_time`. + 3. Ensures that both input and output TCP message queues are empty. + Can be disabled by setting `check_tcp_queues` input flag to `False`. + + Expected Results + ---------------- + * Success: If all of the following conditions are met: + - All specified peers are found in the BGP configuration. + - All peers sessions state are `Established` and, if specified, has remained established for at least the duration given by `minimum_established_time`. + - All peers have empty TCP message queues if `check_tcp_queues` is `True` (default). + - All peers are established for specified minimum duration. + * Failure: If any of the following occur: + - A specified peer is not found in the BGP configuration. + - A peer's session state is not `Established` or if specified, has not remained established for at least the duration specified by + the `minimum_established_time`. + - A peer has non-empty TCP message queues (input or output) when `check_tcp_queues` is `True`. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPPeerSession: + minimum_established_time: 10000 + check_tcp_queues: false + bgp_peers: + - peer_address: 10.1.0.1 + vrf: default + - peer_address: 10.1.0.2 + vrf: default + - peer_address: 10.1.255.2 + vrf: DEV + - peer_address: 10.1.255.4 + vrf: DEV + - peer_address: fd00:dc:1::1 + vrf: default + # RFC5549 + - interface: Ethernet1 + vrf: default + - interface: Vlan3499 + vrf: PROD + ``` + """ - for command in self.instance_commands: - command_output = command.json_output + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPPeerSession test.""" - afi = command.params.afi - safi = command.params.safi - afi_vrf = command.params.vrf or "default" + minimum_established_time: PositiveInt | None = None + """Minimum established time (seconds) for all BGP sessions.""" + check_tcp_queues: bool = True + """Flag to check if the TCP session queues are empty for all BGP peers.""" + bgp_peers: list[BgpPeer] + """List of BGP peers.""" - # Swapping AFI and SAFI in case of SR-TE - if afi == "sr-te": - afi, safi = safi, afi + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPPeerSession.""" + self.result.is_success() - for input_entry in self.inputs.address_families: - if input_entry.afi == afi and input_entry.safi == safi and input_entry.vrf == afi_vrf: - afi_peers = input_entry.peers - break + output = self.instance_commands[0].json_output - if not (vrfs := command_output.get("vrfs")): - _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue="Not Configured") + for peer in self.inputs.bgp_peers: + # Check if the peer is found + if (peer_data := _get_bgp_peer_data(peer, output)) is None: + self.result.is_failure(f"{peer} - Not found") continue - peer_issues = {} - for peer in afi_peers: - peer_ip = str(peer) - peer_data = get_value(dictionary=vrfs, key=f"{afi_vrf}_peers_{peer_ip}", separator="_") - issues = _check_peer_issues(peer_data) - if issues: - peer_issues[peer_ip] = issues + # Check if the BGP session is established + if peer_data["state"] != "Established": + self.result.is_failure(f"{peer} - Incorrect session state - Expected: Established Actual: {peer_data['state']}") + continue - if peer_issues: - _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue=peer_issues) + if self.inputs.minimum_established_time and (act_time := peer_data["establishedTime"]) < self.inputs.minimum_established_time: + self.result.is_failure( + f"{peer} - BGP session not established for the minimum required duration - Expected: {self.inputs.minimum_established_time}s Actual: {act_time}s" + ) - if failures: - self.result.is_failure(f"Failures: {list(failures.values())}") + # Check the TCP session message queues + if self.inputs.check_tcp_queues: + inq = peer_data["peerTcpInfo"]["inputQueueLength"] + outq = peer_data["peerTcpInfo"]["outputQueueLength"] + if inq != 0 or outq != 0: + self.result.is_failure(f"{peer} - Session has non-empty message queues - InQ: {inq} OutQ: {outq}") class VerifyBGPExchangedRoutes(AntaTest): - """Verifies if the BGP peers have correctly advertised and received routes. + """Verifies the advertised and received routes of BGP IPv4 peer(s). + + This test performs the following checks for each advertised and received route for each peer: - The route type should be 'valid' and 'active' for a specified VRF. + - Confirms that the route exists in the BGP route table. + - If `check_active` input flag is True, verifies that the route is 'valid' and 'active'. + - If `check_active` input flag is False, verifies that the route is 'valid'. Expected Results ---------------- - * Success: If the BGP peers have correctly advertised and received routes of type 'valid' and 'active' for a specified VRF. - * Failure: If a BGP peer is not found, the expected advertised/received routes are not found, or the routes are not 'valid' or 'active'. + * Success: If all of the following conditions are met: + - All specified advertised/received routes are found in the BGP route table. + - All routes are 'active' and 'valid' or 'valid' only per the `check_active` input flag. + * Failure: If any of the following occur: + - An advertised/received route is not found in the BGP route table. + - Any route is not 'active' and 'valid' or 'valid' only per `check_active` input flag. Examples -------- @@ -598,6 +493,7 @@ class VerifyBGPExchangedRoutes(AntaTest): anta.tests.routing: bgp: - VerifyBGPExchangedRoutes: + check_active: True bgp_peers: - peer_address: 172.30.255.5 vrf: default @@ -610,13 +506,9 @@ class VerifyBGPExchangedRoutes(AntaTest): advertised_routes: - 192.0.255.1/32 - 192.0.254.5/32 - received_routes: - - 192.0.254.3/32 ``` """ - name = "VerifyBGPExchangedRoutes" - description = "Verifies the advertised and received routes of BGP peers." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaTemplate(template="show bgp neighbors {peer} advertised-routes vrf {vrf}", revision=3), @@ -626,69 +518,100 @@ class VerifyBGPExchangedRoutes(AntaTest): class Input(AntaTest.Input): """Input model for the VerifyBGPExchangedRoutes test.""" - bgp_peers: list[BgpNeighbor] - """List of BGP neighbors.""" - - class BgpNeighbor(BaseModel): - """Model for a BGP neighbor.""" - - peer_address: IPv4Address - """IPv4 address of a BGP peer.""" - vrf: str = "default" - """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" - advertised_routes: list[IPv4Network] - """List of advertised routes in CIDR format.""" - received_routes: list[IPv4Network] - """List of received routes in CIDR format.""" + check_active: bool = True + """Flag to check if the provided prefixes must be active and valid. If False, checks if the prefixes are valid only. """ + bgp_peers: list[BgpPeer] + """List of BGP peers.""" + BgpNeighbor: ClassVar[type[BgpNeighbor]] = BgpNeighbor + + @field_validator("bgp_peers") + @classmethod + def validate_bgp_peers(cls, bgp_peers: list[BgpPeer]) -> list[BgpPeer]: + """Validate that 'advertised_routes' or 'received_routes' field is provided in each BGP peer.""" + for peer in bgp_peers: + if peer.advertised_routes is None and peer.received_routes is None: + msg = f"{peer} 'advertised_routes' or 'received_routes' field missing in the input" + raise ValueError(msg) + return bgp_peers def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Render the template for each BGP neighbor in the input list.""" + """Render the template for each BGP peer in the input list.""" return [template.render(peer=str(bgp_peer.peer_address), vrf=bgp_peer.vrf) for bgp_peer in self.inputs.bgp_peers] + def _validate_bgp_route_paths(self, peer: str, route_type: str, route: str, entries: dict[str, Any], *, active_flag: bool = True) -> str | None: + """Validate the BGP route paths.""" + # Check if the route is found + if route in entries: + # Check if the route is active and valid + route_paths = entries[route]["bgpRoutePaths"][0]["routeType"] + is_active = route_paths["active"] + is_valid = route_paths["valid"] + if active_flag: + if not is_active or not is_valid: + return f"{peer} {route_type} route: {route} - Valid: {is_valid} Active: {is_active}" + elif not is_valid: + return f"{peer} {route_type} route: {route} - Valid: {is_valid}" + return None + + return f"{peer} {route_type} route: {route} - Not found" + @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBGPExchangedRoutes.""" - failures: dict[str, dict[str, Any]] = {"bgp_peers": {}} - - # Iterating over command output for different peers - for command in self.instance_commands: - peer = command.params.peer - vrf = command.params.vrf - for input_entry in self.inputs.bgp_peers: - if str(input_entry.peer_address) == peer and input_entry.vrf == vrf: - advertised_routes = input_entry.advertised_routes - received_routes = input_entry.received_routes - break - failure = {vrf: ""} - - # Verify if a BGP peer is configured with the provided vrf - if not (bgp_routes := get_value(command.json_output, f"vrfs.{vrf}.bgpRouteEntries")): - failure[vrf] = "Not configured" - failures["bgp_peers"][peer] = failure - continue + self.result.is_success() - # Validate advertised routes - if "advertised-routes" in command.command: - failure_routes = _add_bgp_routes_failure(advertised_routes, bgp_routes, peer, vrf) + num_peers = len(self.inputs.bgp_peers) - # Validate received routes - else: - failure_routes = _add_bgp_routes_failure(received_routes, bgp_routes, peer, vrf, route_type="received_routes") - failures = deep_update(failures, failure_routes) + # Process each peer and its corresponding command pair + for peer_idx, peer in enumerate(self.inputs.bgp_peers): + # For n peers, advertised routes are at indices 0 to n-1, and received routes are at indices n to 2n-1 + advertised_routes_cmd = self.instance_commands[peer_idx] + received_routes_cmd = self.instance_commands[peer_idx + num_peers] + + # Get the BGP route entries of each command + command_output = { + "Advertised": get_value(advertised_routes_cmd.json_output, f"vrfs.{peer.vrf}.bgpRouteEntries", default={}), + "Received": get_value(received_routes_cmd.json_output, f"vrfs.{peer.vrf}.bgpRouteEntries", default={}), + } - if not failures["bgp_peers"]: - self.result.is_success() - else: - self.result.is_failure(f"Following BGP peers are not found or routes are not exchanged properly:\n{failures}") + # Validate both advertised and received routes + for route_type, routes in zip(["Advertised", "Received"], [peer.advertised_routes, peer.received_routes]): + # Skipping the validation for routes if user input is None + if not routes: + continue + + entries = command_output[route_type] + for route in routes: + # Check if the route is found. If yes then checks the route is active/valid + failure_msg = self._validate_bgp_route_paths(str(peer), route_type, str(route), entries, active_flag=self.inputs.check_active) + if failure_msg: + self.result.is_failure(failure_msg) class VerifyBGPPeerMPCaps(AntaTest): - """Verifies the multiprotocol capabilities of a BGP peer in a specified VRF. + """Verifies the multiprotocol capabilities of BGP peers. + + This test performs the following checks for each specified peer: + + 1. Verifies that the peer is found in its VRF in the BGP configuration. + 2. For each specified capability: + - Validates that the capability is present in the peer configuration. + - Confirms that the capability is advertised, received, and enabled. + 3. When strict mode is enabled (`strict: true`): + - Verifies that only the specified capabilities are configured. + - Ensures an exact match between configured and expected capabilities. Expected Results ---------------- - * Success: The test will pass if the BGP peer's multiprotocol capabilities are advertised, received, and enabled in the specified VRF. - * Failure: The test will fail if BGP peers are not found or multiprotocol capabilities are not advertised, received, and enabled in the specified VRF. + * Success: If all of the following conditions are met: + - All specified peers are found in the BGP configuration. + - All specified capabilities are present and properly negotiated. + - In strict mode, only the specified capabilities are configured. + * Failure: If any of the following occur: + - A specified peer is not found in the BGP configuration. + - A specified capability is not found. + - A capability is not properly negotiated (not advertised, received, or enabled). + - In strict mode, additional or missing capabilities are detected. Examples -------- @@ -699,13 +622,26 @@ class VerifyBGPPeerMPCaps(AntaTest): bgp_peers: - peer_address: 172.30.11.1 vrf: default + strict: False capabilities: - - ipv4Unicast + - ipv4 labeled-Unicast + - ipv4MplsVpn + - peer_address: fd00:dc:1::1 + vrf: default + strict: False + capabilities: + - ipv4 labeled-Unicast + - ipv4MplsVpn + # RFC5549 + - interface: Ethernet1 + vrf: default + strict: False + capabilities: + - ipv4 labeled-Unicast + - ipv4MplsVpn ``` """ - name = "VerifyBGPPeerMPCaps" - description = "Verifies the multiprotocol capabilities of a BGP peer." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] @@ -713,68 +649,72 @@ class Input(AntaTest.Input): """Input model for the VerifyBGPPeerMPCaps test.""" bgp_peers: list[BgpPeer] - """List of BGP peers""" - - class BgpPeer(BaseModel): - """Model for a BGP peer.""" - - peer_address: IPv4Address - """IPv4 address of a BGP peer.""" - vrf: str = "default" - """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" - capabilities: list[MultiProtocolCaps] - """List of multiprotocol capabilities to be verified.""" + """List of BGP peers.""" + BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer + + @field_validator("bgp_peers") + @classmethod + def validate_bgp_peers(cls, bgp_peers: list[T]) -> list[T]: + """Validate that 'capabilities' field is provided in each BGP peer.""" + for peer in bgp_peers: + if peer.capabilities is None: + msg = f"{peer} 'capabilities' field missing in the input" + raise ValueError(msg) + return bgp_peers @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBGPPeerMPCaps.""" - failures: dict[str, Any] = {"bgp_peers": {}} - - # Iterate over each bgp peer - for bgp_peer in self.inputs.bgp_peers: - peer = str(bgp_peer.peer_address) - vrf = bgp_peer.vrf - capabilities = bgp_peer.capabilities - failure: dict[str, dict[str, dict[str, Any]]] = {"bgp_peers": {peer: {vrf: {}}}} - - # Check if BGP output exists - if ( - not (bgp_output := get_value(self.instance_commands[0].json_output, f"vrfs.{vrf}.peerList")) - or (bgp_output := get_item(bgp_output, "peerAddress", peer)) is None - ): - failure["bgp_peers"][peer][vrf] = {"status": "Not configured"} - failures = deep_update(failures, failure) + self.result.is_success() + + output = self.instance_commands[0].json_output + + for peer in self.inputs.bgp_peers: + # Check if the peer is found + if (peer_data := _get_bgp_peer_data(peer, output)) is None: + self.result.is_failure(f"{peer} - Not found") continue - # Check each capability - bgp_output = get_value(bgp_output, "neighborCapabilities.multiprotocolCaps") - for capability in capabilities: - capability_output = bgp_output.get(capability) + # Check if the multiprotocol capabilities are found + if (act_mp_caps := get_value(peer_data, "neighborCapabilities.multiprotocolCaps")) is None: + self.result.is_failure(f"{peer} - Multiprotocol capabilities not found") + continue - # Check if capabilities are missing - if not capability_output: - failure["bgp_peers"][peer][vrf][capability] = "not found" - failures = deep_update(failures, failure) + # If strict is True, check if only the specified capabilities are configured + if peer.strict and sorted(peer.capabilities) != sorted(act_mp_caps): + self.result.is_failure(f"{peer} - Mismatch - Expected: {', '.join(peer.capabilities)} Actual: {', '.join(act_mp_caps)}") + continue - # Check if capabilities are not advertised, received, or enabled - elif not all(capability_output.get(prop, False) for prop in ["advertised", "received", "enabled"]): - failure["bgp_peers"][peer][vrf][capability] = capability_output - failures = deep_update(failures, failure) + # Check each capability + for capability in peer.capabilities: + # Check if the capability is found + if (capability_status := get_value(act_mp_caps, capability)) is None: + self.result.is_failure(f"{peer} - {capability} not found") - # Check if there are any failures - if not failures["bgp_peers"]: - self.result.is_success() - else: - self.result.is_failure(f"Following BGP peer multiprotocol capabilities are not found or not ok:\n{failures}") + # Check if the capability is advertised, received, and enabled + elif not _check_bgp_neighbor_capability(capability_status): + self.result.is_failure(f"{peer} - {capability} not negotiated - {format_data(capability_status)}") class VerifyBGPPeerASNCap(AntaTest): - """Verifies the four octet asn capabilities of a BGP peer in a specified VRF. + """Verifies the four octet ASN capability of BGP peers. + + This test performs the following checks for each specified peer: + + 1. Verifies that the peer is found in its VRF in the BGP configuration. + 2. Validates that the capability is present in the peer configuration. + 3. Confirms that the capability is advertised, received, and enabled. Expected Results ---------------- - * Success: The test will pass if BGP peer's four octet asn capabilities are advertised, received, and enabled in the specified VRF. - * Failure: The test will fail if BGP peers are not found or four octet asn capabilities are not advertised, received, and enabled in the specified VRF. + * Success: If all of the following conditions are met: + - All specified peers are found in the BGP configuration. + - The four octet ASN capability is present in each peer configuration. + - The capability is properly negotiated (advertised, received, and enabled) for all peers. + * Failure: If any of the following occur: + - A specified peer is not found in the BGP configuration. + - The four octet ASN capability is not present for a peer. + - The capability is not properly negotiated (not advertised, received, or enabled) for any peer. Examples -------- @@ -785,11 +725,14 @@ class VerifyBGPPeerASNCap(AntaTest): bgp_peers: - peer_address: 172.30.11.1 vrf: default + - peer_address: fd00:dc:1::1 + vrf: default + # RFC5549 + - interface: Ethernet1 + vrf: MGMT ``` """ - name = "VerifyBGPPeerASNCap" - description = "Verifies the four octet asn capabilities of a BGP peer." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] @@ -798,61 +741,50 @@ class Input(AntaTest.Input): bgp_peers: list[BgpPeer] """List of BGP peers.""" - - class BgpPeer(BaseModel): - """Model for a BGP peer.""" - - peer_address: IPv4Address - """IPv4 address of a BGP peer.""" - vrf: str = "default" - """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBGPPeerASNCap.""" - failures: dict[str, Any] = {"bgp_peers": {}} - - # Iterate over each bgp peer - for bgp_peer in self.inputs.bgp_peers: - peer = str(bgp_peer.peer_address) - vrf = bgp_peer.vrf - failure: dict[str, dict[str, dict[str, Any]]] = {"bgp_peers": {peer: {vrf: {}}}} - - # Check if BGP output exists - if ( - not (bgp_output := get_value(self.instance_commands[0].json_output, f"vrfs.{vrf}.peerList")) - or (bgp_output := get_item(bgp_output, "peerAddress", peer)) is None - ): - failure["bgp_peers"][peer][vrf] = {"status": "Not configured"} - failures = deep_update(failures, failure) - continue + self.result.is_success() - bgp_output = get_value(bgp_output, "neighborCapabilities.fourOctetAsnCap") + output = self.instance_commands[0].json_output - # Check if four octet asn capabilities are found - if not bgp_output: - failure["bgp_peers"][peer][vrf] = {"fourOctetAsnCap": "not found"} - failures = deep_update(failures, failure) + for peer in self.inputs.bgp_peers: + # Check if the peer is found + if (peer_data := _get_bgp_peer_data(peer, output)) is None: + self.result.is_failure(f"{peer} - Not found") + continue - # Check if capabilities are not advertised, received, or enabled - elif not all(bgp_output.get(prop, False) for prop in ["advertised", "received", "enabled"]): - failure["bgp_peers"][peer][vrf] = {"fourOctetAsnCap": bgp_output} - failures = deep_update(failures, failure) + # Check if the 4-octet ASN capability is found + if (capablity_status := get_value(peer_data, "neighborCapabilities.fourOctetAsnCap")) is None: + self.result.is_failure(f"{peer} - 4-octet ASN capability not found") + continue - # Check if there are any failures - if not failures["bgp_peers"]: - self.result.is_success() - else: - self.result.is_failure(f"Following BGP peer four octet asn capabilities are not found or not ok:\n{failures}") + # Check if the 4-octet ASN capability is advertised, received, and enabled + if not _check_bgp_neighbor_capability(capablity_status): + self.result.is_failure(f"{peer} - 4-octet ASN capability not negotiated - {format_data(capablity_status)}") class VerifyBGPPeerRouteRefreshCap(AntaTest): - """Verifies the route refresh capabilities of a BGP peer in a specified VRF. + """Verifies the route refresh capabilities of BGP peers. + + This test performs the following checks for each specified peer: + + 1. Verifies that the peer is found in its VRF in the BGP configuration. + 2. Validates that the route refresh capability is present in the peer configuration. + 3. Confirms that the capability is advertised, received, and enabled. Expected Results ---------------- - * Success: The test will pass if the BGP peer's route refresh capabilities are advertised, received, and enabled in the specified VRF. - * Failure: The test will fail if BGP peers are not found or route refresh capabilities are not advertised, received, and enabled in the specified VRF. + * Success: If all of the following conditions are met: + - All specified peers are found in the BGP configuration. + - The route refresh capability is present in each peer configuration. + - The capability is properly negotiated (advertised, received, and enabled) for all peers. + * Failure: If any of the following occur: + - A specified peer is not found in the BGP configuration. + - The route refresh capability is not present for a peer. + - The capability is not properly negotiated (not advertised, received, or enabled) for any peer. Examples -------- @@ -863,11 +795,14 @@ class VerifyBGPPeerRouteRefreshCap(AntaTest): bgp_peers: - peer_address: 172.30.11.1 vrf: default + - peer_address: fd00:dc:1::1 + vrf: default + # RFC5549 + - interface: Ethernet1 + vrf: MGMT ``` """ - name = "VerifyBGPPeerRouteRefreshCap" - description = "Verifies the route refresh capabilities of a BGP peer." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] @@ -875,62 +810,51 @@ class Input(AntaTest.Input): """Input model for the VerifyBGPPeerRouteRefreshCap test.""" bgp_peers: list[BgpPeer] - """List of BGP peers""" - - class BgpPeer(BaseModel): - """Model for a BGP peer.""" - - peer_address: IPv4Address - """IPv4 address of a BGP peer.""" - vrf: str = "default" - """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + """List of BGP peers.""" + BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBGPPeerRouteRefreshCap.""" - failures: dict[str, Any] = {"bgp_peers": {}} - - # Iterate over each bgp peer - for bgp_peer in self.inputs.bgp_peers: - peer = str(bgp_peer.peer_address) - vrf = bgp_peer.vrf - failure: dict[str, dict[str, dict[str, Any]]] = {"bgp_peers": {peer: {vrf: {}}}} - - # Check if BGP output exists - if ( - not (bgp_output := get_value(self.instance_commands[0].json_output, f"vrfs.{vrf}.peerList")) - or (bgp_output := get_item(bgp_output, "peerAddress", peer)) is None - ): - failure["bgp_peers"][peer][vrf] = {"status": "Not configured"} - failures = deep_update(failures, failure) - continue + self.result.is_success() - bgp_output = get_value(bgp_output, "neighborCapabilities.routeRefreshCap") + output = self.instance_commands[0].json_output - # Check if route refresh capabilities are found - if not bgp_output: - failure["bgp_peers"][peer][vrf] = {"routeRefreshCap": "not found"} - failures = deep_update(failures, failure) + for peer in self.inputs.bgp_peers: + # Check if the peer is found + if (peer_data := _get_bgp_peer_data(peer, output)) is None: + self.result.is_failure(f"{peer} - Not found") + continue - # Check if capabilities are not advertised, received, or enabled - elif not all(bgp_output.get(prop, False) for prop in ["advertised", "received", "enabled"]): - failure["bgp_peers"][peer][vrf] = {"routeRefreshCap": bgp_output} - failures = deep_update(failures, failure) + # Check if the route refresh capability is found + if (capablity_status := get_value(peer_data, "neighborCapabilities.routeRefreshCap")) is None: + self.result.is_failure(f"{peer} - Route refresh capability not found") + continue - # Check if there are any failures - if not failures["bgp_peers"]: - self.result.is_success() - else: - self.result.is_failure(f"Following BGP peer route refresh capabilities are not found or not ok:\n{failures}") + # Check if the route refresh capability is advertised, received, and enabled + if not _check_bgp_neighbor_capability(capablity_status): + self.result.is_failure(f"{peer} - Route refresh capability not negotiated - {format_data(capablity_status)}") class VerifyBGPPeerMD5Auth(AntaTest): - """Verifies the MD5 authentication and state of IPv4 BGP peers in a specified VRF. + """Verifies the MD5 authentication and state of BGP peers. + + This test performs the following checks for each specified peer: + + 1. Verifies that the peer is found in its VRF in the BGP configuration. + 2. Validates that the BGP session is in `Established` state. + 3. Confirms that MD5 authentication is enabled for the peer. Expected Results ---------------- - * Success: The test will pass if IPv4 BGP peers are configured with MD5 authentication and state as established in the specified VRF. - * Failure: The test will fail if IPv4 BGP peers are not found, state is not as established or MD5 authentication is not enabled in the specified VRF. + * Success: If all of the following conditions are met: + - All specified peers are found in the BGP configuration. + - All peers are in `Established` state. + - MD5 authentication is enabled for all peers. + * Failure: If any of the following occur: + - A specified peer is not found in the BGP configuration. + - A peer's session state is not `Established`. + - MD5 authentication is not enabled for a peer. Examples -------- @@ -943,11 +867,14 @@ class VerifyBGPPeerMD5Auth(AntaTest): vrf: default - peer_address: 172.30.11.5 vrf: default + - peer_address: fd00:dc:1::1 + vrf: default + # RFC5549 + - interface: Ethernet1 + vrf: default ``` """ - name = "VerifyBGPPeerMD5Auth" - description = "Verifies the MD5 authentication and state of a BGP peer." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] @@ -955,57 +882,47 @@ class Input(AntaTest.Input): """Input model for the VerifyBGPPeerMD5Auth test.""" bgp_peers: list[BgpPeer] - """List of IPv4 BGP peers.""" - - class BgpPeer(BaseModel): - """Model for a BGP peer.""" - - peer_address: IPv4Address - """IPv4 address of BGP peer.""" - vrf: str = "default" - """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + """List of BGP peers.""" + BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBGPPeerMD5Auth.""" - failures: dict[str, Any] = {"bgp_peers": {}} - - # Iterate over each command - for bgp_peer in self.inputs.bgp_peers: - peer = str(bgp_peer.peer_address) - vrf = bgp_peer.vrf - failure: dict[str, dict[str, dict[str, Any]]] = {"bgp_peers": {peer: {vrf: {}}}} - - # Check if BGP output exists - if ( - not (bgp_output := get_value(self.instance_commands[0].json_output, f"vrfs.{vrf}.peerList")) - or (bgp_output := get_item(bgp_output, "peerAddress", peer)) is None - ): - failure["bgp_peers"][peer][vrf] = {"status": "Not configured"} - failures = deep_update(failures, failure) - continue + self.result.is_success() - # Check if BGP peer state and authentication - state = bgp_output.get("state") - md5_auth_enabled = bgp_output.get("md5AuthEnabled") - if state != "Established" or not md5_auth_enabled: - failure["bgp_peers"][peer][vrf] = {"state": state, "md5_auth_enabled": md5_auth_enabled} - failures = deep_update(failures, failure) + output = self.instance_commands[0].json_output - # Check if there are any failures - if not failures["bgp_peers"]: - self.result.is_success() - else: - self.result.is_failure(f"Following BGP peers are not configured, not established or MD5 authentication is not enabled:\n{failures}") + for peer in self.inputs.bgp_peers: + # Check if the peer is found + if (peer_data := _get_bgp_peer_data(peer, output)) is None: + self.result.is_failure(f"{peer} - Not found") + continue + + # Check BGP peer state and MD5 authentication + state = peer_data.get("state") + md5_auth_enabled = peer_data.get("md5AuthEnabled") + if state != "Established": + self.result.is_failure(f"{peer} - Incorrect session state - Expected: Established Actual: {state}") + if not md5_auth_enabled: + self.result.is_failure(f"{peer} - Session does not have MD5 authentication enabled") class VerifyEVPNType2Route(AntaTest): """Verifies the EVPN Type-2 routes for a given IPv4 or MAC address and VNI. + This test performs the following checks for each specified VXLAN endpoint: + + 1. Verifies that the endpoint exists in the BGP EVPN table. + 2. Confirms that at least one EVPN Type-2 route with a valid and active path exists. + Expected Results ---------------- - * Success: If all provided VXLAN endpoints have at least one valid and active path to their EVPN Type-2 routes. - * Failure: If any of the provided VXLAN endpoints do not have at least one valid and active path to their EVPN Type-2 routes. + * Success: If all of the following conditions are met: + - All specified VXLAN endpoints are found in the BGP EVPN table. + - Each endpoint has at least one EVPN Type-2 route with a valid and active path. + * Failure: If any of the following occur: + - A VXLAN endpoint is not found in the BGP EVPN table. + - No EVPN Type-2 route with a valid and active path exists for an endpoint. Examples -------- @@ -1021,8 +938,6 @@ class VerifyEVPNType2Route(AntaTest): ``` """ - name = "VerifyEVPNType2Route" - description = "Verifies the EVPN Type-2 routes for a given IPv4 or MAC address and VNI." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show bgp evpn route-type mac-ip {address} vni {vni}", revision=2)] @@ -1031,14 +946,7 @@ class Input(AntaTest.Input): vxlan_endpoints: list[VxlanEndpoint] """List of VXLAN endpoints to verify.""" - - class VxlanEndpoint(BaseModel): - """Model for a VXLAN endpoint.""" - - address: IPv4Address | MacAddress - """IPv4 or MAC address of the VXLAN endpoint.""" - vni: Vni - """VNI of the VXLAN endpoint.""" + VxlanEndpoint: ClassVar[type[VxlanEndpoint]] = VxlanEndpoint def render(self, template: AntaTemplate) -> list[AntaCommand]: """Render the template for each VXLAN endpoint in the input list.""" @@ -1048,41 +956,42 @@ def render(self, template: AntaTemplate) -> list[AntaCommand]: def test(self) -> None: """Main test function for VerifyEVPNType2Route.""" self.result.is_success() - no_evpn_routes = [] - bad_evpn_routes = [] - for command in self.instance_commands: - address = command.params.address - vni = command.params.vni + for command, endpoint in zip(self.instance_commands, self.inputs.vxlan_endpoints): # Verify that the VXLAN endpoint is in the BGP EVPN table evpn_routes = command.json_output["evpnRoutes"] if not evpn_routes: - no_evpn_routes.append((address, vni)) + self.result.is_failure(f"{endpoint} - No EVPN Type-2 route") continue - # Verify that each EVPN route has at least one valid and active path - for route, route_data in evpn_routes.items(): - has_active_path = False - for path in route_data["evpnRoutePaths"]: - if path["routeType"]["valid"] is True and path["routeType"]["active"] is True: - # At least one path is valid and active, no need to check the other paths + + # Verify that at least one EVPN Type-2 route has at least one active and valid path across all learned routes from all RDs combined + has_active_path = False + for route_data in evpn_routes.values(): + for path in route_data.get("evpnRoutePaths", []): + route_type = path.get("routeType", {}) + if route_type.get("active") and route_type.get("valid"): has_active_path = True break - if not has_active_path: - bad_evpn_routes.append(route) - - if no_evpn_routes: - self.result.is_failure(f"The following VXLAN endpoint do not have any EVPN Type-2 route: {no_evpn_routes}") - if bad_evpn_routes: - self.result.is_failure(f"The following EVPN Type-2 routes do not have at least one valid and active path: {bad_evpn_routes}") + if not has_active_path: + self.result.is_failure(f"{endpoint} - No valid and active path") class VerifyBGPAdvCommunities(AntaTest): - """Verifies if the advertised communities of BGP peers are standard, extended, and large in the specified VRF. + """Verifies the advertised communities of BGP peers. + + This test performs the following checks for each specified peer: + + 1. Verifies that the peer is found in its VRF in the BGP configuration. + 2. Validates that given community types are advertised. If not provided, validates that all communities (standard, extended, large) are advertised. Expected Results ---------------- - * Success: The test will pass if the advertised communities of BGP peers are standard, extended, and large in the specified VRF. - * Failure: The test will fail if the advertised communities of BGP peers are not standard, extended, and large in the specified VRF. + * Success: If all of the following conditions are met: + - All specified peers are found in the BGP configuration. + - Each peer advertises the given community types. + * Failure: If any of the following occur: + - A specified peer is not found in the BGP configuration. + - A peer does not advertise any of the given community types. Examples -------- @@ -1094,12 +1003,17 @@ class VerifyBGPAdvCommunities(AntaTest): - peer_address: 172.30.11.17 vrf: default - peer_address: 172.30.11.21 + vrf: MGMT + advertised_communities: ["standard", "extended"] + - peer_address: fd00:dc:1::1 vrf: default + # RFC5549 + - interface: Ethernet1 + vrf: default + advertised_communities: ["standard", "extended"] ``` """ - name = "VerifyBGPAdvCommunities" - description = "Verifies the advertised communities of a BGP peer." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] @@ -1108,54 +1022,42 @@ class Input(AntaTest.Input): bgp_peers: list[BgpPeer] """List of BGP peers.""" - - class BgpPeer(BaseModel): - """Model for a BGP peer.""" - - peer_address: IPv4Address - """IPv4 address of a BGP peer.""" - vrf: str = "default" - """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBGPAdvCommunities.""" - failures: dict[str, Any] = {"bgp_peers": {}} - - # Iterate over each bgp peer - for bgp_peer in self.inputs.bgp_peers: - peer = str(bgp_peer.peer_address) - vrf = bgp_peer.vrf - failure: dict[str, dict[str, dict[str, Any]]] = {"bgp_peers": {peer: {vrf: {}}}} - - # Verify BGP peer - if ( - not (bgp_output := get_value(self.instance_commands[0].json_output, f"vrfs.{vrf}.peerList")) - or (bgp_output := get_item(bgp_output, "peerAddress", peer)) is None - ): - failure["bgp_peers"][peer][vrf] = {"status": "Not configured"} - failures = deep_update(failures, failure) - continue + self.result.is_success() + + output = self.instance_commands[0].json_output - # Verify BGP peer's advertised communities - bgp_output = bgp_output.get("advertisedCommunities") - if not bgp_output["standard"] or not bgp_output["extended"] or not bgp_output["large"]: - failure["bgp_peers"][peer][vrf] = {"advertised_communities": bgp_output} - failures = deep_update(failures, failure) + for peer in self.inputs.bgp_peers: + # Check if the peer is found + if (peer_data := _get_bgp_peer_data(peer, output)) is None: + self.result.is_failure(f"{peer} - Not found") + continue - if not failures["bgp_peers"]: - self.result.is_success() - else: - self.result.is_failure(f"Following BGP peers are not configured or advertised communities are not standard, extended, and large:\n{failures}") + # Check BGP peer advertised communities + if not all(get_value(peer_data, f"advertisedCommunities.{community}") is True for community in peer.advertised_communities): + self.result.is_failure(f"{peer} - {format_data(peer_data['advertisedCommunities'])}") class VerifyBGPTimers(AntaTest): - """Verifies if the BGP peers are configured with the correct hold and keep-alive timers in the specified VRF. + """Verifies the timers of BGP peers. + + This test performs the following checks for each specified peer: + + 1. Verifies that the peer is found in its VRF in the BGP configuration. + 2. Confirms the BGP session hold time/keepalive timers match the expected value. Expected Results ---------------- - * Success: The test will pass if the hold and keep-alive timers are correct for BGP peers in the specified VRF. - * Failure: The test will fail if BGP peers are not found or hold and keep-alive timers are not correct in the specified VRF. + * Success: If all of the following conditions are met: + - All specified peers are found in the BGP configuration. + - The hold time/keepalive timers match the expected value for each peer. + * Failure: If any of the following occur: + - A specified peer is not found in the BGP configuration. + - The hold time/keepalive timers do not match the expected value for a peer. Examples -------- @@ -1172,11 +1074,18 @@ class VerifyBGPTimers(AntaTest): vrf: default hold_time: 180 keep_alive_time: 60 + - peer_address: fd00:dc:1::1 + vrf: default + hold_time: 180 + keep_alive_time: 60 + # RFC5549 + - interface: Ethernet1 + vrf: MGMT + hold_time: 180 + keep_alive_time: 60 ``` """ - name = "VerifyBGPTimers" - description = "Verifies the timers of a BGP peer." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] @@ -1184,45 +1093,1067 @@ class Input(AntaTest.Input): """Input model for the VerifyBGPTimers test.""" bgp_peers: list[BgpPeer] - """List of BGP peers""" + """List of BGP peers.""" + BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer + + @field_validator("bgp_peers") + @classmethod + def validate_bgp_peers(cls, bgp_peers: list[T]) -> list[T]: + """Validate that 'hold_time' or 'keep_alive_time' field is provided in each BGP peer.""" + for peer in bgp_peers: + if peer.hold_time is None or peer.keep_alive_time is None: + msg = f"{peer} 'hold_time' or 'keep_alive_time' field missing in the input" + raise ValueError(msg) + return bgp_peers + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPTimers.""" + self.result.is_success() + + output = self.instance_commands[0].json_output + + for peer in self.inputs.bgp_peers: + # Check if the peer is found + if (peer_data := _get_bgp_peer_data(peer, output)) is None: + self.result.is_failure(f"{peer} - Not found") + continue + + # Check BGP peer timers + if peer_data["holdTime"] != peer.hold_time: + self.result.is_failure(f"{peer} - Hold time mismatch - Expected: {peer.hold_time} Actual: {peer_data['holdTime']}") + if peer_data["keepaliveTime"] != peer.keep_alive_time: + self.result.is_failure(f"{peer} - Keepalive time mismatch - Expected: {peer.keep_alive_time} Actual: {peer_data['keepaliveTime']}") + + +class VerifyBGPPeerDropStats(AntaTest): + """Verifies BGP NLRI drop statistics of BGP peers. + + This test performs the following checks for each specified peer: + + 1. Verifies that the peer is found in its VRF in the BGP configuration. + 2. Validates the BGP drop statistics: + - If specific drop statistics are provided, checks only those counters. + - If no specific drop statistics are provided, checks all available counters. + - Confirms that all checked counters have a value of zero. + + Expected Results + ---------------- + * Success: If all of the following conditions are met: + - All specified peers are found in the BGP configuration. + - All specified drop statistics counters (or all counters if none specified) are zero. + * Failure: If any of the following occur: + - A specified peer is not found in the BGP configuration. + - Any checked drop statistics counter has a non-zero value. + - A specified drop statistics counter does not exist. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPPeerDropStats: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + drop_stats: + - inDropAsloop + - prefixEvpnDroppedUnsupportedRouteType + - peer_address: fd00:dc:1::1 + vrf: default + drop_stats: + - inDropAsloop + - prefixEvpnDroppedUnsupportedRouteType + # RFC5549 + - interface: Ethernet1 + vrf: MGMT + drop_stats: + - inDropAsloop + - prefixEvpnDroppedUnsupportedRouteType + ``` + """ + + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] - class BgpPeer(BaseModel): - """Model for a BGP peer.""" + class Input(AntaTest.Input): + """Input model for the VerifyBGPPeerDropStats test.""" - peer_address: IPv4Address - """IPv4 address of a BGP peer.""" - vrf: str = "default" - """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" - hold_time: int = Field(ge=3, le=7200) - """BGP hold time in seconds.""" - keep_alive_time: int = Field(ge=0, le=3600) - """BGP keep-alive time in seconds.""" + bgp_peers: list[BgpPeer] + """List of BGP peers.""" + BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer @AntaTest.anta_test def test(self) -> None: - """Main test function for VerifyBGPTimers.""" - failures: dict[str, Any] = {} - - # Iterate over each bgp peer - for bgp_peer in self.inputs.bgp_peers: - peer_address = str(bgp_peer.peer_address) - vrf = bgp_peer.vrf - hold_time = bgp_peer.hold_time - keep_alive_time = bgp_peer.keep_alive_time - - # Verify BGP peer - if ( - not (bgp_output := get_value(self.instance_commands[0].json_output, f"vrfs.{vrf}.peerList")) - or (bgp_output := get_item(bgp_output, "peerAddress", peer_address)) is None - ): - failures[peer_address] = {vrf: "Not configured"} + """Main test function for VerifyBGPPeerDropStats.""" + self.result.is_success() + + output = self.instance_commands[0].json_output + + for peer in self.inputs.bgp_peers: + drop_stats_input = peer.drop_stats + # Check if the peer is found + if (peer_data := _get_bgp_peer_data(peer, output)) is None: + self.result.is_failure(f"{peer} - Not found") + continue + + # Verify BGP peers' drop stats + drop_stats_output = peer_data["dropStats"] + + # In case drop stats not provided, It will check all drop statistics + if not drop_stats_input: + drop_stats_input = drop_stats_output + + # Verify BGP peer's drop stats + for drop_stat in drop_stats_input: + if (stat_value := drop_stats_output.get(drop_stat, 0)) != 0: + self.result.is_failure(f"{peer} - Non-zero NLRI drop statistics counter - {drop_stat}: {stat_value}") + + +class VerifyBGPPeerUpdateErrors(AntaTest): + """Verifies BGP update error counters of BGP peers. + + This test performs the following checks for each specified peer: + + 1. Verifies that the peer is found in its VRF in the BGP configuration. + 2. Validates the BGP update error counters: + - If specific update error counters are provided, checks only those counters. + - If no update error counters are provided, checks all available counters. + - Confirms that all checked counters have a value of zero. + + Note: For "disabledAfiSafi" error counter field, checking that it's not "None" versus 0. + + Expected Results + ---------------- + * Success: If all of the following conditions are met: + - All specified peers are found in the BGP configuration. + - All specified update error counters (or all counters if none specified) are zero. + * Failure: If any of the following occur: + - A specified peer is not found in the BGP configuration. + - Any checked update error counters has a non-zero value. + - A specified update error counters does not exist. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPPeerUpdateErrors: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + update_errors: + - inUpdErrWithdraw + - peer_address: fd00:dc:1::1 + vrf: default + update_errors: + - inUpdErrWithdraw + # RFC5549 + - interface: Ethernet1 + vrf: MGMT + update_errors: + - inUpdErrWithdraw + ``` + """ + + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPPeerUpdateErrors test.""" + + bgp_peers: list[BgpPeer] + """List of BGP peers.""" + BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPPeerUpdateErrors.""" + self.result.is_success() + + output = self.instance_commands[0].json_output + + for peer in self.inputs.bgp_peers: + update_errors_input = peer.update_errors + # Check if the peer is found + if (peer_data := _get_bgp_peer_data(peer, output)) is None: + self.result.is_failure(f"{peer} - Not found") + continue + + # Getting the BGP peer's error counters output. + error_counters_output = peer_data.get("peerInUpdateErrors", {}) + + # In case update error counters not provided, It will check all the update error counters. + if not update_errors_input: + update_errors_input = error_counters_output + + # Verify BGP peer's update error counters + for error_counter in update_errors_input: + if (stat_value := error_counters_output.get(error_counter, "Not Found")) != 0 and stat_value != "None": + self.result.is_failure(f"{peer} - Non-zero update error counter - {error_counter}: {stat_value}") + + +class VerifyBgpRouteMaps(AntaTest): + """Verifies BGP inbound and outbound route-maps of BGP peers. + + This test performs the following checks for each specified peer: + + 1. Verifies that the peer is found in its VRF in the BGP configuration. + 2. Validates the correct BGP route maps are applied in the correct direction (inbound or outbound). + + Expected Results + ---------------- + * Success: If all of the following conditions are met: + - All specified peers are found in the BGP configuration. + - All specified peers has correct BGP route maps are applied in the correct direction (inbound or outbound). + * Failure: If any of the following occur: + - A specified peer is not found in the BGP configuration. + - A incorrect or missing route map in either the inbound or outbound direction. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBgpRouteMaps: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + inbound_route_map: RM-MLAG-PEER-IN + outbound_route_map: RM-MLAG-PEER-OUT + - peer_address: fd00:dc:1::1 + vrf: default + inbound_route_map: RM-MLAG-PEER-IN + outbound_route_map: RM-MLAG-PEER-OUT + # RFC5549 + - interface: Ethernet1 + vrf: MGMT + inbound_route_map: RM-MLAG-PEER-IN + outbound_route_map: RM-MLAG-PEER-OUT + ``` + """ + + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] + + class Input(AntaTest.Input): + """Input model for the VerifyBgpRouteMaps test.""" + + bgp_peers: list[BgpPeer] + """List of BGP peers.""" + BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer + + @field_validator("bgp_peers") + @classmethod + def validate_bgp_peers(cls, bgp_peers: list[T]) -> list[T]: + """Validate that 'inbound_route_map' or 'outbound_route_map' field is provided in each BGP peer.""" + for peer in bgp_peers: + if not (peer.inbound_route_map or peer.outbound_route_map): + msg = f"{peer} 'inbound_route_map' or 'outbound_route_map' field missing in the input" + raise ValueError(msg) + return bgp_peers + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBgpRouteMaps.""" + self.result.is_success() + + output = self.instance_commands[0].json_output + + for peer in self.inputs.bgp_peers: + inbound_route_map = peer.inbound_route_map + outbound_route_map = peer.outbound_route_map + + # Check if the peer is found + if (peer_data := _get_bgp_peer_data(peer, output)) is None: + self.result.is_failure(f"{peer} - Not found") + continue + + # Verify Inbound route-map + if inbound_route_map and (inbound_map := peer_data.get("routeMapInbound", "Not Configured")) != inbound_route_map: + self.result.is_failure(f"{peer} - Inbound route-map mismatch - Expected: {inbound_route_map} Actual: {inbound_map}") + + # Verify Outbound route-map + if outbound_route_map and (outbound_map := peer_data.get("routeMapOutbound", "Not Configured")) != outbound_route_map: + self.result.is_failure(f"{peer} - Outbound route-map mismatch - Expected: {outbound_route_map} Actual: {outbound_map}") + + +class VerifyBGPPeerRouteLimit(AntaTest): + """Verifies maximum routes and warning limit for BGP peers. + + This test performs the following checks for each specified peer: + + 1. Verifies that the peer is found in its VRF in the BGP configuration. + 2. Confirms the maximum routes and maximum routes warning limit, if provided, match the expected value. + + Expected Results + ---------------- + * Success: If all of the following conditions are met: + - All specified peers are found in the BGP configuration. + - The maximum routes/maximum routes warning limit match the expected value for a peer. + * Failure: If any of the following occur: + - A specified peer is not found in the BGP configuration. + - The maximum routes/maximum routes warning limit do not match the expected value for a peer. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPPeerRouteLimit: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + maximum_routes: 12000 + warning_limit: 10000 + - peer_address: fd00:dc:1::1 + vrf: default + maximum_routes: 12000 + warning_limit: 10000 + # RFC5549 + - interface: Ethernet1 + vrf: MGMT + maximum_routes: 12000 + warning_limit: 10000 + ``` + """ + + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPPeerRouteLimit test.""" + + bgp_peers: list[BgpPeer] + """List of BGP peers.""" + BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer + + @field_validator("bgp_peers") + @classmethod + def validate_bgp_peers(cls, bgp_peers: list[T]) -> list[T]: + """Validate that 'maximum_routes' field is provided in each BGP peer.""" + for peer in bgp_peers: + if peer.maximum_routes is None: + msg = f"{peer} 'maximum_routes' field missing in the input" + raise ValueError(msg) + return bgp_peers + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPPeerRouteLimit.""" + self.result.is_success() + + output = self.instance_commands[0].json_output + + for peer in self.inputs.bgp_peers: + maximum_routes = peer.maximum_routes + warning_limit = peer.warning_limit + + # Check if the peer is found + if (peer_data := _get_bgp_peer_data(peer, output)) is None: + self.result.is_failure(f"{peer} - Not found") + continue + + # Verify maximum routes + if (actual_maximum_routes := peer_data.get("maxTotalRoutes", "Not Found")) != maximum_routes: + self.result.is_failure(f"{peer} - Maximum routes mismatch - Expected: {maximum_routes} Actual: {actual_maximum_routes}") + + # Verify warning limit if provided. By default, EOS does not have a warning limit and `totalRoutesWarnLimit` is not present in the output. + if warning_limit is not None and (actual_warning_limit := peer_data.get("totalRoutesWarnLimit", 0)) != warning_limit: + self.result.is_failure(f"{peer} - Maximum routes warning limit mismatch - Expected: {warning_limit} Actual: {actual_warning_limit}") + + +class VerifyBGPPeerGroup(AntaTest): + """Verifies BGP peer group of BGP peers. + + This test performs the following checks for each specified peer: + + 1. Verifies that the peer is found in its VRF in the BGP configuration. + 2. Confirms the peer group is correctly assigned to the specified BGP peer. + + Expected Results + ---------------- + * Success: If all of the following conditions are met: + - All specified peers are found in the BGP configuration. + - The peer group is correctly assigned to the specified BGP peer. + * Failure: If any of the following occur: + - A specified peer is not found in the BGP configuration. + - The peer group is not correctly assigned to the specified BGP peer. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPPeerGroup: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + peer_group: IPv4-UNDERLAY-PEERS + - peer_address: fd00:dc:1::1 + vrf: default + peer_group: IPv4-UNDERLAY-PEERS + # RFC5549 + - interface: Ethernet1 + vrf: MGMT + peer_group: IPv4-UNDERLAY-PEERS + ``` + """ + + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPPeerGroup test.""" + + bgp_peers: list[BgpPeer] + """List of BGP peers.""" + + @field_validator("bgp_peers") + @classmethod + def validate_bgp_peers(cls, bgp_peers: list[BgpPeer]) -> list[BgpPeer]: + """Validate that 'peer_group' field is provided in each BGP peer.""" + for peer in bgp_peers: + if peer.peer_group is None: + msg = f"{peer} 'peer_group' field missing in the input" + raise ValueError(msg) + return bgp_peers + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPPeerGroup.""" + self.result.is_success() + + output = self.instance_commands[0].json_output + + for peer in self.inputs.bgp_peers: + # Check if the peer is found + if (peer_data := _get_bgp_peer_data(peer, output)) is None: + self.result.is_failure(f"{peer} - Not found") + continue + + if (actual_peer_group := peer_data.get("peerGroupName", "Not Found")) != peer.peer_group: + self.result.is_failure(f"{peer} - Incorrect peer group configured - Expected: {peer.peer_group} Actual: {actual_peer_group}") + + +class VerifyBGPPeerSessionRibd(AntaTest): + """Verifies the session state of BGP peers. + + Compatible with EOS operating in `ribd` routing protocol model. + + This test performs the following checks for each specified peer: + + 1. Verifies that the peer is found in its VRF in the BGP configuration. + 2. Verifies that the BGP session is `Established` and, if specified, has remained established for at least the duration given by `minimum_established_time`. + 3. Ensures that both input and output TCP message queues are empty. + Can be disabled by setting `check_tcp_queues` input flag to `False`. + + Expected Results + ---------------- + * Success: If all of the following conditions are met: + - All specified peers are found in the BGP configuration. + - All peers sessions state are `Established` and, if specified, has remained established for at least the duration given by `minimum_established_time`. + - All peers have empty TCP message queues if `check_tcp_queues` is `True` (default). + - All peers are established for specified minimum duration. + * Failure: If any of the following occur: + - A specified peer is not found in the BGP configuration. + - A peer's session state is not `Established` or if specified, has not remained established for at least the duration specified by + the `minimum_established_time`. + - A peer has non-empty TCP message queues (input or output) when `check_tcp_queues` is `True`. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPPeerSessionRibd: + minimum_established_time: 10000 + check_tcp_queues: false + bgp_peers: + - peer_address: 10.1.0.1 + vrf: default + - peer_address: 10.1.255.4 + vrf: DEV + - peer_address: fd00:dc:1::1 + vrf: default + # RFC5549 + - interface: Ethernet1 + vrf: MGMT + ``` + """ + + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip bgp neighbors vrf all", revision=2)] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPPeerSessionRibd test.""" + + minimum_established_time: PositiveInt | None = None + """Minimum established time (seconds) for all the BGP sessions.""" + check_tcp_queues: bool = True + """Flag to check if the TCP session queues are empty for all BGP peers. Defaults to `True`.""" + bgp_peers: list[BgpPeer] + """List of BGP peers.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPPeerSessionRibd.""" + self.result.is_success() + + output = self.instance_commands[0].json_output + + for peer in self.inputs.bgp_peers: + # Check if the peer is found + if (peer_data := _get_bgp_peer_data(peer, output)) is None: + self.result.is_failure(f"{peer} - Not found") + continue + + # Check if the BGP session is established + if peer_data["state"] != "Established": + self.result.is_failure(f"{peer} - Incorrect session state - Expected: Established Actual: {peer_data['state']}") + continue + + if self.inputs.minimum_established_time and (act_time := peer_data["establishedTime"]) < self.inputs.minimum_established_time: + self.result.is_failure( + f"{peer} - BGP session not established for the minimum required duration - Expected: {self.inputs.minimum_established_time}s Actual: {act_time}s" + ) + + # Check the TCP session message queues + if self.inputs.check_tcp_queues: + inq_stat = peer_data["peerTcpInfo"]["inputQueueLength"] + outq_stat = peer_data["peerTcpInfo"]["outputQueueLength"] + if inq_stat != 0 or outq_stat != 0: + self.result.is_failure(f"{peer} - Session has non-empty message queues - InQ: {inq_stat} OutQ: {outq_stat}") + + +class VerifyBGPPeersHealthRibd(AntaTest): + """Verifies the health of all the BGP peers. + + Compatible with EOS operating in `ribd` routing protocol model. + + This test performs the following checks for all BGP IPv4 peers: + + 1. Verifies that the BGP session is in the `Established` state. + 2. Checks that both input and output TCP message queues are empty. + Can be disabled by setting `check_tcp_queues` input flag to `False`. + + Expected Results + ---------------- + * Success: If all checks pass for all BGP IPv4 peers. + * Failure: If any of the following occur: + - Any BGP session is not in the `Established` state. + - Any TCP message queue (input or output) is not empty when `check_tcp_queues` is `True` (default). + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPPeersHealthRibd: + check_tcp_queues: True + ``` + """ + + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip bgp neighbors vrf all", revision=2)] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPPeersHealthRibd test.""" + + check_tcp_queues: bool = True + """Flag to check if the TCP session queues are empty for all BGP peers. Defaults to `True`.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPPeersHealthRibd.""" + self.result.is_success() + + output = self.instance_commands[0].json_output + + for vrf, vrf_data in output["vrfs"].items(): + peer_list = vrf_data.get("peerList", []) + + for peer in peer_list: + # Check if the BGP session is established + if peer["state"] != "Established": + self.result.is_failure(f"Peer: {peer['peerAddress']} VRF: {vrf} - Incorrect session state - Expected: Established Actual: {peer['state']}") + continue + + # Check the TCP session message queues + inq = peer["peerTcpInfo"]["inputQueueLength"] + outq = peer["peerTcpInfo"]["outputQueueLength"] + if self.inputs.check_tcp_queues and (inq != 0 or outq != 0): + self.result.is_failure(f"Peer: {peer['peerAddress']} VRF: {vrf} - Session has non-empty message queues - InQ: {inq} OutQ: {outq}") + + +class VerifyBGPNlriAcceptance(AntaTest): + """Verifies that all received NLRI are accepted for all AFI/SAFI configured for BGP peers. + + This test performs the following checks for each specified peer: + + 1. Verifies that the peer is found in its VRF in the BGP configuration. + 2. Verifies that all received NLRI were accepted by comparing `nlrisReceived` with `nlrisAccepted`. + + Expected Results + ---------------- + * Success: If `nlrisReceived` equals `nlrisAccepted`, indicating all NLRI were accepted. + * Failure: If any of the following occur: + - The specified VRF is not configured. + - `nlrisReceived` does not equal `nlrisAccepted`, indicating some NLRI were rejected or filtered. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPNlriAcceptance: + bgp_peers: + - peer_address: 10.100.0.128 + vrf: default + capabilities: + - ipv4Unicast + - peer_address: 2001:db8:1::2 + vrf: default + capabilities: + - ipv6Unicast + - peer_address: fe80::2%Et1 + vrf: default + capabilities: + - ipv6Unicast + # RFC 5549 + - peer_address: fe80::2%Et1 + vrf: default + capabilities: + - ipv6Unicast + ``` + """ + + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ + AntaCommand(command="show bgp summary vrf all", revision=1), + AntaCommand(command="show bgp neighbors vrf all", revision=3), + ] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPNlriAcceptance test.""" + + bgp_peers: list[BgpPeer] + """List of BGP IPv4 peers.""" + + @field_validator("bgp_peers") + @classmethod + def validate_bgp_peers(cls, bgp_peers: list[T]) -> list[T]: + """Validate that 'capabilities' field is provided in each BGP peer.""" + for peer in bgp_peers: + if peer.capabilities is None: + msg = f"{peer} 'capabilities' field missing in the input" + raise ValueError(msg) + return bgp_peers + + @staticmethod + def _get_peer_address(peer: BgpPeer, command_output: dict[str, Any]) -> str | None: + """Retrieve the peer address for the given BGP peer data. + + If an interface is specified, the address is extracted from the command output; + otherwise, it is retrieved directly from the peer object. + + Parameters + ---------- + peer + The BGP peer object to look up. + command_output + Parsed output from the relevant command. + + Returns + ------- + str | None + The peer address if found, otherwise None. + """ + if peer.interface is not None: + # RFC5549 + interface = str(peer.interface) + lookup_key = "ifName" + + peer_list = get_value(command_output, f"vrfs.{peer.vrf}.peerList", default=[]) + # Check if the peer is found + if (peer_details := get_item(peer_list, lookup_key, interface)) is not None: + return str(peer_details.get("peerAddress")) + return None + + return str(peer.peer_address) + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPNlriAcceptance.""" + self.result.is_success() + + output = self.instance_commands[0].json_output + peer_output = self.instance_commands[1].json_output + + for peer in self.inputs.bgp_peers: + identity = self._get_peer_address(peer, peer_output) + # Check if the peer is found + if not (peer_data := get_value(output, f"vrfs..{peer.vrf}..peers..{identity}", separator="..")): + self.result.is_failure(f"{peer} - Not found") + continue + + # Fetching the multiprotocol capabilities + for capability in peer.capabilities: + # Check if the capability is found + if (capability_status := get_value(peer_data, capability)) is None: + self.result.is_failure(f"{peer} - {capability} not found") + continue + + if capability_status["afiSafiState"] != "negotiated": + self.result.is_failure(f"{peer} - {capability} not negotiated") + + if (received := capability_status.get("nlrisReceived")) != (accepted := capability_status.get("nlrisAccepted")): + self.result.is_failure(f"{peer} AFI/SAFI: {capability} - Some NLRI were filtered or rejected - Accepted: {accepted} Received: {received}") + + +class VerifyBGPRoutePaths(AntaTest): + """Verifies BGP IPv4 route paths. + + This test performs the following checks for each specified BGP route entry: + + 1. Verifies the specified BGP route exists in the routing table. + 2. For each expected paths: + - Verifies a path with matching next-hop exists. + - Verifies the path's origin attribute matches the expected value. + + Expected Results + ---------------- + * Success: The test will pass if all specified routes exist with paths matching the expected next-hops and origin attributes. + * Failure: The test will fail if: + - A specified BGP route is not found. + - A path with specified next-hop is not found. + - A path's origin attribute doesn't match the expected value. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPRoutePaths: + route_entries: + - prefix: 10.100.0.128/31 + vrf: default + paths: + - nexthop: 10.100.0.10 + origin: Igp + - nexthop: 10.100.4.5 + origin: Incomplete + ``` + """ + + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip bgp vrf all", revision=3)] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPRoutePaths test.""" + + route_entries: list[BgpRoute] + """List of BGP IPv4 route(s).""" + + @field_validator("route_entries") + @classmethod + def validate_route_entries(cls, route_entries: list[BgpRoute]) -> list[BgpRoute]: + """Validate that 'paths' field is provided in each BGP route.""" + for route in route_entries: + if route.paths is None: + msg = f"{route} 'paths' field missing in the input" + raise ValueError(msg) + return route_entries + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPRoutePaths.""" + self.result.is_success() + + for route in self.inputs.route_entries: + # Verify if the prefix exists in BGP table + if not (bgp_routes := get_value(self.instance_commands[0].json_output, f"vrfs..{route.vrf}..bgpRouteEntries..{route.prefix}", separator="..")): + self.result.is_failure(f"{route} - Prefix not found") + continue + + # Iterating over each path. + for path in route.paths: + nexthop = str(path.nexthop) + origin = path.origin + if not (route_path := get_item(bgp_routes["bgpRoutePaths"], "nextHop", nexthop)): + self.result.is_failure(f"{route} {path} - Path not found") + continue + + if (actual_origin := get_value(route_path, "routeType.origin")) != origin: + self.result.is_failure(f"{route} {path} - Origin mismatch - Actual: {actual_origin}") + + +class VerifyBGPRouteECMP(AntaTest): + """Verifies BGP IPv4 route ECMP paths. + + This test performs the following checks for each specified BGP route entry: + + 1. Route exists in BGP table. + 2. First path is a valid and active ECMP head. + 3. Correct number of valid ECMP contributors follow the head path. + 4. Route is installed in RIB with same amount of next-hops. + + Expected Results + ---------------- + * Success: The test will pass if all specified routes exist in both BGP and RIB tables with correct amount of ECMP paths. + * Failure: The test will fail if: + - A specified route is not found in BGP table. + - A valid and active ECMP head is not found. + - ECMP contributors count does not match the expected value. + - Route is not installed in RIB table. + - BGP and RIB nexthops count do not match. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPRouteECMP: + route_entries: + - prefix: 10.100.0.128/31 + vrf: default + ecmp_count: 2 + ``` + """ + + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ + AntaCommand(command="show ip bgp vrf all", revision=3), + AntaCommand(command="show ip route vrf all bgp", revision=4), + ] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPRouteECMP test.""" + + route_entries: list[BgpRoute] + """List of BGP IPv4 route(s).""" + + @field_validator("route_entries") + @classmethod + def validate_route_entries(cls, route_entries: list[BgpRoute]) -> list[BgpRoute]: + """Validate that 'ecmp_count' field is provided in each BGP route.""" + for route in route_entries: + if route.ecmp_count is None: + msg = f"{route} 'ecmp_count' field missing in the input" + raise ValueError(msg) + return route_entries + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPRouteECMP.""" + self.result.is_success() + + for route in self.inputs.route_entries: + # Verify if the prefix exists in BGP table. + if not (bgp_route_entry := get_value(self.instance_commands[0].json_output, f"vrfs..{route.vrf}..bgpRouteEntries..{route.prefix}", separator="..")): + self.result.is_failure(f"{route} - Prefix not found in BGP table") + continue + + route_paths = iter(bgp_route_entry["bgpRoutePaths"]) + head = next(route_paths, None) + # Verify if the active ECMP head exists. + if head is None or not all(head["routeType"][key] for key in ["valid", "active", "ecmpHead"]): + self.result.is_failure(f"{route} - Valid and active ECMP head not found") + continue + + bgp_nexthops = {head["nextHop"]} + bgp_nexthops.update([path["nextHop"] for path in route_paths if all(path["routeType"][key] for key in ["valid", "ecmp", "ecmpContributor"])]) + + # Verify ECMP count is correct. + if len(bgp_nexthops) != route.ecmp_count: + self.result.is_failure(f"{route} - ECMP count mismatch - Expected: {route.ecmp_count} Actual: {len(bgp_nexthops)}") + continue + + # Verify if the prefix exists in routing table. + if not (route_entry := get_value(self.instance_commands[1].json_output, f"vrfs..{route.vrf}..routes..{route.prefix}", separator="..")): + self.result.is_failure(f"{route} - Prefix not found in routing table") + continue + + # Verify BGP and RIB nexthops are same. + if len(bgp_nexthops) != len(route_entry["vias"]): + self.result.is_failure(f"{route} - Nexthops count mismatch - BGP: {len(bgp_nexthops)} RIB: {len(route_entry['vias'])}") + + +class VerifyBGPRedistribution(AntaTest): + """Verifies BGP redistribution. + + This test performs the following checks for each specified VRF in the BGP instance: + + 1. Ensures that the expected address-family is configured on the device. + 2. Confirms that the redistributed route protocol, include leaked and route map match the expected values. + + + Expected Results + ---------------- + * Success: If all of the following conditions are met: + - The expected address-family is configured on the device. + - The redistributed route protocol, include leaked and route map align with the expected values for the route. + * Failure: If any of the following occur: + - The expected address-family is not configured on device. + - The redistributed route protocol, include leaked or route map does not match the expected values. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPRedistribution: + vrfs: + - vrf: default + address_families: + - afi_safi: ipv4multicast + redistributed_routes: + - proto: Connected + include_leaked: True + route_map: RM-CONN-2-BGP + - proto: IS-IS + include_leaked: True + route_map: RM-CONN-2-BGP + - afi_safi: IPv6 Unicast + redistributed_routes: + - proto: User # Converted to EOS SDK + route_map: RM-CONN-2-BGP + - proto: Static + include_leaked: True + route_map: RM-CONN-2-BGP + ``` + """ + + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp instance vrf all", revision=4)] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPRedistribution test.""" + + vrfs: list[BgpVrf] + """List of VRFs in the BGP instance.""" + + def _validate_redistribute_route(self, vrf_data: str, addr_family: str, afi_safi_configs: list[dict[str, Any]], route_info: dict[str, Any]) -> list[Any]: + """Validate the redstributed route details for a given address family.""" + failure_msg = [] + # If the redistributed route protocol does not match the expected value, test fails. + if not (actual_route := get_item(afi_safi_configs.get("redistributedRoutes"), "proto", route_info.proto)): + failure_msg.append(f"{vrf_data}, {addr_family}, Proto: {route_info.proto} - Not configured") + return failure_msg + + # If includes leaked field applicable, and it does not matches the expected value, test fails. + if (act_include_leaked := actual_route.get("includeLeaked", False)) != route_info.include_leaked: + failure_msg.append(f"{vrf_data}, {addr_family}, {route_info} - Include leaked mismatch - Actual: {act_include_leaked}") + + # If route map is required and it is not matching the expected value, test fails. + if all([route_info.route_map, (act_route_map := actual_route.get("routeMap", "Not Found")) != route_info.route_map]): + failure_msg.append(f"{vrf_data}, {addr_family}, {route_info} - Route map mismatch - Actual: {act_route_map}") + return failure_msg + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPRedistribution.""" + self.result.is_success() + command_output = self.instance_commands[0].json_output + + for vrf_data in self.inputs.vrfs: + # If the specified VRF details are not found, test fails. + if not (instance_details := get_value(command_output, f"vrfs.{vrf_data.vrf}")): + self.result.is_failure(f"{vrf_data} - Not configured") + continue + for address_family in vrf_data.address_families: + # If the AFI-SAFI configuration details are not found, test fails. + if not (afi_safi_configs := get_value(instance_details, f"afiSafiConfig.{address_family.afi_safi}")): + self.result.is_failure(f"{vrf_data}, {address_family} - Not redistributed") + continue + + for route_info in address_family.redistributed_routes: + failure_msg = self._validate_redistribute_route(str(vrf_data), str(address_family), afi_safi_configs, route_info) + for msg in failure_msg: + self.result.is_failure(msg) + + +class VerifyBGPPeerTtlMultiHops(AntaTest): + """Verifies BGP TTL and max-ttl-hops count for BGP peers. + + This test performs the following checks for each specified BGP peer: + + 1. Verifies the specified BGP peer exists in the BGP configuration. + 2. Verifies the TTL and max-ttl-hops attribute matches the expected value. + + Expected Results + ---------------- + * Success: The test will pass if all specified peers exist with TTL and max-ttl-hops attributes matching the expected values. + * Failure: If any of the following occur: + - A specified BGP peer is not found. + - A TTL or max-ttl-hops attribute doesn't match the expected value for any peer. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPPeerTtlMultiHops: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + ttl: 3 + max_ttl_hops: 3 + - peer_address: 172.30.11.2 + vrf: test + ttl: 30 + max_ttl_hops: 30 + - peer_address: fd00:dc:1::1 + vrf: default + ttl: 30 + max_ttl_hops: 30 + # RFC5549 + - interface: Ethernet1 + vrf: MGMT + ttl: 30 + max_ttl_hops: 30 + ``` + """ + + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip bgp neighbors vrf all", revision=2)] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPPeerTtlMultiHops test.""" + + bgp_peers: list[BgpPeer] + """List of peer(s).""" + + @field_validator("bgp_peers") + @classmethod + def validate_bgp_peers(cls, bgp_peers: list[BgpPeer]) -> list[BgpPeer]: + """Validate that 'ttl' and 'max_ttl_hops' field is provided in each BGP peer.""" + for peer in bgp_peers: + if peer.ttl is None: + msg = f"{peer} 'ttl' field missing in the input" + raise ValueError(msg) + if peer.max_ttl_hops is None: + msg = f"{peer} 'max_ttl_hops' field missing in the input" + raise ValueError(msg) + + return bgp_peers + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPPeerTtlMultiHops.""" + self.result.is_success() + command_output = self.instance_commands[0].json_output + + for peer in self.inputs.bgp_peers: + # Check if the peer is found + if (peer_details := _get_bgp_peer_data(peer, command_output)) is None: + self.result.is_failure(f"{peer} - Not found") continue - # Verify BGP peer's hold and keep alive timers - if bgp_output.get("holdTime") != hold_time or bgp_output.get("keepaliveTime") != keep_alive_time: - failures[peer_address] = {vrf: {"hold_time": bgp_output.get("holdTime"), "keep_alive_time": bgp_output.get("keepaliveTime")}} + # Verify if the TTL duration matches the expected value. + if peer_details.get("ttl") != peer.ttl: + self.result.is_failure(f"{peer} - TTL mismatch - Expected: {peer.ttl} Actual: {peer_details.get('ttl')}") - if not failures: - self.result.is_success() - else: - self.result.is_failure(f"Following BGP peers are not configured or hold and keep-alive timers are not correct:\n{failures}") + # Verify if the max-ttl-hops time matches the expected value. + if peer_details.get("maxTtlHops") != peer.max_ttl_hops: + self.result.is_failure(f"{peer} - Max TTL Hops mismatch - Expected: {peer.max_ttl_hops} Actual: {peer_details.get('maxTtlHops')}") diff --git a/anta/tests/routing/generic.py b/anta/tests/routing/generic.py index 89d4bc56f..fe8e047f6 100644 --- a/anta/tests/routing/generic.py +++ b/anta/tests/routing/generic.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to generic routing tests.""" @@ -7,16 +7,28 @@ # mypy: disable-error-code=attr-defined from __future__ import annotations -from ipaddress import IPv4Address, ip_interface -from typing import ClassVar, Literal +from functools import cache +from ipaddress import IPv4Address, IPv4Interface +from typing import TYPE_CHECKING, Any, ClassVar, Literal -from pydantic import model_validator +from pydantic import field_validator, model_validator +from anta.custom_types import PositiveInteger +from anta.input_models.routing.generic import IPv4Routes from anta.models import AntaCommand, AntaTemplate, AntaTest +from anta.tools import get_item, get_value + +if TYPE_CHECKING: + import sys + + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self class VerifyRoutingProtocolModel(AntaTest): - """Verifies the configured routing protocol model is the one we expect. + """Verifies the configured routing protocol model. Expected Results ---------------- @@ -33,8 +45,6 @@ class VerifyRoutingProtocolModel(AntaTest): ``` """ - name = "VerifyRoutingProtocolModel" - description = "Verifies the configured routing protocol model." categories: ClassVar[list[str]] = ["routing"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)] @@ -53,7 +63,7 @@ def test(self) -> None: if configured_model == operating_model == self.inputs.model: self.result.is_success() else: - self.result.is_failure(f"routing model is misconfigured: configured: {configured_model} - operating: {operating_model} - expected: {self.inputs.model}") + self.result.is_failure(f"Routing model is misconfigured - Expected: {self.inputs.model} Actual: {operating_model}") class VerifyRoutingTableSize(AntaTest): @@ -75,21 +85,19 @@ class VerifyRoutingTableSize(AntaTest): ``` """ - name = "VerifyRoutingTableSize" - description = "Verifies the size of the IP routing table of the default VRF." categories: ClassVar[list[str]] = ["routing"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)] class Input(AntaTest.Input): """Input model for the VerifyRoutingTableSize test.""" - minimum: int + minimum: PositiveInteger """Expected minimum routing table size.""" - maximum: int + maximum: PositiveInteger """Expected maximum routing table size.""" - @model_validator(mode="after") # type: ignore[misc] - def check_min_max(self) -> AntaTest.Input: + @model_validator(mode="after") + def check_min_max(self) -> Self: """Validate that maximum is greater than minimum.""" if self.minimum > self.maximum: msg = f"Minimum {self.minimum} is greater than maximum {self.maximum}" @@ -104,7 +112,9 @@ def test(self) -> None: if self.inputs.minimum <= total_routes <= self.inputs.maximum: self.result.is_success() else: - self.result.is_failure(f"routing-table has {total_routes} routes and not between min ({self.inputs.minimum}) and maximum ({self.inputs.maximum})") + self.result.is_failure( + f"Routing table routes are outside the routes range - Expected: {self.inputs.minimum} <= to >= {self.inputs.maximum} Actual: {total_routes}" + ) class VerifyRoutingTableEntry(AntaTest): @@ -128,10 +138,11 @@ class VerifyRoutingTableEntry(AntaTest): ``` """ - name = "VerifyRoutingTableEntry" - description = "Verifies that the provided routes are present in the routing table of a specified VRF." categories: ClassVar[list[str]] = ["routing"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip route vrf {vrf} {route}", revision=4)] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ + AntaTemplate(template="show ip route vrf {vrf} {route}", revision=4), + AntaTemplate(template="show ip route vrf {vrf}", revision=4), + ] class Input(AntaTest.Input): """Input model for the VerifyRoutingTableEntry test.""" @@ -140,22 +151,256 @@ class Input(AntaTest.Input): """VRF context. Defaults to `default` VRF.""" routes: list[IPv4Address] """List of routes to verify.""" + collect: Literal["one", "all"] = "one" + """Route collect behavior: one=one route per command, all=all routes in vrf per command. Defaults to `one`""" def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Render the template for each route in the input list.""" - return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes] + """Render the template for the input vrf.""" + if template == VerifyRoutingTableEntry.commands[0] and self.inputs.collect == "one": + return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes] + + if template == VerifyRoutingTableEntry.commands[1] and self.inputs.collect == "all": + return [template.render(vrf=self.inputs.vrf)] + + return [] + + @staticmethod + @cache + def ip_interface_ip(route: str) -> IPv4Address: + """Return the IP address of the provided ip route with mask.""" + return IPv4Interface(route).ip @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyRoutingTableEntry.""" - missing_routes = [] + commands_output_route_ips = set() for command in self.instance_commands: - vrf, route = command.params.vrf, command.params.route - if len(routes := command.json_output["vrfs"][vrf]["routes"]) == 0 or route != ip_interface(next(iter(routes))).ip: - missing_routes.append(str(route)) + command_output_vrf = command.json_output["vrfs"][self.inputs.vrf] + commands_output_route_ips |= {self.ip_interface_ip(route) for route in command_output_vrf["routes"]} + + missing_routes = [str(route) for route in self.inputs.routes if route not in commands_output_route_ips] if not missing_routes: self.result.is_success() else: - self.result.is_failure(f"The following route(s) are missing from the routing table of VRF {self.inputs.vrf}: {missing_routes}") + self.result.is_failure(f"The following route(s) are missing from the routing table of VRF {self.inputs.vrf}: {', '.join(missing_routes)}") + + +class VerifyIPv4RouteType(AntaTest): + """Verifies the route-type of the IPv4 prefixes. + + This test performs the following checks for each IPv4 route: + + 1. Verifies that the specified VRF is configured. + 2. Verifies that the specified IPv4 route is exists in the configuration. + 3. Verifies that the the specified IPv4 route is of the expected type. + + Expected Results + ---------------- + * Success: If all of the following conditions are met: + - All the specified VRFs are configured. + - All the specified IPv4 routes are found. + - All the specified IPv4 routes are of the expected type. + * Failure: If any of the following occur: + - A specified VRF is not configured. + - A specified IPv4 route is not found. + - Any specified IPv4 route is not of the expected type. + + Examples + -------- + ```yaml + anta.tests.routing: + generic: + - VerifyIPv4RouteType: + routes_entries: + - prefix: 10.10.0.1/32 + vrf: default + route_type: eBGP + - prefix: 10.100.0.12/31 + vrf: default + route_type: connected + - prefix: 10.100.1.5/32 + vrf: default + route_type: iBGP + ``` + """ + + categories: ClassVar[list[str]] = ["routing"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route vrf all", revision=4)] + + class Input(AntaTest.Input): + """Input model for the VerifyIPv4RouteType test.""" + + routes_entries: list[IPv4Routes] + """List of IPv4 route(s).""" + + @field_validator("routes_entries") + @classmethod + def validate_routes_entries(cls, routes_entries: list[IPv4Routes]) -> list[IPv4Routes]: + """Validate that 'route_type' field is provided in each BGP route entry.""" + for entry in routes_entries: + if entry.route_type is None: + msg = f"{entry} 'route_type' field missing in the input" + raise ValueError(msg) + return routes_entries + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyIPv4RouteType.""" + self.result.is_success() + output = self.instance_commands[0].json_output + + # Iterating over the all routes entries mentioned in the inputs. + for entry in self.inputs.routes_entries: + prefix = str(entry.prefix) + vrf = entry.vrf + expected_route_type = entry.route_type + + # Verifying that on device, expected VRF is configured. + if (routes_details := get_value(output, f"vrfs.{vrf}.routes")) is None: + self.result.is_failure(f"{entry} - VRF not configured") + continue + + # Verifying that the expected IPv4 route is present or not on the device + if (route_data := routes_details.get(prefix)) is None: + self.result.is_failure(f"{entry} - Route not found") + continue + + # Verifying that the specified IPv4 routes are of the expected type. + if expected_route_type != (actual_route_type := route_data.get("routeType")): + self.result.is_failure(f"{entry} - Incorrect route type - Expected: {expected_route_type} Actual: {actual_route_type}") + + +class VerifyIPv4RouteNextHops(AntaTest): + """Verifies the next-hops of the IPv4 prefixes. + + This test performs the following checks for each IPv4 prefix: + + 1. Verifies the specified IPv4 route exists in the routing table. + 2. For each specified next-hop: + - Verifies a path with matching next-hop exists. + - Supports `strict: True` to verify that routes must be learned exclusively via the exact next-hops specified. + + Expected Results + ---------------- + * Success: The test will pass if routes exist with paths matching the expected next-hops. + * Failure: The test will fail if: + - A route entry is not found for given IPv4 prefixes. + - A path with specified next-hop is not found. + + Examples + -------- + ```yaml + anta.tests.routing: + generic: + - VerifyIPv4RouteNextHops: + route_entries: + - prefix: 10.10.0.1/32 + vrf: default + strict: false + nexthops: + - 10.100.0.8 + - 10.100.0.10 + ``` + """ + + categories: ClassVar[list[str]] = ["routing"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route vrf all", revision=4)] + + class Input(AntaTest.Input): + """Input model for the VerifyIPv4RouteNextHops test.""" + + route_entries: list[IPv4Routes] + """List of IPv4 route(s).""" + + @field_validator("route_entries") + @classmethod + def validate_route_entries(cls, route_entries: list[IPv4Routes]) -> list[IPv4Routes]: + """Validate that 'nexthops' field is provided in each route entry.""" + for entry in route_entries: + if entry.nexthops is None: + msg = f"{entry} 'nexthops' field missing in the input" + raise ValueError(msg) + return route_entries + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyIPv4RouteNextHops.""" + self.result.is_success() + + output = self.instance_commands[0].json_output + + for entry in self.inputs.route_entries: + # Verify if the prefix exists in route table + if (route_data := get_value(output, f"vrfs..{entry.vrf}..routes..{entry.prefix}", separator="..")) is None: + self.result.is_failure(f"{entry} - prefix not found") + continue + + # Verify the nexthop addresses + actual_nexthops = sorted(["Directly connected" if (next_hop := route.get("nexthopAddr")) == "" else next_hop for route in route_data["vias"]]) + expected_nexthops = sorted([str(nexthop) for nexthop in entry.nexthops]) + + if entry.strict and expected_nexthops != actual_nexthops: + exp_nexthops = ", ".join(expected_nexthops) + self.result.is_failure(f"{entry} - List of next-hops not matching - Expected: {exp_nexthops} Actual: {', '.join(actual_nexthops)}") + continue + + for nexthop in entry.nexthops: + if not get_item(route_data["vias"], "nexthopAddr", str(nexthop)): + self.result.is_failure(f"{entry} Nexthop: {nexthop} - Route not found") + + +class VerifyRoutingStatus(AntaTest): + """Verifies the routing status for IPv4/IPv6 unicast, multicast, and IPv6 interfaces (RFC5549). + + Expected Results + ---------------- + * Success: The test will pass if the routing status is correct. + * Failure: The test will fail if the routing status doesn't match the expected configuration. + + Examples + -------- + ```yaml + anta.tests.routing: + generic: + - VerifyRoutingStatus: + ipv4_unicast: True + ipv6_unicast: True + ``` + """ + + categories: ClassVar[list[str]] = ["routing"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyRoutingStatus test.""" + + ipv4_unicast: bool = False + """IPv4 unicast routing status.""" + ipv6_unicast: bool = False + """IPv6 unicast routing status.""" + ipv4_multicast: bool = False + """IPv4 multicast routing status.""" + ipv6_multicast: bool = False + """IPv6 multicast routing status.""" + ipv6_interfaces: bool = False + """IPv6 interface forwarding status.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyRoutingStatus.""" + self.result.is_success() + command_output = self.instance_commands[0].json_output + actual_routing_status: dict[str, Any] = { + "ipv4_unicast": command_output["v4RoutingEnabled"], + "ipv6_unicast": command_output["v6RoutingEnabled"], + "ipv4_multicast": command_output["multicastRouting"]["ipMulticastEnabled"], + "ipv6_multicast": command_output["multicastRouting"]["ip6MulticastEnabled"], + "ipv6_interfaces": command_output.get("v6IntfForwarding", False), + } + + for input_key, value in self.inputs: + if input_key in actual_routing_status and value != actual_routing_status[input_key]: + route_type = " ".join([{"ipv4": "IPv4", "ipv6": "IPv6"}.get(part, part) for part in input_key.split("_")]) + self.result.is_failure(f"{route_type} routing enabled status mismatch - Expected: {value} Actual: {actual_routing_status[input_key]}") diff --git a/anta/tests/routing/isis.py b/anta/tests/routing/isis.py new file mode 100644 index 000000000..cd9dbb6b5 --- /dev/null +++ b/anta/tests/routing/isis.py @@ -0,0 +1,519 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module related to IS-IS tests.""" + +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import field_validator + +from anta.input_models.routing.isis import Entry, InterfaceCount, InterfaceState, ISISInstance, IsisInstance, ISISInterface, Tunnel, TunnelPath +from anta.models import AntaCommand, AntaTemplate, AntaTest +from anta.tools import get_item, get_value + + +class VerifyISISNeighborState(AntaTest): + """Verifies the health of IS-IS neighbors. + + Expected Results + ---------------- + * Success: The test will pass if all IS-IS neighbors are in the `up` state. + * Failure: The test will fail if any IS-IS neighbor adjacency is down. + * Skipped: The test will be skipped if IS-IS is not configured or no IS-IS neighbor is found. + + Examples + -------- + ```yaml + anta.tests.routing: + isis: + - VerifyISISNeighborState: + check_all_vrfs: true + ``` + """ + + categories: ClassVar[list[str]] = ["isis"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis neighbors vrf all", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyISISNeighborState test.""" + + check_all_vrfs: bool = False + """If enabled, verifies IS-IS instances of all VRFs.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyISISNeighborState.""" + self.result.is_success() + + # Verify if IS-IS is configured + if not (command_output := self.instance_commands[0].json_output["vrfs"]): + self.result.is_skipped("IS-IS not configured") + return + + vrfs_to_check = command_output + if not self.inputs.check_all_vrfs: + vrfs_to_check = {"default": command_output["default"]} + + no_neighbor = True + for vrf, vrf_data in vrfs_to_check.items(): + for isis_instance, instance_data in vrf_data["isisInstances"].items(): + neighbors = instance_data["neighbors"] + if not neighbors: + continue + no_neighbor = False + interfaces = [(adj["interfaceName"], adj["state"]) for neighbor in neighbors.values() for adj in neighbor["adjacencies"] if adj["state"] != "up"] + for interface in interfaces: + self.result.is_failure( + f"Instance: {isis_instance} VRF: {vrf} Interface: {interface[0]} - Incorrect adjacency state - Expected: up Actual: {interface[1]}" + ) + + if no_neighbor: + self.result.is_skipped("No IS-IS neighbor detected") + + +class VerifyISISNeighborCount(AntaTest): + """Verifies the number of IS-IS neighbors per interface and level. + + Expected Results + ---------------- + * Success: The test will pass if all provided IS-IS interfaces have the expected number of neighbors. + * Failure: The test will fail if any of the provided IS-IS interfaces are not configured or have an incorrect number of neighbors. + * Skipped: The test will be skipped if IS-IS is not configured. + + Examples + -------- + ```yaml + anta.tests.routing: + isis: + - VerifyISISNeighborCount: + interfaces: + - name: Ethernet1 + level: 1 + count: 2 + - name: Ethernet2 + level: 2 + count: 1 + - name: Ethernet3 + count: 2 + ``` + """ + + categories: ClassVar[list[str]] = ["isis"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief vrf all", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyISISNeighborCount test.""" + + interfaces: list[ISISInterface] + """List of IS-IS interfaces with their information.""" + InterfaceCount: ClassVar[type[InterfaceCount]] = InterfaceCount + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyISISNeighborCount.""" + self.result.is_success() + + # Verify if IS-IS is configured + if not (command_output := self.instance_commands[0].json_output["vrfs"]): + self.result.is_skipped("IS-IS not configured") + return + + for interface in self.inputs.interfaces: + interface_detail = {} + vrf_instances = get_value(command_output, f"{interface.vrf}..isisInstances", default={}, separator="..") + for instance_data in vrf_instances.values(): + if interface_data := get_value(instance_data, f"interfaces..{interface.name}..intfLevels..{interface.level}", separator=".."): + interface_detail = interface_data + # An interface can only be configured in one IS-IS instance at a time + break + + if not interface_detail: + self.result.is_failure(f"{interface} - Not configured") + continue + + if interface_detail["passive"] is False and (act_count := interface_detail["numAdjacencies"]) != interface.count: + self.result.is_failure(f"{interface} - Neighbor count mismatch - Expected: {interface.count} Actual: {act_count}") + + +class VerifyISISInterfaceMode(AntaTest): + """Verifies IS-IS interfaces are running in the correct mode. + + Expected Results + ---------------- + * Success: The test will pass if all provided IS-IS interfaces are running in the correct mode. + * Failure: The test will fail if any of the provided IS-IS interfaces are not configured or running in the incorrect mode. + * Skipped: The test will be skipped if IS-IS is not configured. + + Examples + -------- + ```yaml + anta.tests.routing: + isis: + - VerifyISISInterfaceMode: + interfaces: + - name: Loopback0 + mode: passive + - name: Ethernet2 + mode: passive + level: 2 + - name: Ethernet1 + mode: point-to-point + vrf: PROD + ``` + """ + + categories: ClassVar[list[str]] = ["isis"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief vrf all", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyISISInterfaceMode test.""" + + interfaces: list[ISISInterface] + """List of IS-IS interfaces with their information.""" + InterfaceState: ClassVar[type[InterfaceState]] = InterfaceState + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyISISInterfaceMode.""" + self.result.is_success() + + # Verify if IS-IS is configured + if not (command_output := self.instance_commands[0].json_output["vrfs"]): + self.result.is_skipped("IS-IS not configured") + return + + for interface in self.inputs.interfaces: + interface_detail = {} + vrf_instances = get_value(command_output, f"{interface.vrf}..isisInstances", default={}, separator="..") + for instance_data in vrf_instances.values(): + if interface_data := get_value(instance_data, f"interfaces..{interface.name}", separator=".."): + interface_detail = interface_data + # An interface can only be configured in one IS-IS instance at a time + break + + if not interface_detail: + self.result.is_failure(f"{interface} - Not configured") + continue + + # Check for passive + if interface.mode == "passive": + if get_value(interface_detail, f"intfLevels.{interface.level}.passive", default=False) is False: + self.result.is_failure(f"{interface} - Not running in passive mode") + + # Check for point-to-point or broadcast + elif interface.mode != (interface_type := get_value(interface_detail, "interfaceType", default="unset")): + self.result.is_failure(f"{interface} - Incorrect interface mode - Expected: {interface.mode} Actual: {interface_type}") + + +class VerifyISISSegmentRoutingAdjacencySegments(AntaTest): + """Verifies IS-IS segment routing adjacency segments. + + !!! warning "IS-IS SR Limitation" + As of EOS 4.33.1F, IS-IS SR is supported only in the default VRF. + Please refer to the IS-IS Segment Routing [documentation](https://www.arista.com/en/support/toi/eos-4-17-0f/13789-isis-segment-routing) + for more information. + + Expected Results + ---------------- + * Success: The test will pass if all provided IS-IS instances have the correct adjacency segments. + * Failure: The test will fail if any of the provided IS-IS instances have no adjacency segments or incorrect segments. + * Skipped: The test will be skipped if IS-IS is not configured. + + Examples + -------- + ```yaml + anta.tests.routing: + isis: + - VerifyISISSegmentRoutingAdjacencySegments: + instances: + - name: CORE-ISIS + vrf: default + segments: + - interface: Ethernet2 + address: 10.0.1.3 + sid_origin: dynamic + ``` + """ + + categories: ClassVar[list[str]] = ["isis", "segment-routing"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis segment-routing adjacency-segments", ofmt="json")] + + class Input(AntaTest.Input): + """Input model for the VerifyISISSegmentRoutingAdjacencySegments test.""" + + instances: list[ISISInstance] + """List of IS-IS instances with their information.""" + IsisInstance: ClassVar[type[IsisInstance]] = IsisInstance + + @field_validator("instances") + @classmethod + def validate_instances(cls, instances: list[ISISInstance]) -> list[ISISInstance]: + """Validate that 'vrf' field is 'default' in each IS-IS instance.""" + for instance in instances: + if instance.vrf != "default": + msg = f"{instance} 'vrf' field must be 'default'" + raise ValueError(msg) + return instances + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyISISSegmentRoutingAdjacencySegments.""" + self.result.is_success() + + # Verify if IS-IS is configured + if not (command_output := self.instance_commands[0].json_output["vrfs"]): + self.result.is_skipped("IS-IS not configured") + return + + for instance in self.inputs.instances: + if not (act_segments := get_value(command_output, f"{instance.vrf}..isisInstances..{instance.name}..adjacencySegments", default=[], separator="..")): + self.result.is_failure(f"{instance} - No adjacency segments found") + continue + + for segment in instance.segments: + if (act_segment := get_item(act_segments, "ipAddress", str(segment.address))) is None: + self.result.is_failure(f"{instance} {segment} - Adjacency segment not found") + continue + + # Check SID origin + if (act_origin := act_segment["sidOrigin"]) != segment.sid_origin: + self.result.is_failure(f"{instance} {segment} - Incorrect SID origin - Expected: {segment.sid_origin} Actual: {act_origin}") + + # Check IS-IS level + if (actual_level := act_segment["level"]) != segment.level: + self.result.is_failure(f"{instance} {segment} - Incorrect IS-IS level - Expected: {segment.level} Actual: {actual_level}") + + +class VerifyISISSegmentRoutingDataplane(AntaTest): + """Verifies IS-IS segment routing data-plane configuration. + + !!! warning "IS-IS SR Limitation" + As of EOS 4.33.1F, IS-IS SR is supported only in the default VRF. + Please refer to the IS-IS Segment Routing [documentation](https://www.arista.com/en/support/toi/eos-4-17-0f/13789-isis-segment-routing) + for more information. + + Expected Results + ---------------- + * Success: The test will pass if all provided IS-IS instances have the correct data-plane configured. + * Failure: The test will fail if any of the provided IS-IS instances have an incorrect data-plane configured. + * Skipped: The test will be skipped if IS-IS is not configured. + + Examples + -------- + ```yaml + anta.tests.routing: + isis: + - VerifyISISSegmentRoutingDataplane: + instances: + - name: CORE-ISIS + vrf: default + dataplane: MPLS + ``` + """ + + categories: ClassVar[list[str]] = ["isis", "segment-routing"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis segment-routing", ofmt="json")] + + class Input(AntaTest.Input): + """Input model for the VerifyISISSegmentRoutingDataplane test.""" + + instances: list[ISISInstance] + """List of IS-IS instances with their information.""" + IsisInstance: ClassVar[type[IsisInstance]] = IsisInstance + + @field_validator("instances") + @classmethod + def validate_instances(cls, instances: list[ISISInstance]) -> list[ISISInstance]: + """Validate that 'vrf' field is 'default' in each IS-IS instance.""" + for instance in instances: + if instance.vrf != "default": + msg = f"{instance} 'vrf' field must be 'default'" + raise ValueError(msg) + return instances + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyISISSegmentRoutingDataplane.""" + self.result.is_success() + + # Verify if IS-IS is configured + if not (command_output := self.instance_commands[0].json_output["vrfs"]): + self.result.is_skipped("IS-IS not configured") + return + + for instance in self.inputs.instances: + if not (instance_data := get_value(command_output, f"{instance.vrf}..isisInstances..{instance.name}", separator="..")): + self.result.is_failure(f"{instance} - Not configured") + continue + + if instance.dataplane.upper() != (dataplane := instance_data["dataPlane"]): + self.result.is_failure(f"{instance} - Data-plane not correctly configured - Expected: {instance.dataplane.upper()} Actual: {dataplane}") + + +class VerifyISISSegmentRoutingTunnels(AntaTest): + """Verify ISIS-SR tunnels computed by device. + + Expected Results + ---------------- + * Success: The test will pass if all listed tunnels are computed on device. + * Failure: The test will fail if one of the listed tunnels is missing. + * Skipped: The test will be skipped if ISIS-SR is not configured. + + Examples + -------- + ```yaml + anta.tests.routing: + isis: + - VerifyISISSegmentRoutingTunnels: + entries: + # Check only endpoint + - endpoint: 1.0.0.122/32 + # Check endpoint and via TI-LFA + - endpoint: 1.0.0.13/32 + vias: + - type: tunnel + tunnel_id: ti-lfa + # Check endpoint and via IP routers + - endpoint: 1.0.0.14/32 + vias: + - type: ip + nexthop: 1.1.1.1 + ``` + """ + + categories: ClassVar[list[str]] = ["isis", "segment-routing"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis segment-routing tunnel", ofmt="json")] + + class Input(AntaTest.Input): + """Input model for the VerifyISISSegmentRoutingTunnels test.""" + + entries: list[Tunnel] + """List of tunnels to check on device.""" + Entry: ClassVar[type[Entry]] = Entry + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyISISSegmentRoutingTunnels. + + This method performs the main test logic for verifying ISIS Segment Routing tunnels. + It checks the command output, initiates defaults, and performs various checks on the tunnels. + """ + self.result.is_success() + + command_output = self.instance_commands[0].json_output + if len(command_output["entries"]) == 0: + self.result.is_skipped("IS-IS-SR not configured") + return + + for input_entry in self.inputs.entries: + entries = list(command_output["entries"].values()) + if (eos_entry := get_item(entries, "endpoint", str(input_entry.endpoint))) is None: + self.result.is_failure(f"{input_entry} - Tunnel not found") + continue + + if input_entry.vias is not None: + for via_input in input_entry.vias: + via_search_result = any(self._via_matches(via_input, eos_via) for eos_via in eos_entry["vias"]) + if not via_search_result: + self.result.is_failure(f"{input_entry} {via_input} - Tunnel is incorrect") + + def _via_matches(self, via_input: TunnelPath, eos_via: dict[str, Any]) -> bool: + """Check if the via input matches the eos via. + + Parameters + ---------- + via_input : TunnelPath + The input via to check. + eos_via : dict[str, Any] + The EOS via to compare against. + + Returns + ------- + bool + True if the via input matches the eos via, False otherwise. + """ + return ( + (via_input.type is None or via_input.type == eos_via.get("type")) + and (via_input.nexthop is None or str(via_input.nexthop) == eos_via.get("nexthop")) + and (via_input.interface is None or via_input.interface == eos_via.get("interface")) + and (via_input.tunnel_id is None or via_input.tunnel_id.upper() == get_value(eos_via, "tunnelId.type", default="").upper()) + ) + + +class VerifyISISGracefulRestart(AntaTest): + """Verifies the IS-IS graceful restart feature. + + This test performs the following checks for each IS-IS instance: + + 1. Verifies that the specified IS-IS instance is configured on the device. + 2. Verifies the statuses of the graceful restart and graceful restart helper functionalities. + + Expected Results + ---------------- + * Success: The test will pass if all of the following conditions are met: + - The specified IS-IS instance is configured on the device. + - Expected and actual IS-IS graceful restart and graceful restart helper values match. + * Failure: The test will fail if any of the following conditions is met: + - The specified IS-IS instance is not configured on the device. + - Expected and actual IS-IS graceful restart and graceful restart helper values do not match. + * Skipped: The test will skip if IS-IS is not configured on the device. + + Examples + -------- + ```yaml + anta.tests.routing: + isis: + - VerifyISISGracefulRestart: + instances: + - name: '1' + vrf: default + graceful_restart: True + graceful_restart_helper: False + - name: '2' + vrf: default + - name: '11' + vrf: test + graceful_restart: True + - name: '12' + vrf: test + graceful_restart_helper: False + ``` + """ + + categories: ClassVar[list[str]] = ["isis"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis graceful-restart vrf all", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyISISGracefulRestart test.""" + + instances: list[ISISInstance] + """List of IS-IS instance entries.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyISISGracefulRestart.""" + self.result.is_success() + + # Verify if IS-IS is configured + if not (command_output := self.instance_commands[0].json_output["vrfs"]): + self.result.is_skipped("IS-IS not configured") + return + + # If IS-IS instance is not found or GR and GR helpers are not matching with the expected values, test fails. + for instance in self.inputs.instances: + graceful_restart = "enabled" if instance.graceful_restart else "disabled" + graceful_restart_helper = "enabled" if instance.graceful_restart_helper else "disabled" + + if (instance_details := get_value(command_output, f"{instance.vrf}..isisInstances..{instance.name}", separator="..")) is None: + self.result.is_failure(f"{instance} - Not configured") + continue + + if (act_state := instance_details.get("gracefulRestart")) != graceful_restart: + self.result.is_failure(f"{instance} - Incorrect graceful restart state - Expected: {graceful_restart} Actual: {act_state}") + + if (act_helper_state := instance_details.get("gracefulRestartHelper")) != graceful_restart_helper: + self.result.is_failure(f"{instance} - Incorrect graceful restart helper state - Expected: {graceful_restart_helper} Actual: {act_helper_state}") diff --git a/anta/tests/routing/ospf.py b/anta/tests/routing/ospf.py index 5910bf04e..1c0b2f0a9 100644 --- a/anta/tests/routing/ospf.py +++ b/anta/tests/routing/ospf.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to OSPF tests.""" @@ -7,84 +7,16 @@ # mypy: disable-error-code=attr-defined from __future__ import annotations -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, ClassVar +from anta.input_models.routing.ospf import OSPFNeighbor from anta.models import AntaCommand, AntaTest +from anta.tools import get_value if TYPE_CHECKING: from anta.models import AntaTemplate -def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int: - """Count the number of OSPF neighbors. - - Args: - ---- - ospf_neighbor_json: The JSON output of the `show ip ospf neighbor` command. - - Returns - ------- - int: The number of OSPF neighbors. - - """ - count = 0 - for vrf_data in ospf_neighbor_json["vrfs"].values(): - for instance_data in vrf_data["instList"].values(): - count += len(instance_data.get("ospfNeighborEntries", [])) - return count - - -def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]: - """Return the OSPF neighbors whose adjacency state is not `full`. - - Args: - ---- - ospf_neighbor_json: The JSON output of the `show ip ospf neighbor` command. - - Returns - ------- - list[dict[str, Any]]: A list of OSPF neighbors whose adjacency state is not `full`. - - """ - return [ - { - "vrf": vrf, - "instance": instance, - "neighbor": neighbor_data["routerId"], - "state": state, - } - for vrf, vrf_data in ospf_neighbor_json["vrfs"].items() - for instance, instance_data in vrf_data["instList"].items() - for neighbor_data in instance_data.get("ospfNeighborEntries", []) - if (state := neighbor_data["adjacencyState"]) != "full" - ] - - -def _get_ospf_max_lsa_info(ospf_process_json: dict[str, Any]) -> list[dict[str, Any]]: - """Return information about OSPF instances and their LSAs. - - Args: - ---- - ospf_process_json: OSPF process information in JSON format. - - Returns - ------- - list[dict[str, Any]]: A list of dictionaries containing OSPF LSAs information. - - """ - return [ - { - "vrf": vrf, - "instance": instance, - "maxLsa": instance_data.get("maxLsaInformation", {}).get("maxLsa"), - "maxLsaThreshold": instance_data.get("maxLsaInformation", {}).get("maxLsaThreshold"), - "numLsa": instance_data.get("lsaInformation", {}).get("numLsa"), - } - for vrf, vrf_data in ospf_process_json.get("vrfs", {}).items() - for instance, instance_data in vrf_data.get("instList", {}).items() - ] - - class VerifyOSPFNeighborState(AntaTest): """Verifies all OSPF neighbors are in FULL state. @@ -103,22 +35,35 @@ class VerifyOSPFNeighborState(AntaTest): ``` """ - name = "VerifyOSPFNeighborState" - description = "Verifies all OSPF neighbors are in FULL state." categories: ClassVar[list[str]] = ["ospf"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)] @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyOSPFNeighborState.""" - command_output = self.instance_commands[0].json_output - if _count_ospf_neighbor(command_output) == 0: - self.result.is_skipped("no OSPF neighbor found") - return self.result.is_success() - not_full_neighbors = _get_not_full_ospf_neighbors(command_output) - if not_full_neighbors: - self.result.is_failure(f"Some neighbors are not correctly configured: {not_full_neighbors}.") + + # If OSPF is not configured on device, test skipped. + if not (command_output := get_value(self.instance_commands[0].json_output, "vrfs")): + self.result.is_skipped("OSPF not configured") + return + + no_neighbor = True + for vrf, vrf_data in command_output.items(): + for instance, instance_data in vrf_data["instList"].items(): + neighbors = instance_data["ospfNeighborEntries"] + if not neighbors: + continue + no_neighbor = False + interfaces = [(neighbor["routerId"], state) for neighbor in neighbors if (state := neighbor["adjacencyState"]) != "full"] + for interface in interfaces: + self.result.is_failure( + f"Instance: {instance} VRF: {vrf} Interface: {interface[0]} - Incorrect adjacency state - Expected: Full Actual: {interface[1]}" + ) + + # If OSPF neighbors are not configured on device, test skipped. + if no_neighbor: + self.result.is_skipped("No OSPF neighbor detected") class VerifyOSPFNeighborCount(AntaTest): @@ -140,8 +85,6 @@ class VerifyOSPFNeighborCount(AntaTest): ``` """ - name = "VerifyOSPFNeighborCount" - description = "Verifies the number of OSPF neighbors in FULL state is the one we expect." categories: ClassVar[list[str]] = ["ospf"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)] @@ -154,20 +97,34 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyOSPFNeighborCount.""" - command_output = self.instance_commands[0].json_output - if (neighbor_count := _count_ospf_neighbor(command_output)) == 0: - self.result.is_skipped("no OSPF neighbor found") - return self.result.is_success() - if neighbor_count != self.inputs.number: - self.result.is_failure(f"device has {neighbor_count} neighbors (expected {self.inputs.number})") - not_full_neighbors = _get_not_full_ospf_neighbors(command_output) - if not_full_neighbors: - self.result.is_failure(f"Some neighbors are not correctly configured: {not_full_neighbors}.") + # If OSPF is not configured on device, test skipped. + if not (command_output := get_value(self.instance_commands[0].json_output, "vrfs")): + self.result.is_skipped("OSPF not configured") + return + + no_neighbor = True + interfaces = [] + for vrf_data in command_output.values(): + for instance_data in vrf_data["instList"].values(): + neighbors = instance_data["ospfNeighborEntries"] + if not neighbors: + continue + no_neighbor = False + interfaces.extend([neighbor["routerId"] for neighbor in neighbors if neighbor["adjacencyState"] == "full"]) + + # If OSPF neighbors are not configured on device, test skipped. + if no_neighbor: + self.result.is_skipped("No OSPF neighbor detected") + return + + # If the number of OSPF neighbors expected to be in the FULL state does not match with actual one, test fails. + if len(interfaces) != self.inputs.number: + self.result.is_failure(f"Neighbor count mismatch - Expected: {self.inputs.number} Actual: {len(interfaces)}") class VerifyOSPFMaxLSA(AntaTest): - """Verifies LSAs present in the OSPF link state database did not cross the maximum LSA Threshold. + """Verifies all OSPF instances did not cross the maximum LSA threshold. Expected Results ---------------- @@ -184,24 +141,95 @@ class VerifyOSPFMaxLSA(AntaTest): ``` """ - name = "VerifyOSPFMaxLSA" - description = "Verifies all OSPF instances did not cross the maximum LSA threshold." categories: ClassVar[list[str]] = ["ospf"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf", revision=1)] @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyOSPFMaxLSA.""" - command_output = self.instance_commands[0].json_output - ospf_instance_info = _get_ospf_max_lsa_info(command_output) - if not ospf_instance_info: - self.result.is_skipped("No OSPF instance found.") + self.result.is_success() + + # If OSPF is not configured on device, test skipped. + if not (command_output := get_value(self.instance_commands[0].json_output, "vrfs")): + self.result.is_skipped("OSPF not configured") return - all_instances_within_threshold = all(instance["numLsa"] <= instance["maxLsa"] * (instance["maxLsaThreshold"] / 100) for instance in ospf_instance_info) - if all_instances_within_threshold: - self.result.is_success() - else: - exceeded_instances = [ - instance["instance"] for instance in ospf_instance_info if instance["numLsa"] > instance["maxLsa"] * (instance["maxLsaThreshold"] / 100) - ] - self.result.is_failure(f"OSPF Instances {exceeded_instances} crossed the maximum LSA threshold.") + + for vrf_data in command_output.values(): + for instance, instance_data in vrf_data.get("instList", {}).items(): + max_lsa = instance_data["maxLsaInformation"]["maxLsa"] + max_lsa_threshold = instance_data["maxLsaInformation"]["maxLsaThreshold"] + num_lsa = get_value(instance_data, "lsaInformation.numLsa") + if num_lsa > (max_lsa_threshold := round(max_lsa * (max_lsa_threshold / 100))): + self.result.is_failure(f"Instance: {instance} - Crossed the maximum LSA threshold - Expected: < {max_lsa_threshold} Actual: {num_lsa}") + + +class VerifyOSPFSpecificNeighbors(AntaTest): + """Verifies OSPF specific neighbors. + + Expected Results + ---------------- + * Success: The test will pass if all specified OSPF neighbors meet expected state and area. + * Failure: The test will fail if OSPF is not configured, or any specified neighbor is not found or has incorrect state/area. + + Examples + -------- + ```yaml + anta.tests.routing: + ospf: + - VerifyOSPFSpecificNeighbors: + neighbors: + - instance: 100 + vrf: default + ip_address: 10.1.255.46 + local_interface: Ethernet2 + area_id: 0 # Support for decimal format + state: full + - instance: 200 + vrf: DEV + ip_address: 10.9.1.1 + local_interface: Vlan911 + area_id: 0.0.0.1 # Support for IP address format + state: 2Ways + ``` + """ + + categories: ClassVar[list[str]] = ["ospf"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyOSPFSpecificNeighbors test.""" + + neighbors: list[OSPFNeighbor] + """List of OSPF neighbors to verify.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyOSPFSpecificNeighbors.""" + self.result.is_success() + + # If OSPF is not configured on the device, test fails + if not (vrf_data := get_value(self.instance_commands[0].json_output, "vrfs")): + self.result.is_failure("OSPF not configured") + return + + for neighbor in self.inputs.neighbors: + # Try to get the neighbor data from the ospfNeighborEntries output list + neighbor_data = {} + for entry in get_value(vrf_data, f"{neighbor.vrf}..instList..{neighbor.instance}..ospfNeighborEntries", default=[], separator=".."): + if str(neighbor.ip_address) == entry["interfaceAddress"] and neighbor.local_interface == entry["interfaceName"]: + # Neighbor found + neighbor_data = entry + break + + if not neighbor_data: + self.result.is_failure(f"{neighbor} - Neighbor not found") + continue + + # Check the area_id + if (exp_area_id := str(neighbor.area_id)) != (act_area_id := neighbor_data["details"]["areaId"]): + self.result.is_failure(f"{neighbor} - Area-ID mismatch - Expected: {exp_area_id} Actual: {act_area_id}") + continue + + # Check the adjacency state + if (exp_adj_state := neighbor.state) != (act_adj_state := neighbor_data["adjacencyState"]): + self.result.is_failure(f"{neighbor} - Adjacency state mismatch - Expected: {exp_adj_state} Actual: {act_adj_state}") diff --git a/anta/tests/security.py b/anta/tests/security.py index 0f9df8723..d0266923f 100644 --- a/anta/tests/security.py +++ b/anta/tests/security.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to the EOS various security tests.""" @@ -8,14 +8,12 @@ # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from datetime import datetime, timezone -from ipaddress import IPv4Address from typing import ClassVar -from pydantic import BaseModel, Field, model_validator - -from anta.custom_types import EcdsaKeySize, EncryptionAlgorithm, PositiveInteger, RsaKeySize +from anta.custom_types import PositiveInteger +from anta.input_models.security import ACL, APISSLCertificate, IPSecPeer, IPSecPeers from anta.models import AntaCommand, AntaTemplate, AntaTest -from anta.tools import get_failed_logs, get_item, get_value +from anta.tools import get_item, get_value class VerifySSHStatus(AntaTest): @@ -34,8 +32,6 @@ class VerifySSHStatus(AntaTest): ``` """ - name = "VerifySSHStatus" - description = "Verifies if the SSHD agent is disabled in the default VRF." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh", ofmt="text")] @@ -44,8 +40,12 @@ def test(self) -> None: """Main test function for VerifySSHStatus.""" command_output = self.instance_commands[0].text_output - line = next(line for line in command_output.split("\n") if line.startswith("SSHD status")) - status = line.split("is ")[1] + try: + line = next(line for line in command_output.split("\n") if line.startswith("SSHD status")) + except StopIteration: + self.result.is_failure("Could not find SSH status in returned output") + return + status = line.split()[-1] if status == "disabled": self.result.is_success() @@ -71,7 +71,6 @@ class VerifySSHIPv4Acl(AntaTest): ``` """ - name = "VerifySSHIPv4Acl" description = "Verifies if the SSHD agent has IPv4 ACL(s) configured." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ip access-list summary", revision=1)] @@ -87,19 +86,18 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySSHIPv4Acl.""" + self.result.is_success() command_output = self.instance_commands[0].json_output ipv4_acl_list = command_output["ipAclList"]["aclList"] ipv4_acl_number = len(ipv4_acl_list) if ipv4_acl_number != self.inputs.number: - self.result.is_failure(f"Expected {self.inputs.number} SSH IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}") + self.result.is_failure(f"VRF: {self.inputs.vrf} - SSH IPv4 ACL(s) count mismatch - Expected: {self.inputs.number} Actual: {ipv4_acl_number}") return not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]] if not_configured_acl: - self.result.is_failure(f"SSH IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}") - else: - self.result.is_success() + self.result.is_failure(f"VRF: {self.inputs.vrf} - Following SSH IPv4 ACL(s) not configured or active: {', '.join(not_configured_acl)}") class VerifySSHIPv6Acl(AntaTest): @@ -120,7 +118,6 @@ class VerifySSHIPv6Acl(AntaTest): ``` """ - name = "VerifySSHIPv6Acl" description = "Verifies if the SSHD agent has IPv6 ACL(s) configured." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ipv6 access-list summary", revision=1)] @@ -136,19 +133,18 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySSHIPv6Acl.""" + self.result.is_success() command_output = self.instance_commands[0].json_output ipv6_acl_list = command_output["ipv6AclList"]["aclList"] ipv6_acl_number = len(ipv6_acl_list) if ipv6_acl_number != self.inputs.number: - self.result.is_failure(f"Expected {self.inputs.number} SSH IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}") + self.result.is_failure(f"VRF: {self.inputs.vrf} - SSH IPv6 ACL(s) count mismatch - Expected: {self.inputs.number} Actual: {ipv6_acl_number}") return not_configured_acl = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]] if not_configured_acl: - self.result.is_failure(f"SSH IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}") - else: - self.result.is_success() + self.result.is_failure(f"VRF: {self.inputs.vrf} - Following SSH IPv6 ACL(s) not configured or active: {', '.join(not_configured_acl)}") class VerifyTelnetStatus(AntaTest): @@ -167,8 +163,6 @@ class VerifyTelnetStatus(AntaTest): ``` """ - name = "VerifyTelnetStatus" - description = "Verifies if Telnet is disabled in the default VRF." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management telnet", revision=1)] @@ -198,8 +192,6 @@ class VerifyAPIHttpStatus(AntaTest): ``` """ - name = "VerifyAPIHttpStatus" - description = "Verifies if eAPI HTTP server is disabled globally." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)] @@ -214,7 +206,7 @@ def test(self) -> None: class VerifyAPIHttpsSSL(AntaTest): - """Verifies if eAPI HTTPS server SSL profile is configured and valid. + """Verifies if the eAPI has a valid SSL profile. Expected Results ---------------- @@ -230,8 +222,6 @@ class VerifyAPIHttpsSSL(AntaTest): ``` """ - name = "VerifyAPIHttpsSSL" - description = "Verifies if the eAPI has a valid SSL profile." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)] @@ -249,10 +239,10 @@ def test(self) -> None: if command_output["sslProfile"]["name"] == self.inputs.profile and command_output["sslProfile"]["state"] == "valid": self.result.is_success() else: - self.result.is_failure(f"eAPI HTTPS server SSL profile ({self.inputs.profile}) is misconfigured or invalid") + self.result.is_failure(f"eAPI HTTPS server SSL profile {self.inputs.profile} is misconfigured or invalid") except KeyError: - self.result.is_failure(f"eAPI HTTPS server SSL profile ({self.inputs.profile}) is not configured") + self.result.is_failure(f"eAPI HTTPS server SSL profile {self.inputs.profile} is not configured") class VerifyAPIIPv4Acl(AntaTest): @@ -273,8 +263,6 @@ class VerifyAPIIPv4Acl(AntaTest): ``` """ - name = "VerifyAPIIPv4Acl" - description = "Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ip access-list summary", revision=1)] @@ -293,13 +281,13 @@ def test(self) -> None: ipv4_acl_list = command_output["ipAclList"]["aclList"] ipv4_acl_number = len(ipv4_acl_list) if ipv4_acl_number != self.inputs.number: - self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}") + self.result.is_failure(f"VRF: {self.inputs.vrf} - eAPI IPv4 ACL(s) count mismatch - Expected: {self.inputs.number} Actual: {ipv4_acl_number}") return not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]] if not_configured_acl: - self.result.is_failure(f"eAPI IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}") + self.result.is_failure(f"VRF: {self.inputs.vrf} - Following eAPI IPv4 ACL(s) not configured or active: {', '.join(not_configured_acl)}") else: self.result.is_success() @@ -323,8 +311,6 @@ class VerifyAPIIPv6Acl(AntaTest): ``` """ - name = "VerifyAPIIPv6Acl" - description = "Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ipv6 access-list summary", revision=1)] @@ -343,13 +329,13 @@ def test(self) -> None: ipv6_acl_list = command_output["ipv6AclList"]["aclList"] ipv6_acl_number = len(ipv6_acl_list) if ipv6_acl_number != self.inputs.number: - self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}") + self.result.is_failure(f"VRF: {self.inputs.vrf} - eAPI IPv6 ACL(s) count mismatch - Expected: {self.inputs.number} Actual: {ipv6_acl_number}") return not_configured_acl = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]] if not_configured_acl: - self.result.is_failure(f"eAPI IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}") + self.result.is_failure(f"VRF: {self.inputs.vrf} - Following eAPI IPv6 ACL(s) not configured or active: {', '.join(not_configured_acl)}") else: self.result.is_success() @@ -357,12 +343,25 @@ def test(self) -> None: class VerifyAPISSLCertificate(AntaTest): """Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size. + This test performs the following checks for each certificate: + + 1. Validates that the certificate is not expired and meets the configured expiry threshold. + 2. Validates that the certificate Common Name matches the expected one. + 3. Ensures the certificate uses the specified encryption algorithm. + 4. Verifies the certificate key matches the expected key size. + Expected Results ---------------- - * Success: The test will pass if the certificate's expiry date is greater than the threshold, - and the certificate has the correct name, encryption algorithm, and key size. - * Failure: The test will fail if the certificate is expired or is going to expire, - or if the certificate has an incorrect name, encryption algorithm, or key size. + * Success: If all of the following occur: + - The certificate's expiry date exceeds the configured threshold. + - The certificate's Common Name matches the input configuration. + - The encryption algorithm used by the certificate is as expected. + - The key size of the certificate matches the input configuration. + * Failure: If any of the following occur: + - The certificate is expired or set to expire within the defined threshold. + - The certificate's common name does not match the expected input. + - The encryption algorithm is incorrect. + - The key size does not match the expected input. Examples -------- @@ -383,8 +382,6 @@ class VerifyAPISSLCertificate(AntaTest): ``` """ - name = "VerifyAPISSLCertificate" - description = "Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="show management security ssl certificate", revision=1), @@ -396,38 +393,7 @@ class Input(AntaTest.Input): certificates: list[APISSLCertificate] """List of API SSL certificates.""" - - class APISSLCertificate(BaseModel): - """Model for an API SSL certificate.""" - - certificate_name: str - """The name of the certificate to be verified.""" - expiry_threshold: int - """The expiry threshold of the certificate in days.""" - common_name: str - """The common subject name of the certificate.""" - encryption_algorithm: EncryptionAlgorithm - """The encryption algorithm of the certificate.""" - key_size: RsaKeySize | EcdsaKeySize - """The encryption algorithm key size of the certificate.""" - - @model_validator(mode="after") - def validate_inputs(self: BaseModel) -> BaseModel: - """Validate the key size provided to the APISSLCertificates class. - - If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}. - - If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}. - """ - if self.encryption_algorithm == "RSA" and self.key_size not in RsaKeySize.__args__: - msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {RsaKeySize.__args__}." - raise ValueError(msg) - - if self.encryption_algorithm == "ECDSA" and self.key_size not in EcdsaKeySize.__args__: - msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {EcdsaKeySize.__args__}." - raise ValueError(msg) - - return self + APISSLCertificate: ClassVar[type[APISSLCertificate]] = APISSLCertificate @AntaTest.anta_test def test(self) -> None: @@ -445,7 +411,7 @@ def test(self) -> None: # Collecting certificate expiry time and current EOS time. # These times are used to calculate the number of days until the certificate expires. if not (certificate_data := get_value(certificate_output, f"certificates..{certificate.certificate_name}", separator="..")): - self.result.is_failure(f"SSL certificate '{certificate.certificate_name}', is not configured.\n") + self.result.is_failure(f"{certificate} - Not found") continue expiry_time = certificate_data["notAfter"] @@ -453,24 +419,25 @@ def test(self) -> None: # Verify certificate expiry if 0 < day_difference < certificate.expiry_threshold: - self.result.is_failure(f"SSL certificate `{certificate.certificate_name}` is about to expire in {day_difference} days.\n") + self.result.is_failure( + f"{certificate} - set to expire within the threshold - Threshold: {certificate.expiry_threshold} days Actual: {day_difference} days" + ) elif day_difference < 0: - self.result.is_failure(f"SSL certificate `{certificate.certificate_name}` is expired.\n") + self.result.is_failure(f"{certificate} - certificate expired") # Verify certificate common subject name, encryption algorithm and key size - keys_to_verify = ["subject.commonName", "publicKey.encryptionAlgorithm", "publicKey.size"] - actual_certificate_details = {key: get_value(certificate_data, key) for key in keys_to_verify} + common_name = get_value(certificate_data, "subject.commonName", default="Not found") + encryp_algo = get_value(certificate_data, "publicKey.encryptionAlgorithm", default="Not found") + key_size = get_value(certificate_data, "publicKey.size", default="Not found") - expected_certificate_details = { - "subject.commonName": certificate.common_name, - "publicKey.encryptionAlgorithm": certificate.encryption_algorithm, - "publicKey.size": certificate.key_size, - } + if common_name != certificate.common_name: + self.result.is_failure(f"{certificate} - incorrect common name - Expected: {certificate.common_name} Actual: {common_name}") + + if encryp_algo != certificate.encryption_algorithm: + self.result.is_failure(f"{certificate} - incorrect encryption algorithm - Expected: {certificate.encryption_algorithm} Actual: {encryp_algo}") - if actual_certificate_details != expected_certificate_details: - failed_log = f"SSL certificate `{certificate.certificate_name}` is not configured properly:" - failed_log += get_failed_logs(expected_certificate_details, actual_certificate_details) - self.result.is_failure(f"{failed_log}\n") + if key_size != certificate.key_size: + self.result.is_failure(f"{certificate} - incorrect public key - Expected: {certificate.key_size} Actual: {key_size}") class VerifyBannerLogin(AntaTest): @@ -486,15 +453,13 @@ class VerifyBannerLogin(AntaTest): ```yaml anta.tests.security: - VerifyBannerLogin: - login_banner: | - # Copyright (c) 2023-2024 Arista Networks, Inc. - # Use of this source code is governed by the Apache License 2.0 - # that can be found in the LICENSE file. + login_banner: | + # Copyright (c) 2023-2024 Arista Networks, Inc. + # Use of this source code is governed by the Apache License 2.0 + # that can be found in the LICENSE file. ``` """ - name = "VerifyBannerLogin" - description = "Verifies the login banner of a device." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner login", revision=1)] @@ -507,14 +472,15 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBannerLogin.""" - login_banner = self.instance_commands[0].json_output["loginBanner"] + self.result.is_success() + if not (login_banner := self.instance_commands[0].json_output["loginBanner"]): + self.result.is_failure("Login banner is not configured") + return # Remove leading and trailing whitespaces from each line cleaned_banner = "\n".join(line.strip() for line in self.inputs.login_banner.split("\n")) if login_banner != cleaned_banner: - self.result.is_failure(f"Expected `{cleaned_banner}` as the login banner, but found `{login_banner}` instead.") - else: - self.result.is_success() + self.result.is_failure(f"Incorrect login banner configured - Expected: {cleaned_banner} Actual: {login_banner}") class VerifyBannerMotd(AntaTest): @@ -530,15 +496,13 @@ class VerifyBannerMotd(AntaTest): ```yaml anta.tests.security: - VerifyBannerMotd: - motd_banner: | - # Copyright (c) 2023-2024 Arista Networks, Inc. - # Use of this source code is governed by the Apache License 2.0 - # that can be found in the LICENSE file. + motd_banner: | + # Copyright (c) 2023-2024 Arista Networks, Inc. + # Use of this source code is governed by the Apache License 2.0 + # that can be found in the LICENSE file. ``` """ - name = "VerifyBannerMotd" - description = "Verifies the motd banner of a device." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner motd", revision=1)] @@ -551,23 +515,34 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBannerMotd.""" - motd_banner = self.instance_commands[0].json_output["motd"] + self.result.is_success() + if not (motd_banner := self.instance_commands[0].json_output["motd"]): + self.result.is_failure("MOTD banner is not configured") + return # Remove leading and trailing whitespaces from each line cleaned_banner = "\n".join(line.strip() for line in self.inputs.motd_banner.split("\n")) if motd_banner != cleaned_banner: - self.result.is_failure(f"Expected `{cleaned_banner}` as the motd banner, but found `{motd_banner}` instead.") - else: - self.result.is_success() + self.result.is_failure(f"Incorrect MOTD banner configured - Expected: {cleaned_banner} Actual: {motd_banner}") class VerifyIPv4ACL(AntaTest): """Verifies the configuration of IPv4 ACLs. + This test performs the following checks for each IPv4 ACL: + + 1. Validates that the IPv4 ACL is properly configured. + 2. Validates that the sequence entries in the ACL are correctly ordered. + Expected Results ---------------- - * Success: The test will pass if an IPv4 ACL is configured with the correct sequence entries. - * Failure: The test will fail if an IPv4 ACL is not configured or entries are not in sequence. + * Success: If all of the following occur: + - Any IPv4 ACL entry is not configured. + - The sequency entries are correctly configured. + * Failure: If any of the following occur: + - The IPv4 ACL is not configured. + - The any IPv4 ACL entry is not configured. + - The action for any entry does not match the expected input. Examples -------- @@ -592,73 +567,42 @@ class VerifyIPv4ACL(AntaTest): ``` """ - name = "VerifyIPv4ACL" - description = "Verifies the configuration of IPv4 ACLs." categories: ClassVar[list[str]] = ["security"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip access-lists {acl}", revision=1)] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip access-lists", revision=1)] class Input(AntaTest.Input): """Input model for the VerifyIPv4ACL test.""" - ipv4_access_lists: list[IPv4ACL] + ipv4_access_lists: list[ACL] """List of IPv4 ACLs to verify.""" - - class IPv4ACL(BaseModel): - """Model for an IPv4 ACL.""" - - name: str - """Name of IPv4 ACL.""" - - entries: list[IPv4ACLEntry] - """List of IPv4 ACL entries.""" - - class IPv4ACLEntry(BaseModel): - """Model for an IPv4 ACL entry.""" - - sequence: int = Field(ge=1, le=4294967295) - """Sequence number of an ACL entry.""" - action: str - """Action of an ACL entry.""" - - def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Render the template for each input ACL.""" - return [template.render(acl=acl.name) for acl in self.inputs.ipv4_access_lists] + IPv4ACL: ClassVar[type[ACL]] = ACL + """To maintain backward compatibility.""" @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyIPv4ACL.""" self.result.is_success() - for command_output, acl in zip(self.instance_commands, self.inputs.ipv4_access_lists): - # Collecting input ACL details - acl_name = command_output.params.acl - # Retrieve the expected entries from the inputs - acl_entries = acl.entries - - # Check if ACL is configured - ipv4_acl_list = command_output.json_output["aclList"] - if not ipv4_acl_list: - self.result.is_failure(f"{acl_name}: Not found") + + if not (command_output := self.instance_commands[0].json_output["aclList"]): + self.result.is_failure("No Access Control List (ACL) configured") + return + + for access_list in self.inputs.ipv4_access_lists: + if not (access_list_output := get_item(command_output, "name", access_list.name)): + self.result.is_failure(f"{access_list} - Not configured") continue - # Check if the sequence number is configured and has the correct action applied - failed_log = f"{acl_name}:\n" - for acl_entry in acl_entries: - acl_seq = acl_entry.sequence - acl_action = acl_entry.action - if (actual_entry := get_item(ipv4_acl_list[0]["sequence"], "sequenceNumber", acl_seq)) is None: - failed_log += f"Sequence number `{acl_seq}` is not found.\n" + for entry in access_list.entries: + if not (actual_entry := get_item(access_list_output["sequence"], "sequenceNumber", entry.sequence)): + self.result.is_failure(f"{access_list} {entry} - Not configured") continue - if actual_entry["text"] != acl_action: - failed_log += f"Expected `{acl_action}` as sequence number {acl_seq} action but found `{actual_entry['text']}` instead.\n" - - if failed_log != f"{acl_name}:\n": - self.result.is_failure(f"{failed_log}") + if (act_action := actual_entry["text"]) != entry.action: + self.result.is_failure(f"{access_list} {entry} - action mismatch - Expected: {entry.action} Actual: {act_action}") class VerifyIPSecConnHealth(AntaTest): - """ - Verifies all IPv4 security connections. + """Verifies all IPv4 security connections. Expected Results ---------------- @@ -673,8 +617,6 @@ class VerifyIPSecConnHealth(AntaTest): ``` """ - name = "VerifyIPSecConnHealth" - description = "Verifies all IPv4 security connections." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip security connection vrf all")] @@ -682,12 +624,11 @@ class VerifyIPSecConnHealth(AntaTest): def test(self) -> None: """Main test function for VerifyIPSecConnHealth.""" self.result.is_success() - failure_conn = [] command_output = self.instance_commands[0].json_output["connections"] # Check if IP security connection is configured if not command_output: - self.result.is_failure("No IPv4 security connection configured.") + self.result.is_failure("No IPv4 security connection configured") return # Iterate over all ipsec connections @@ -697,23 +638,26 @@ def test(self) -> None: source = conn_data.get("saddr") destination = conn_data.get("daddr") vrf = conn_data.get("tunnelNs") - failure_conn.append(f"source:{source} destination:{destination} vrf:{vrf}") - if failure_conn: - failure_msg = "\n".join(failure_conn) - self.result.is_failure(f"The following IPv4 security connections are not established:\n{failure_msg}.") + self.result.is_failure(f"Source: {source} Destination: {destination} VRF: {vrf} - IPv4 security connection not established") class VerifySpecificIPSecConn(AntaTest): - """ - Verifies the state of IPv4 security connections for a specified peer. + """Verifies the IPv4 security connections. - It optionally allows for the verification of a specific path for a peer by providing source and destination addresses. - If these addresses are not provided, it will verify all paths for the specified peer. + This test performs the following checks for each peer: + + 1. Validates that the VRF is configured. + 2. Checks for the presence of IPv4 security connections for the specified peer. + 3. For each relevant peer: + - If source and destination addresses are provided, verifies the security connection for the specific path exists and is `Established`. + - If no addresses are provided, verifies that all security connections associated with the peer are `Established`. Expected Results ---------------- - * Success: The test passes if the IPv4 security connection for a peer is established in the specified VRF. - * Failure: The test fails if IPv4 security is not configured, a connection is not found for a peer, or the connection is not established in the specified VRF. + * Success: If all checks pass for all specified IPv4 security connections. + * Failure: If any of the following occur: + - No IPv4 security connections are found for the peer + - The security connection is not established for the specified path or any of the peer connections is not established when no path is specified. Examples -------- @@ -732,36 +676,16 @@ class VerifySpecificIPSecConn(AntaTest): ``` """ - name = "VerifySpecificIPSecConn" - description = "Verifies IPv4 security connections for a peer." categories: ClassVar[list[str]] = ["security"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip security connection vrf {vrf} path peer {peer}")] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip security connection vrf {vrf} path peer {peer}", revision=2)] class Input(AntaTest.Input): """Input model for the VerifySpecificIPSecConn test.""" - ip_security_connections: list[IPSecPeers] + ip_security_connections: list[IPSecPeer] """List of IP4v security peers.""" - - class IPSecPeers(BaseModel): - """Details of IPv4 security peers.""" - - peer: IPv4Address - """IPv4 address of the peer.""" - - vrf: str = "default" - """Optional VRF for the IP security peer.""" - - connections: list[IPSecConn] | None = None - """Optional list of IPv4 security connections of a peer.""" - - class IPSecConn(BaseModel): - """Details of IPv4 security connections for a peer.""" - - source_address: IPv4Address - """Source IPv4 address of the connection.""" - destination_address: IPv4Address - """Destination IPv4 address of the connection.""" + IPSecPeers: ClassVar[type[IPSecPeers]] = IPSecPeers + """To maintain backward compatibility.""" def render(self, template: AntaTemplate) -> list[AntaCommand]: """Render the template for each input IP Sec connection.""" @@ -771,15 +695,15 @@ def render(self, template: AntaTemplate) -> list[AntaCommand]: def test(self) -> None: """Main test function for VerifySpecificIPSecConn.""" self.result.is_success() + for command_output, input_peer in zip(self.instance_commands, self.inputs.ip_security_connections): conn_output = command_output.json_output["connections"] - peer = command_output.params.peer - vrf = command_output.params.vrf conn_input = input_peer.connections + vrf = input_peer.vrf # Check if IPv4 security connection is configured if not conn_output: - self.result.is_failure(f"No IPv4 security connection configured for peer `{peer}`.") + self.result.is_failure(f"{input_peer} - Not configured") continue # If connection details are not provided then check all connections of a peer @@ -789,11 +713,7 @@ def test(self) -> None: if state != "Established": source = conn_data.get("saddr") destination = conn_data.get("daddr") - vrf = conn_data.get("tunnelNs") - self.result.is_failure( - f"Expected state of IPv4 security connection `source:{source} destination:{destination} vrf:{vrf}` for peer `{peer}` is `Established` " - f"but found `{state}` instead." - ) + self.result.is_failure(f"{input_peer} Source: {source} Destination: {destination} - Connection down - Expected: Established Actual: {state}") continue # Create a dictionary of existing connections for faster lookup @@ -808,11 +728,38 @@ def test(self) -> None: if (source_input, destination_input, vrf) in existing_connections: existing_state = existing_connections[(source_input, destination_input, vrf)] if existing_state != "Established": - self.result.is_failure( - f"Expected state of IPv4 security connection `source:{source_input} destination:{destination_input} vrf:{vrf}` " - f"for peer `{peer}` is `Established` but found `{existing_state}` instead." - ) + failure = f"Expected: Established Actual: {existing_state}" + self.result.is_failure(f"{input_peer} Source: {source_input} Destination: {destination_input} - Connection down - {failure}") else: - self.result.is_failure( - f"IPv4 security connection `source:{source_input} destination:{destination_input} vrf:{vrf}` for peer `{peer}` is not found." - ) + self.result.is_failure(f"{input_peer} Source: {source_input} Destination: {destination_input} - Connection not found.") + + +class VerifyHardwareEntropy(AntaTest): + """Verifies hardware entropy generation is enabled on device. + + Expected Results + ---------------- + * Success: The test will pass if hardware entropy generation is enabled. + * Failure: The test will fail if hardware entropy generation is not enabled. + + Examples + -------- + ```yaml + anta.tests.security: + - VerifyHardwareEntropy: + ``` + """ + + categories: ClassVar[list[str]] = ["security"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management security")] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyHardwareEntropy.""" + command_output = self.instance_commands[0].json_output + + # Check if hardware entropy generation is enabled. + if not command_output.get("hardwareEntropyEnabled"): + self.result.is_failure("Hardware entropy generation is disabled") + else: + self.result.is_success() diff --git a/anta/tests/services.py b/anta/tests/services.py index 618426350..a2b09da3b 100644 --- a/anta/tests/services.py +++ b/anta/tests/services.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to the EOS various services tests.""" @@ -7,14 +7,11 @@ # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined -from ipaddress import IPv4Address, IPv6Address from typing import ClassVar -from pydantic import BaseModel, Field - -from anta.custom_types import ErrDisableInterval, ErrDisableReasons +from anta.input_models.services import DnsServer, ErrDisableReason, ErrdisableRecovery from anta.models import AntaCommand, AntaTemplate, AntaTest -from anta.tools import get_dict_superset, get_failed_logs, get_item +from anta.tools import get_dict_superset, get_item class VerifyHostname(AntaTest): @@ -34,8 +31,6 @@ class VerifyHostname(AntaTest): ``` """ - name = "VerifyHostname" - description = "Verifies the hostname of a device." categories: ClassVar[list[str]] = ["services"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hostname", revision=1)] @@ -51,7 +46,7 @@ def test(self) -> None: hostname = self.instance_commands[0].json_output["hostname"] if hostname != self.inputs.hostname: - self.result.is_failure(f"Expected `{self.inputs.hostname}` as the hostname, but found `{hostname}` instead.") + self.result.is_failure(f"Incorrect Hostname - Expected: {self.inputs.hostname} Actual: {hostname}") else: self.result.is_success() @@ -77,7 +72,6 @@ class VerifyDNSLookup(AntaTest): ``` """ - name = "VerifyDNSLookup" description = "Verifies the DNS name to IP address resolution." categories: ClassVar[list[str]] = ["services"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="bash timeout 10 nslookup {domain}", revision=1)] @@ -109,10 +103,17 @@ def test(self) -> None: class VerifyDNSServers(AntaTest): """Verifies if the DNS (Domain Name Service) servers are correctly configured. + This test performs the following checks for each specified DNS Server: + + 1. Confirming correctly registered with a valid IPv4 or IPv6 address with the designated VRF. + 2. Ensuring an appropriate priority level. + Expected Results ---------------- * Success: The test will pass if the DNS server specified in the input is configured with the correct VRF and priority. - * Failure: The test will fail if the DNS server is not configured or if the VRF and priority of the DNS server do not match the input. + * Failure: The test will fail if any of the following conditions are met: + - The provided DNS server is not configured. + - The provided DNS server with designated VRF and priority does not match the expected information. Examples -------- @@ -129,8 +130,6 @@ class VerifyDNSServers(AntaTest): ``` """ - name = "VerifyDNSServers" - description = "Verifies if the DNS servers are correctly configured." categories: ClassVar[list[str]] = ["services"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip name-server", revision=1)] @@ -139,47 +138,49 @@ class Input(AntaTest.Input): dns_servers: list[DnsServer] """List of DNS servers to verify.""" - - class DnsServer(BaseModel): - """Model for a DNS server.""" - - server_address: IPv4Address | IPv6Address - """The IPv4/IPv6 address of the DNS server.""" - vrf: str = "default" - """The VRF for the DNS server. Defaults to 'default' if not provided.""" - priority: int = Field(ge=0, le=4) - """The priority of the DNS server from 0 to 4, lower is first.""" + DnsServer: ClassVar[type[DnsServer]] = DnsServer @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyDNSServers.""" - command_output = self.instance_commands[0].json_output["nameServerConfigs"] self.result.is_success() + + command_output = self.instance_commands[0].json_output["nameServerConfigs"] for server in self.inputs.dns_servers: address = str(server.server_address) vrf = server.vrf priority = server.priority input_dict = {"ipAddr": address, "vrf": vrf} - if get_item(command_output, "ipAddr", address) is None: - self.result.is_failure(f"DNS server `{address}` is not configured with any VRF.") - continue - + # Check if the DNS server is configured with specified VRF. if (output := get_dict_superset(command_output, input_dict)) is None: - self.result.is_failure(f"DNS server `{address}` is not configured with VRF `{vrf}`.") + self.result.is_failure(f"{server} - Not configured") continue + # Check if the DNS server priority matches with expected. if output["priority"] != priority: - self.result.is_failure(f"For DNS server `{address}`, the expected priority is `{priority}`, but `{output['priority']}` was found instead.") + self.result.is_failure(f"{server} - Incorrect priority - Priority: {output['priority']}") class VerifyErrdisableRecovery(AntaTest): - """Verifies the errdisable recovery reason, status, and interval. + """Verifies the error disable recovery functionality. + + This test performs the following checks for each specified error disable reason: + + 1. Verifying if the specified error disable reason exists. + 2. Checking if the recovery timer status matches the expected enabled/disabled state. + 3. Validating that the timer interval matches the configured value. Expected Results ---------------- - * Success: The test will pass if the errdisable recovery reason status is enabled and the interval matches the input. - * Failure: The test will fail if the errdisable recovery reason is not found, the status is not enabled, or the interval does not match the input. + * Success: The test will pass if: + - The specified error disable reason exists. + - The recovery timer status matches the expected state. + - The timer interval matches the configured value. + * Failure: The test will fail if: + - The specified error disable reason does not exist. + - The recovery timer status does not match the expected state. + - The timer interval does not match the configured value. Examples -------- @@ -189,13 +190,13 @@ class VerifyErrdisableRecovery(AntaTest): reasons: - reason: acl interval: 30 + status: Enabled - reason: bpduguard interval: 30 + status: Enabled ``` """ - name = "VerifyErrdisableRecovery" - description = "Verifies the errdisable recovery reason, status, and interval." categories: ClassVar[list[str]] = ["services"] # NOTE: Only `text` output format is supported for this command commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show errdisable recovery", ofmt="text")] @@ -203,44 +204,35 @@ class VerifyErrdisableRecovery(AntaTest): class Input(AntaTest.Input): """Input model for the VerifyErrdisableRecovery test.""" - reasons: list[ErrDisableReason] + reasons: list[ErrdisableRecovery] """List of errdisable reasons.""" - - class ErrDisableReason(BaseModel): - """Model for an errdisable reason.""" - - reason: ErrDisableReasons - """Type or name of the errdisable reason.""" - interval: ErrDisableInterval - """Interval of the reason in seconds.""" + ErrDisableReason: ClassVar[type[ErrdisableRecovery]] = ErrDisableReason @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyErrdisableRecovery.""" - command_output = self.instance_commands[0].text_output self.result.is_success() + + # Skip header and last empty line + command_output = self.instance_commands[0].text_output.split("\n")[2:-1] + + # Collecting the actual errdisable reasons for faster lookup + errdisable_reasons = [ + {"reason": reason, "status": status, "interval": interval} + for line in command_output + if line.strip() # Skip empty lines + for reason, status, interval in [line.split(None, 2)] # Unpack split result + ] + for error_reason in self.inputs.reasons: - input_reason = error_reason.reason - input_interval = error_reason.interval - reason_found = False - - # Skip header and last empty line - lines = command_output.split("\n")[2:-1] - for line in lines: - # Skip empty lines - if not line.strip(): - continue - # Split by first two whitespaces - reason, status, interval = line.split(None, 2) - if reason != input_reason: - continue - reason_found = True - actual_reason_data = {"interval": interval, "status": status} - expected_reason_data = {"interval": str(input_interval), "status": "Enabled"} - if actual_reason_data != expected_reason_data: - failed_log = get_failed_logs(expected_reason_data, actual_reason_data) - self.result.is_failure(f"`{input_reason}`:{failed_log}\n") - break - - if not reason_found: - self.result.is_failure(f"`{input_reason}`: Not found.\n") + if not (reason_output := get_item(errdisable_reasons, "reason", error_reason.reason)): + self.result.is_failure(f"{error_reason} - Not found") + continue + + if not all( + [ + error_reason.status == (act_status := reason_output["status"]), + error_reason.interval == (act_interval := int(reason_output["interval"])), + ] + ): + self.result.is_failure(f"{error_reason} - Incorrect configuration - Status: {act_status} Interval: {act_interval}") diff --git a/anta/tests/snmp.py b/anta/tests/snmp.py index ac98bfd2f..9a9acdeb1 100644 --- a/anta/tests/snmp.py +++ b/anta/tests/snmp.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to the EOS various SNMP tests.""" @@ -7,17 +7,21 @@ # mypy: disable-error-code=attr-defined from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, ClassVar, get_args -from anta.custom_types import PositiveInteger +from pydantic import field_validator + +from anta.custom_types import PositiveInteger, SnmpErrorCounter, SnmpPdu +from anta.input_models.snmp import SnmpGroup, SnmpHost, SnmpSourceInterface, SnmpUser from anta.models import AntaCommand, AntaTest +from anta.tools import get_value if TYPE_CHECKING: from anta.models import AntaTemplate class VerifySnmpStatus(AntaTest): - """Verifies whether the SNMP agent is enabled in a specified VRF. + """Verifies if the SNMP agent is enabled. Expected Results ---------------- @@ -33,8 +37,6 @@ class VerifySnmpStatus(AntaTest): ``` """ - name = "VerifySnmpStatus" - description = "Verifies if the SNMP agent is enabled." categories: ClassVar[list[str]] = ["snmp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] @@ -47,15 +49,14 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySnmpStatus.""" + self.result.is_success() command_output = self.instance_commands[0].json_output - if command_output["enabled"] and self.inputs.vrf in command_output["vrfs"]["snmpVrfs"]: - self.result.is_success() - else: - self.result.is_failure(f"SNMP agent disabled in vrf {self.inputs.vrf}") + if not (command_output["enabled"] and self.inputs.vrf in command_output["vrfs"]["snmpVrfs"]): + self.result.is_failure(f"VRF: {self.inputs.vrf} - SNMP agent disabled") class VerifySnmpIPv4Acl(AntaTest): - """Verifies if the SNMP agent has the right number IPv4 ACL(s) configured for a specified VRF. + """Verifies if the SNMP agent has IPv4 ACL(s) configured. Expected Results ---------------- @@ -72,8 +73,6 @@ class VerifySnmpIPv4Acl(AntaTest): ``` """ - name = "VerifySnmpIPv4Acl" - description = "Verifies if the SNMP agent has IPv4 ACL(s) configured." categories: ClassVar[list[str]] = ["snmp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv4 access-list summary", revision=1)] @@ -88,23 +87,22 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySnmpIPv4Acl.""" + self.result.is_success() command_output = self.instance_commands[0].json_output ipv4_acl_list = command_output["ipAclList"]["aclList"] ipv4_acl_number = len(ipv4_acl_list) if ipv4_acl_number != self.inputs.number: - self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}") + self.result.is_failure(f"VRF: {self.inputs.vrf} - Incorrect SNMP IPv4 ACL(s) - Expected: {self.inputs.number} Actual: {ipv4_acl_number}") return not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]] if not_configured_acl: - self.result.is_failure(f"SNMP IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}") - else: - self.result.is_success() + self.result.is_failure(f"VRF: {self.inputs.vrf} - Following SNMP IPv4 ACL(s) not configured or active: {', '.join(not_configured_acl)}") class VerifySnmpIPv6Acl(AntaTest): - """Verifies if the SNMP agent has the right number IPv6 ACL(s) configured for a specified VRF. + """Verifies if the SNMP agent has IPv6 ACL(s) configured. Expected Results ---------------- @@ -121,8 +119,6 @@ class VerifySnmpIPv6Acl(AntaTest): ``` """ - name = "VerifySnmpIPv6Acl" - description = "Verifies if the SNMP agent has IPv6 ACL(s) configured." categories: ClassVar[list[str]] = ["snmp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv6 access-list summary", revision=1)] @@ -138,18 +134,17 @@ class Input(AntaTest.Input): def test(self) -> None: """Main test function for VerifySnmpIPv6Acl.""" command_output = self.instance_commands[0].json_output + self.result.is_success() ipv6_acl_list = command_output["ipv6AclList"]["aclList"] ipv6_acl_number = len(ipv6_acl_list) if ipv6_acl_number != self.inputs.number: - self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}") + self.result.is_failure(f"VRF: {self.inputs.vrf} - Incorrect SNMP IPv6 ACL(s) - Expected: {self.inputs.number} Actual: {ipv6_acl_number}") return acl_not_configured = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]] if acl_not_configured: - self.result.is_failure(f"SNMP IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {acl_not_configured}") - else: - self.result.is_success() + self.result.is_failure(f"VRF: {self.inputs.vrf} - Following SNMP IPv6 ACL(s) not configured or active: {', '.join(acl_not_configured)}") class VerifySnmpLocation(AntaTest): @@ -169,8 +164,6 @@ class VerifySnmpLocation(AntaTest): ``` """ - name = "VerifySnmpLocation" - description = "Verifies the SNMP location of a device." categories: ClassVar[list[str]] = ["snmp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] @@ -183,12 +176,15 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySnmpLocation.""" - location = self.instance_commands[0].json_output["location"]["location"] + self.result.is_success() + # Verifies the SNMP location is configured. + if not (location := get_value(self.instance_commands[0].json_output, "location.location")): + self.result.is_failure("SNMP location is not configured") + return + # Verifies the expected SNMP location. if location != self.inputs.location: - self.result.is_failure(f"Expected `{self.inputs.location}` as the location, but found `{location}` instead.") - else: - self.result.is_success() + self.result.is_failure(f"Incorrect SNMP location - Expected: {self.inputs.location} Actual: {location}") class VerifySnmpContact(AntaTest): @@ -208,8 +204,6 @@ class VerifySnmpContact(AntaTest): ``` """ - name = "VerifySnmpContact" - description = "Verifies the SNMP contact of a device." categories: ClassVar[list[str]] = ["snmp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] @@ -222,9 +216,511 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySnmpContact.""" - contact = self.instance_commands[0].json_output["contact"]["contact"] + self.result.is_success() + # Verifies the SNMP contact is configured. + if not (contact := get_value(self.instance_commands[0].json_output, "contact.contact")): + self.result.is_failure("SNMP contact is not configured") + return + # Verifies the expected SNMP contact. if contact != self.inputs.contact: - self.result.is_failure(f"Expected `{self.inputs.contact}` as the contact, but found `{contact}` instead.") - else: - self.result.is_success() + self.result.is_failure(f"Incorrect SNMP contact - Expected: {self.inputs.contact} Actual: {contact}") + + +class VerifySnmpPDUCounters(AntaTest): + """Verifies the SNMP PDU counters. + + By default, all SNMP PDU counters will be checked for any non-zero values. + An optional list of specific SNMP PDU(s) can be provided for granular testing. + + Expected Results + ---------------- + * Success: The test will pass if the SNMP PDU counter(s) are non-zero/greater than zero. + * Failure: The test will fail if the SNMP PDU counter(s) are zero/None/Not Found. + + Examples + -------- + ```yaml + anta.tests.snmp: + - VerifySnmpPDUCounters: + pdus: + - outTrapPdus + - inGetNextPdus + ``` + """ + + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifySnmpPDUCounters test.""" + + pdus: list[SnmpPdu] | None = None + """Optional list of SNMP PDU counters to be verified. If not provided, test will verifies all PDU counters.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifySnmpPDUCounters.""" + self.result.is_success() + snmp_pdus = self.inputs.pdus + command_output = self.instance_commands[0].json_output + + # Verify SNMP PDU counters. + if not (pdu_counters := get_value(command_output, "counters")): + self.result.is_failure("SNMP counters not found") + return + + # In case SNMP PDUs not provided, It will check all the update error counters. + if not snmp_pdus: + snmp_pdus = list(get_args(SnmpPdu)) + + failures = {pdu for pdu in snmp_pdus if (value := pdu_counters.get(pdu, "Not Found")) == "Not Found" or value == 0} + + # Check if any failures + if failures: + self.result.is_failure(f"The following SNMP PDU counters are not found or have zero PDU counters: {', '.join(sorted(failures))}") + + +class VerifySnmpErrorCounters(AntaTest): + """Verifies the SNMP error counters. + + By default, all error counters will be checked for any non-zero values. + An optional list of specific error counters can be provided for granular testing. + + Expected Results + ---------------- + * Success: The test will pass if the SNMP error counter(s) are zero/None. + * Failure: The test will fail if the SNMP error counter(s) are non-zero/not None/Not Found or is not configured. + + Examples + -------- + ```yaml + anta.tests.snmp: + - VerifySnmpErrorCounters: + error_counters: + - inVersionErrs + - inBadCommunityNames + """ + + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifySnmpErrorCounters test.""" + + error_counters: list[SnmpErrorCounter] | None = None + """Optional list of SNMP error counters to be verified. If not provided, test will verifies all error counters.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifySnmpErrorCounters.""" + self.result.is_success() + error_counters = self.inputs.error_counters + command_output = self.instance_commands[0].json_output + + # Verify SNMP PDU counters. + if not (snmp_counters := get_value(command_output, "counters")): + self.result.is_failure("SNMP counters not found") + return + + # In case SNMP error counters not provided, It will check all the error counters. + if not error_counters: + error_counters = list(get_args(SnmpErrorCounter)) + + error_counters_not_ok = {counter for counter in error_counters if snmp_counters.get(counter)} + + # Check if any failures + if error_counters_not_ok: + self.result.is_failure(f"The following SNMP error counters are not found or have non-zero error counters: {', '.join(sorted(error_counters_not_ok))}") + + +class VerifySnmpHostLogging(AntaTest): + """Verifies SNMP logging configurations. + + This test performs the following checks: + + 1. SNMP logging is enabled globally. + 2. For each specified SNMP host: + - Host exists in configuration. + - Host's VRF assignment matches expected value. + + Expected Results + ---------------- + * Success: The test will pass if all of the following conditions are met: + - SNMP logging is enabled on the device. + - All specified hosts are configured with correct VRF assignments. + * Failure: The test will fail if any of the following conditions is met: + - SNMP logging is disabled on the device. + - SNMP host not found in configuration. + - Host's VRF assignment doesn't match expected value. + + Examples + -------- + ```yaml + anta.tests.snmp: + - VerifySnmpHostLogging: + hosts: + - hostname: 192.168.1.100 + vrf: default + - hostname: 192.168.1.103 + vrf: MGMT + ``` + """ + + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifySnmpHostLogging test.""" + + hosts: list[SnmpHost] + """List of SNMP hosts.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifySnmpHostLogging.""" + self.result.is_success() + + command_output = self.instance_commands[0].json_output.get("logging", {}) + # If SNMP logging is disabled, test fails. + if not command_output.get("loggingEnabled"): + self.result.is_failure("SNMP logging is disabled") + return + + host_details = command_output.get("hosts", {}) + + for host in self.inputs.hosts: + hostname = str(host.hostname) + vrf = host.vrf + actual_snmp_host = host_details.get(hostname, {}) + + # If SNMP host is not configured on the device, test fails. + if not actual_snmp_host: + self.result.is_failure(f"{host} - Not configured") + continue + + # If VRF is not matches the expected value, test fails. + actual_vrf = "default" if (vrf_name := actual_snmp_host.get("vrf")) == "" else vrf_name + if actual_vrf != vrf: + self.result.is_failure(f"{host} - Incorrect VRF - Actual: {actual_vrf}") + + +class VerifySnmpUser(AntaTest): + """Verifies the SNMP user configurations. + + This test performs the following checks for each specified user: + + 1. User exists in SNMP configuration. + 2. Group assignment is correct. + 3. For SNMPv3 users only: + - Authentication type matches (if specified) + - Privacy type matches (if specified) + + Expected Results + ---------------- + * Success: If all of the following conditions are met: + - All users exist with correct group assignments. + - SNMPv3 authentication and privacy types match specified values. + * Failure: If any of the following occur: + - User not found in SNMP configuration. + - Incorrect group assignment. + - For SNMPv3: Mismatched authentication or privacy types. + + Examples + -------- + ```yaml + anta.tests.snmp: + - VerifySnmpUser: + snmp_users: + - username: test + group_name: test_group + version: v3 + auth_type: MD5 + priv_type: AES-128 + ``` + """ + + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp user", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifySnmpUser test.""" + + snmp_users: list[SnmpUser] + """List of SNMP users.""" + + @field_validator("snmp_users") + @classmethod + def validate_snmp_users(cls, snmp_users: list[SnmpUser]) -> list[SnmpUser]: + """Validate that 'auth_type' or 'priv_type' field is provided in each SNMPv3 user.""" + for user in snmp_users: + if user.version == "v3" and not (user.auth_type or user.priv_type): + msg = f"{user} 'auth_type' or 'priv_type' field is required with 'version: v3'" + raise ValueError(msg) + return snmp_users + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifySnmpUser.""" + self.result.is_success() + + for user in self.inputs.snmp_users: + # Verify SNMP user details. + if not (user_details := get_value(self.instance_commands[0].json_output, f"usersByVersion.{user.version}.users.{user.username}")): + self.result.is_failure(f"{user} - Not found") + continue + + if user.group_name != (act_group := user_details.get("groupName", "Not Found")): + self.result.is_failure(f"{user} - Incorrect user group - Actual: {act_group}") + + if user.version == "v3": + if user.auth_type and (act_auth_type := get_value(user_details, "v3Params.authType", "Not Found")) != user.auth_type: + self.result.is_failure(f"{user} - Incorrect authentication type - Expected: {user.auth_type} Actual: {act_auth_type}") + + if user.priv_type and (act_encryption := get_value(user_details, "v3Params.privType", "Not Found")) != user.priv_type: + self.result.is_failure(f"{user} - Incorrect privacy type - Expected: {user.priv_type} Actual: {act_encryption}") + + +class VerifySnmpNotificationHost(AntaTest): + """Verifies the SNMP notification host(s) (SNMP manager) configurations. + + This test performs the following checks for each specified host: + + 1. Verifies that the SNMP host(s) is configured on the device. + 2. Verifies that the notification type ("trap" or "inform") matches the expected value. + 3. Ensures that UDP port provided matches the expected value. + 4. Ensures the following depending on SNMP version: + - For SNMP version v1/v2c, a valid community string is set and matches the expected value. + - For SNMP version v3, a valid user field is set and matches the expected value. + + Expected Results + ---------------- + * Success: The test will pass if all of the following conditions are met: + - The SNMP host(s) is configured on the device. + - The notification type ("trap" or "inform") and UDP port match the expected value. + - Ensures the following depending on SNMP version: + - For SNMP version v1/v2c, a community string is set and it matches the expected value. + - For SNMP version v3, a valid user field is set and matches the expected value. + * Failure: The test will fail if any of the following conditions is met: + - The SNMP host(s) is not configured on the device. + - The notification type ("trap" or "inform") or UDP port do not matches the expected value. + - Ensures the following depending on SNMP version: + - For SNMP version v1/v2c, a community string is not matches the expected value. + - For SNMP version v3, an user field is not matches the expected value. + + Examples + -------- + ```yaml + anta.tests.snmp: + - VerifySnmpNotificationHost: + notification_hosts: + - hostname: spine + vrf: default + notification_type: trap + version: v1 + udp_port: 162 + community_string: public + - hostname: 192.168.1.100 + vrf: default + notification_type: trap + version: v3 + udp_port: 162 + user: public + ``` + """ + + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp notification host", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifySnmpNotificationHost test.""" + + notification_hosts: list[SnmpHost] + """List of SNMP host(s).""" + + @field_validator("notification_hosts") + @classmethod + def validate_notification_hosts(cls, notification_hosts: list[SnmpHost]) -> list[SnmpHost]: + """Validate that all required fields are provided in each SNMP Notification Host.""" + for host in notification_hosts: + if host.version is None: + msg = f"{host}; 'version' field missing in the input" + raise ValueError(msg) + if host.version in ["v1", "v2c"] and host.community_string is None: + msg = f"{host} Version: {host.version}; 'community_string' field missing in the input" + raise ValueError(msg) + if host.version == "v3" and host.user is None: + msg = f"{host} Version: {host.version}; 'user' field missing in the input" + raise ValueError(msg) + return notification_hosts + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifySnmpNotificationHost.""" + self.result.is_success() + + # If SNMP is not configured, test fails. + if not (snmp_hosts := get_value(self.instance_commands[0].json_output, "hosts")): + self.result.is_failure("No SNMP host is configured") + return + + for host in self.inputs.notification_hosts: + vrf = "" if host.vrf == "default" else host.vrf + hostname = str(host.hostname) + notification_type = host.notification_type + version = host.version + udp_port = host.udp_port + community_string = host.community_string + user = host.user + default_value = "Not Found" + + host_details = next( + (host for host in snmp_hosts if (host.get("hostname") == hostname and host.get("protocolVersion") == version and host.get("vrf") == vrf)), None + ) + # If expected SNMP host is not configured with the specified protocol version, test fails. + if not host_details: + self.result.is_failure(f"{host} Version: {version} - Not configured") + continue + + # If actual notification type does not match the expected value, test fails. + if notification_type != (actual_notification_type := get_value(host_details, "notificationType", default_value)): + self.result.is_failure(f"{host} - Incorrect notification type - Expected: {notification_type} Actual: {actual_notification_type}") + + # If actual UDP port does not match the expected value, test fails. + if udp_port != (actual_udp_port := get_value(host_details, "port", default_value)): + self.result.is_failure(f"{host} - Incorrect UDP port - Expected: {udp_port} Actual: {actual_udp_port}") + + user_found = user != (actual_user := get_value(host_details, "v3Params.user", default_value)) + version_user_check = (version == "v3", user_found) + + # If SNMP protocol version is v1 or v2c and actual community string does not match the expected value, test fails. + if version in ["v1", "v2c"] and community_string != (actual_community_string := get_value(host_details, "v1v2cParams.communityString", default_value)): + self.result.is_failure(f"{host} Version: {version} - Incorrect community string - Expected: {community_string} Actual: {actual_community_string}") + + # If SNMP protocol version is v3 and actual user does not match the expected value, test fails. + elif all(version_user_check): + self.result.is_failure(f"{host} Version: {version} - Incorrect user - Expected: {user} Actual: {actual_user}") + + +class VerifySnmpSourceInterface(AntaTest): + """Verifies SNMP source interfaces. + + This test performs the following checks: + + 1. Verifies that source interface(s) are configured for SNMP. + 2. For each specified source interface: + - Interface is configured in the specified VRF. + + Expected Results + ---------------- + * Success: The test will pass if the provided SNMP source interface(s) are configured in their specified VRF. + * Failure: The test will fail if any of the provided SNMP source interface(s) are NOT configured in their specified VRF. + + Examples + -------- + ```yaml + anta.tests.snmp: + - VerifySnmpSourceInterface: + interfaces: + - interface: Ethernet1 + vrf: default + - interface: Management0 + vrf: MGMT + ``` + """ + + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifySnmpSourceInterface test.""" + + interfaces: list[SnmpSourceInterface] + """List of source interfaces.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifySnmpSourceInterface.""" + self.result.is_success() + command_output = self.instance_commands[0].json_output.get("srcIntf", {}) + + if not (interface_output := command_output.get("sourceInterfaces")): + self.result.is_failure("SNMP source interface(s) not configured") + return + + for interface_details in self.inputs.interfaces: + # If the source interface is not configured, or if it does not match the expected value, the test fails. + if not (actual_interface := interface_output.get(interface_details.vrf)): + self.result.is_failure(f"{interface_details} - Not configured") + elif actual_interface != interface_details.interface: + self.result.is_failure(f"{interface_details} - Incorrect source interface - Actual: {actual_interface}") + + +class VerifySnmpGroup(AntaTest): + """Verifies the SNMP group configurations for specified version(s). + + This test performs the following checks: + + 1. Verifies that the SNMP group is configured for the specified version. + 2. For SNMP version 3, verify that the security model matches the expected value. + 3. Ensures that SNMP group configurations, including read, write, and notify views, align with version-specific requirements. + + Expected Results + ---------------- + * Success: The test will pass if the provided SNMP group and all specified parameters are correctly configured. + * Failure: The test will fail if the provided SNMP group is not configured or if any specified parameter is not correctly configured. + + Examples + -------- + ```yaml + anta.tests.snmp: + - VerifySnmpGroup: + snmp_groups: + - group_name: Group1 + version: v1 + read_view: group_read_1 + write_view: group_write_1 + notify_view: group_notify_1 + - group_name: Group2 + version: v3 + read_view: group_read_2 + write_view: group_write_2 + notify_view: group_notify_2 + authentication: priv + ``` + """ + + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp group", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifySnmpGroup test.""" + + snmp_groups: list[SnmpGroup] + """List of SNMP groups.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifySnmpGroup.""" + self.result.is_success() + for group in self.inputs.snmp_groups: + # Verify SNMP group details. + if not (group_details := get_value(self.instance_commands[0].json_output, f"groups.{group.group_name}.versions.{group.version}")): + self.result.is_failure(f"{group} - Not configured") + continue + + view_types = [view_type for view_type in ["read", "write", "notify"] if getattr(group, f"{view_type}_view")] + # Verify SNMP views, the read, write and notify settings aligning with version-specific requirements. + for view_type in view_types: + expected_view = getattr(group, f"{view_type}_view") + # Verify actual view is configured. + if group_details.get(f"{view_type}View") == "": + self.result.is_failure(f"{group} View: {view_type} - Not configured") + elif (act_view := group_details.get(f"{view_type}View")) != expected_view: + self.result.is_failure(f"{group} - Incorrect {view_type.title()} view - Expected: {expected_view} Actual: {act_view}") + elif not group_details.get(f"{view_type}ViewConfig"): + self.result.is_failure(f"{group} {view_type.title()} View: {expected_view} - Not configured") + + # For version v3, verify that the security model aligns with the expected value. + if group.version == "v3" and (actual_auth := group_details.get("secModel")) != group.authentication: + self.result.is_failure(f"{group} - Incorrect security model - Expected: {group.authentication} Actual: {actual_auth}") diff --git a/anta/tests/software.py b/anta/tests/software.py index 4028dd963..825176046 100644 --- a/anta/tests/software.py +++ b/anta/tests/software.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to the EOS software tests.""" @@ -16,7 +16,7 @@ class VerifyEOSVersion(AntaTest): - """Verifies that the device is running one of the allowed EOS version. + """Verifies the EOS version of the device. Expected Results ---------------- @@ -34,8 +34,6 @@ class VerifyEOSVersion(AntaTest): ``` """ - name = "VerifyEOSVersion" - description = "Verifies the EOS version of the device." categories: ClassVar[list[str]] = ["software"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)] @@ -49,14 +47,13 @@ class Input(AntaTest.Input): def test(self) -> None: """Main test function for VerifyEOSVersion.""" command_output = self.instance_commands[0].json_output - if command_output["version"] in self.inputs.versions: - self.result.is_success() - else: - self.result.is_failure(f'device is running version "{command_output["version"]}" not in expected versions: {self.inputs.versions}') + self.result.is_success() + if command_output["version"] not in self.inputs.versions: + self.result.is_failure(f"EOS version mismatch - Actual: {command_output['version']} not in Expected: {', '.join(self.inputs.versions)}") class VerifyTerminAttrVersion(AntaTest): - """Verifies that he device is running one of the allowed TerminAttr version. + """Verifies the TerminAttr version of the device. Expected Results ---------------- @@ -74,8 +71,6 @@ class VerifyTerminAttrVersion(AntaTest): ``` """ - name = "VerifyTerminAttrVersion" - description = "Verifies the TerminAttr version of the device." categories: ClassVar[list[str]] = ["software"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)] @@ -89,11 +84,10 @@ class Input(AntaTest.Input): def test(self) -> None: """Main test function for VerifyTerminAttrVersion.""" command_output = self.instance_commands[0].json_output + self.result.is_success() command_output_data = command_output["details"]["packages"]["TerminAttr-core"]["version"] - if command_output_data in self.inputs.versions: - self.result.is_success() - else: - self.result.is_failure(f"device is running TerminAttr version {command_output_data} and is not in the allowed list: {self.inputs.versions}") + if command_output_data not in self.inputs.versions: + self.result.is_failure(f"TerminAttr version mismatch - Actual: {command_output_data} not in Expected: {', '.join(self.inputs.versions)}") class VerifyEOSExtensions(AntaTest): @@ -112,8 +106,6 @@ class VerifyEOSExtensions(AntaTest): ``` """ - name = "VerifyEOSExtensions" - description = "Verifies that all EOS extensions installed on the device are enabled for boot persistence." categories: ClassVar[list[str]] = ["software"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="show extensions", revision=2), @@ -124,6 +116,7 @@ class VerifyEOSExtensions(AntaTest): def test(self) -> None: """Main test function for VerifyEOSExtensions.""" boot_extensions = [] + self.result.is_success() show_extensions_command_output = self.instance_commands[0].json_output show_boot_extensions_command_output = self.instance_commands[1].json_output installed_extensions = [ @@ -135,7 +128,7 @@ def test(self) -> None: boot_extensions.append(formatted_extension) installed_extensions.sort() boot_extensions.sort() - if installed_extensions == boot_extensions: - self.result.is_success() - else: - self.result.is_failure(f"Missing EOS extensions: installed {installed_extensions} / configured: {boot_extensions}") + if installed_extensions != boot_extensions: + str_installed_extensions = ", ".join(installed_extensions) if installed_extensions else "Not found" + str_boot_extensions = ", ".join(boot_extensions) if boot_extensions else "Not found" + self.result.is_failure(f"EOS extensions mismatch - Installed: {str_installed_extensions} Configured: {str_boot_extensions}") diff --git a/anta/tests/stp.py b/anta/tests/stp.py index 5f8b3e89b..1c8badd53 100644 --- a/anta/tests/stp.py +++ b/anta/tests/stp.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to various Spanning Tree Protocol (STP) tests.""" @@ -11,9 +11,9 @@ from pydantic import Field -from anta.custom_types import Vlan +from anta.custom_types import Interface, InterfaceType, VlanId from anta.models import AntaCommand, AntaTemplate, AntaTest -from anta.tools import get_value +from anta.tools import get_value, is_interface_ignored class VerifySTPMode(AntaTest): @@ -36,8 +36,6 @@ class VerifySTPMode(AntaTest): ``` """ - name = "VerifySTPMode" - description = "Verifies the configured STP mode for a provided list of VLAN(s)." categories: ClassVar[list[str]] = ["stp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree vlan {vlan}", revision=1)] @@ -46,7 +44,7 @@ class Input(AntaTest.Input): mode: Literal["mstp", "rstp", "rapidPvst"] = "mstp" """STP mode to verify. Supported values: mstp, rstp, rapidPvst. Defaults to mstp.""" - vlans: list[Vlan] + vlans: list[VlanId] """List of VLAN on which to verify STP mode.""" def render(self, template: AntaTemplate) -> list[AntaCommand]: @@ -56,8 +54,7 @@ def render(self, template: AntaTemplate) -> list[AntaCommand]: @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySTPMode.""" - not_configured = [] - wrong_stp_mode = [] + self.result.is_success() for command in self.instance_commands: vlan_id = command.params.vlan if not ( @@ -66,15 +63,9 @@ def test(self) -> None: f"spanningTreeVlanInstances.{vlan_id}.spanningTreeVlanInstance.protocol", ) ): - not_configured.append(vlan_id) + self.result.is_failure(f"VLAN {vlan_id} STP mode: {self.inputs.mode} - Not configured") elif stp_mode != self.inputs.mode: - wrong_stp_mode.append(vlan_id) - if not_configured: - self.result.is_failure(f"STP mode '{self.inputs.mode}' not configured for the following VLAN(s): {not_configured}") - if wrong_stp_mode: - self.result.is_failure(f"Wrong STP mode configured for the following VLAN(s): {wrong_stp_mode}") - if not not_configured and not wrong_stp_mode: - self.result.is_success() + self.result.is_failure(f"VLAN {vlan_id} - Incorrect STP mode - Expected: {self.inputs.mode} Actual: {stp_mode}") class VerifySTPBlockedPorts(AntaTest): @@ -93,8 +84,6 @@ class VerifySTPBlockedPorts(AntaTest): ``` """ - name = "VerifySTPBlockedPorts" - description = "Verifies there is no STP blocked ports." categories: ClassVar[list[str]] = ["stp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree blockedports", revision=1)] @@ -106,8 +95,8 @@ def test(self) -> None: self.result.is_success() else: for key, value in stp_instances.items(): - stp_instances[key] = value.pop("spanningTreeBlockedPorts") - self.result.is_failure(f"The following ports are blocked by STP: {stp_instances}") + stp_block_ports = value.get("spanningTreeBlockedPorts") + self.result.is_failure(f"STP Instance: {key} - Blocked ports - {', '.join(stp_block_ports)}") class VerifySTPCounters(AntaTest): @@ -115,7 +104,7 @@ class VerifySTPCounters(AntaTest): Expected Results ---------------- - * Success: The test will pass if there are NO STP BPDU packet errors under all interfaces participating in STP. + * Success: The test will pass if there are NO STP BPDU packet errors under all or on specified interfaces participating in STP. * Failure: The test will fail if there are STP BPDU packet errors on one or many interface(s). Examples @@ -123,25 +112,47 @@ class VerifySTPCounters(AntaTest): ```yaml anta.tests.stp: - VerifySTPCounters: + interfaces: + - Ethernet10 + - Ethernet12 + ignored_interfaces: + - Vxlan1 + - Loopback0 ``` """ - name = "VerifySTPCounters" - description = "Verifies there is no errors in STP BPDU packets." categories: ClassVar[list[str]] = ["stp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree counters", revision=1)] + class Input(AntaTest.Input): + """Input model for the VerifySTPCounters test.""" + + interfaces: list[Interface] | None = None + """A list of interfaces to be tested. If not provided, all interfaces (excluding any in `ignored_interfaces`) are tested.""" + ignored_interfaces: list[InterfaceType | Interface] | None = None + """A list of interfaces or interface types like Ethernet which will ignore all Ethernet interfaces.""" + @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySTPCounters.""" + self.result.is_success() command_output = self.instance_commands[0].json_output - interfaces_with_errors = [ - interface for interface, counters in command_output["interfaces"].items() if counters["bpduTaggedError"] or counters["bpduOtherError"] != 0 - ] - if interfaces_with_errors: - self.result.is_failure(f"The following interfaces have STP BPDU packet errors: {interfaces_with_errors}") - else: - self.result.is_success() + interfaces = self.inputs.interfaces if self.inputs.interfaces else command_output["interfaces"].keys() + + for interface in interfaces: + # Verification is skipped if the interface is in the ignored interfaces list. + if is_interface_ignored(interface, self.inputs.ignored_interfaces): + continue + + # If specified interface is not configured, test fails + if (counters := get_value(command_output, f"interfaces..{interface}", separator="..")) is None: + self.result.is_failure(f"Interface: {interface} - Not found") + continue + + if counters["bpduTaggedError"] != 0: + self.result.is_failure(f"Interface {interface} - STP BPDU packet tagged errors count mismatch - Expected: 0 Actual: {counters['bpduTaggedError']}") + if counters["bpduOtherError"] != 0: + self.result.is_failure(f"Interface {interface} - STP BPDU packet other errors count mismatch - Expected: 0 Actual: {counters['bpduOtherError']}") class VerifySTPForwardingPorts(AntaTest): @@ -163,7 +174,6 @@ class VerifySTPForwardingPorts(AntaTest): ``` """ - name = "VerifySTPForwardingPorts" description = "Verifies that all interfaces are forwarding for a provided list of VLAN(s)." categories: ClassVar[list[str]] = ["stp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree topology vlan {vlan} status", revision=1)] @@ -171,7 +181,7 @@ class VerifySTPForwardingPorts(AntaTest): class Input(AntaTest.Input): """Input model for the VerifySTPForwardingPorts test.""" - vlans: list[Vlan] + vlans: list[VlanId] """List of VLAN on which to verify forwarding states.""" def render(self, template: AntaTemplate) -> list[AntaCommand]: @@ -181,24 +191,22 @@ def render(self, template: AntaTemplate) -> list[AntaCommand]: @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySTPForwardingPorts.""" - not_configured = [] - not_forwarding = [] + self.result.is_success() + interfaces_state = [] for command in self.instance_commands: vlan_id = command.params.vlan if not (topologies := get_value(command.json_output, "topologies")): - not_configured.append(vlan_id) - else: - for value in topologies.values(): - if vlan_id and int(vlan_id) in value["vlans"]: - interfaces_not_forwarding = [interface for interface, state in value["interfaces"].items() if state["state"] != "forwarding"] - if interfaces_not_forwarding: - not_forwarding.append({f"VLAN {vlan_id}": interfaces_not_forwarding}) - if not_configured: - self.result.is_failure(f"STP instance is not configured for the following VLAN(s): {not_configured}") - if not_forwarding: - self.result.is_failure(f"The following VLAN(s) have interface(s) that are not in a forwarding state: {not_forwarding}") - if not not_configured and not interfaces_not_forwarding: - self.result.is_success() + self.result.is_failure(f"VLAN {vlan_id} - STP instance is not configured") + continue + for value in topologies.values(): + if vlan_id and int(vlan_id) in value["vlans"]: + interfaces_state = [ + (interface, actual_state) for interface, state in value["interfaces"].items() if (actual_state := state["state"]) != "forwarding" + ] + + if interfaces_state: + for interface, state in interfaces_state: + self.result.is_failure(f"VLAN {vlan_id} Interface: {interface} - Invalid state - Expected: forwarding Actual: {state}") class VerifySTPRootPriority(AntaTest): @@ -221,8 +229,6 @@ class VerifySTPRootPriority(AntaTest): ``` """ - name = "VerifySTPRootPriority" - description = "Verifies the STP root priority for a provided list of VLAN or MST instance ID(s)." categories: ClassVar[list[str]] = ["stp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree root detail", revision=1)] @@ -231,12 +237,13 @@ class Input(AntaTest.Input): priority: int """STP root priority to verify.""" - instances: list[Vlan] = Field(default=[]) + instances: list[VlanId] = Field(default=[]) """List of VLAN or MST instance ID(s). If empty, ALL VLAN or MST instance ID(s) will be verified.""" @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySTPRootPriority.""" + self.result.is_success() command_output = self.instance_commands[0].json_output if not (stp_instances := command_output["instances"]): self.result.is_failure("No STP instances configured") @@ -248,13 +255,128 @@ def test(self) -> None: elif first_name.startswith("VL"): prefix = "VL" else: - self.result.is_failure(f"Unsupported STP instance type: {first_name}") + self.result.is_failure(f"STP Instance: {first_name} - Unsupported STP instance type") return check_instances = [f"{prefix}{instance_id}" for instance_id in self.inputs.instances] if self.inputs.instances else command_output["instances"].keys() - wrong_priority_instances = [ - instance for instance in check_instances if get_value(command_output, f"instances.{instance}.rootBridge.priority") != self.inputs.priority - ] - if wrong_priority_instances: - self.result.is_failure(f"The following instance(s) have the wrong STP root priority configured: {wrong_priority_instances}") - else: - self.result.is_success() + for instance in check_instances: + if not (instance_details := get_value(command_output, f"instances.{instance}")): + self.result.is_failure(f"Instance: {instance} - Not configured") + continue + if (priority := get_value(instance_details, "rootBridge.priority")) != self.inputs.priority: + self.result.is_failure(f"STP Instance: {instance} - Incorrect root priority - Expected: {self.inputs.priority} Actual: {priority}") + + +class VerifyStpTopologyChanges(AntaTest): + """Verifies the number of changes across all interfaces in the Spanning Tree Protocol (STP) topology is below a threshold. + + Expected Results + ---------------- + * Success: The test will pass if the total number of changes across all interfaces is less than the specified threshold. + * Failure: The test will fail if the total number of changes across all interfaces meets or exceeds the specified threshold, + indicating potential instability in the topology. + + Examples + -------- + ```yaml + anta.tests.stp: + - VerifyStpTopologyChanges: + threshold: 10 + ``` + """ + + categories: ClassVar[list[str]] = ["stp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree topology status detail", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyStpTopologyChanges test.""" + + threshold: int + """The threshold number of changes in the STP topology.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyStpTopologyChanges.""" + self.result.is_success() + command_output = self.instance_commands[0].json_output + stp_topologies = command_output.get("topologies", {}) + + # verifies all available topologies except the "NoStp" topology. + stp_topologies.pop("NoStp", None) + + # Verify the STP topology(s). + if not stp_topologies: + self.result.is_failure("STP is not configured") + return + + # Verifies the number of changes across all interfaces + for topology, topology_details in stp_topologies.items(): + for interface, details in topology_details.get("interfaces", {}).items(): + if (num_of_changes := details.get("numChanges")) > self.inputs.threshold: + self.result.is_failure( + f"Topology: {topology} Interface: {interface} - Number of changes not within the threshold - Expected: " + f"{self.inputs.threshold} Actual: {num_of_changes}" + ) + + +class VerifySTPDisabledVlans(AntaTest): + """Verifies the STP disabled VLAN(s). + + This test performs the following checks: + + 1. Verifies that the STP is configured. + 2. Verifies that the specified VLAN(s) exist on the device. + 3. Verifies that the STP is disabled for the specified VLAN(s). + + Expected Results + ---------------- + * Success: The test will pass if all of the following conditions are met: + - STP is properly configured on the device. + - The specified VLAN(s) exist on the device. + - STP is confirmed to be disabled for all the specified VLAN(s). + * Failure: The test will fail if any of the following condition is met: + - STP is not configured on the device. + - The specified VLAN(s) do not exist on the device. + - STP is enabled for any of the specified VLAN(s). + + Examples + -------- + ```yaml + anta.tests.stp: + - VerifySTPDisabledVlans: + vlans: + - 6 + - 4094 + ``` + """ + + categories: ClassVar[list[str]] = ["stp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree vlan detail", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifySTPDisabledVlans test.""" + + vlans: list[VlanId] + """List of STP disabled VLAN(s).""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifySTPDisabledVlans.""" + self.result.is_success() + + command_output = self.instance_commands[0].json_output + stp_vlan_instances = command_output.get("spanningTreeVlanInstances", {}) + + # If the spanningTreeVlanInstances detail are not found in the command output, the test fails. + if not stp_vlan_instances: + self.result.is_failure("STP is not configured") + return + + actual_vlans = list(stp_vlan_instances) + # If the specified VLAN is not present on the device, STP is enabled for the VLAN(s), test fails. + for vlan in self.inputs.vlans: + if str(vlan) not in actual_vlans: + self.result.is_failure(f"VLAN: {vlan} - Not configured") + continue + + if stp_vlan_instances.get(str(vlan)): + self.result.is_failure(f"VLAN: {vlan} - STP is enabled") diff --git a/anta/tests/stun.py b/anta/tests/stun.py index a8e8d9eb9..da8c28191 100644 --- a/anta/tests/stun.py +++ b/anta/tests/stun.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Test functions related to various STUN settings.""" @@ -7,32 +7,36 @@ # mypy: disable-error-code=attr-defined from __future__ import annotations -from ipaddress import IPv4Address from typing import ClassVar -from pydantic import BaseModel - -from anta.custom_types import Port +from anta.decorators import deprecated_test_class +from anta.input_models.stun import StunClientTranslation from anta.models import AntaCommand, AntaTemplate, AntaTest -from anta.tools import get_failed_logs, get_value +from anta.tools import get_value -class VerifyStunClient(AntaTest): - """ - Verifies the configuration of the STUN client, specifically the IPv4 source address and port. +class VerifyStunClientTranslation(AntaTest): + """Verifies the translation for a source address on a STUN client. + + This test performs the following checks for each specified address family: - Optionally, it can also verify the public address and port. + 1. Validates that there is a translation for the source address on the STUN client. + 2. If public IP and port details are provided, validates their correctness against the configuration. Expected Results ---------------- - * Success: The test will pass if the STUN client is correctly configured with the specified IPv4 source address/port and public address/port. - * Failure: The test will fail if the STUN client is not configured or if the IPv4 source address, public address, or port details are incorrect. + * Success: If all of the following conditions are met: + - The test will pass if the source address translation is present. + - If public IP and port details are provided, they must also match the translation information. + * Failure: If any of the following occur: + - There is no translation for the source address on the STUN client. + - The public IP or port details, if specified, are incorrect. Examples -------- ```yaml anta.tests.stun: - - VerifyStunClient: + - VerifyStunClientTranslation: stun_clients: - source_address: 172.18.3.2 public_address: 172.18.3.21 @@ -45,27 +49,15 @@ class VerifyStunClient(AntaTest): ``` """ - name = "VerifyStunClient" - description = "Verifies the STUN client is configured with the specified IPv4 source address and port. Validate the public IP and port if provided." categories: ClassVar[list[str]] = ["stun"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show stun client translations {source_address} {source_port}")] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show stun client translations {source_address} {source_port}", revision=1)] class Input(AntaTest.Input): - """Input model for the VerifyStunClient test.""" - - stun_clients: list[ClientAddress] + """Input model for the VerifyStunClientTranslation test.""" - class ClientAddress(BaseModel): - """Source and public address/port details of STUN client.""" - - source_address: IPv4Address - """IPv4 source address of STUN client.""" - source_port: Port = 4500 - """Source port number for STUN client.""" - public_address: IPv4Address | None = None - """Optional IPv4 public address of STUN client.""" - public_port: Port | None = None - """Optional public port number for STUN client.""" + stun_clients: list[StunClientTranslation] + """List of STUN clients.""" + StunClientTranslation: ClassVar[type[StunClientTranslation]] = StunClientTranslation def render(self, template: AntaTemplate) -> list[AntaCommand]: """Render the template for each STUN translation.""" @@ -73,45 +65,90 @@ def render(self, template: AntaTemplate) -> list[AntaCommand]: @AntaTest.anta_test def test(self) -> None: - """Main test function for VerifyStunClient.""" + """Main test function for VerifyStunClientTranslation.""" self.result.is_success() # Iterate over each command output and corresponding client input for command, client_input in zip(self.instance_commands, self.inputs.stun_clients): bindings = command.json_output["bindings"] - source_address = str(command.params.source_address) - source_port = command.params.source_port + input_public_address = client_input.public_address + input_public_port = client_input.public_port # If no bindings are found for the STUN client, mark the test as a failure and continue with the next client if not bindings: - self.result.is_failure(f"STUN client transaction for source `{source_address}:{source_port}` is not found.") + self.result.is_failure(f"{client_input} - STUN client translation not found") continue - # Extract the public address and port from the client input - public_address = client_input.public_address - public_port = client_input.public_port - # Extract the transaction ID from the bindings transaction_id = next(iter(bindings.keys())) - # Prepare the actual and expected STUN data for comparison - actual_stun_data = { - "source ip": get_value(bindings, f"{transaction_id}.sourceAddress.ip"), - "source port": get_value(bindings, f"{transaction_id}.sourceAddress.port"), - } - expected_stun_data = {"source ip": source_address, "source port": source_port} - - # If public address is provided, add it to the actual and expected STUN data - if public_address is not None: - actual_stun_data["public ip"] = get_value(bindings, f"{transaction_id}.publicAddress.ip") - expected_stun_data["public ip"] = str(public_address) - - # If public port is provided, add it to the actual and expected STUN data - if public_port is not None: - actual_stun_data["public port"] = get_value(bindings, f"{transaction_id}.publicAddress.port") - expected_stun_data["public port"] = public_port - - # If the actual STUN data does not match the expected STUN data, mark the test as failure - if actual_stun_data != expected_stun_data: - failed_log = get_failed_logs(expected_stun_data, actual_stun_data) - self.result.is_failure(f"For STUN source `{source_address}:{source_port}`:{failed_log}") + # Verifying the public address if provided + if input_public_address and str(input_public_address) != (actual_public_address := get_value(bindings, f"{transaction_id}.publicAddress.ip")): + self.result.is_failure(f"{client_input} - Incorrect public-facing address - Expected: {input_public_address} Actual: {actual_public_address}") + + # Verifying the public port if provided + if input_public_port and input_public_port != (actual_public_port := get_value(bindings, f"{transaction_id}.publicAddress.port")): + self.result.is_failure(f"{client_input} - Incorrect public-facing port - Expected: {input_public_port} Actual: {actual_public_port}") + + +@deprecated_test_class(new_tests=["VerifyStunClientTranslation"], removal_in_version="v2.0.0") +class VerifyStunClient(VerifyStunClientTranslation): + """(Deprecated) Verifies the translation for a source address on a STUN client. + + Alias for the VerifyStunClientTranslation test to maintain backward compatibility. + When initialized, it will emit a deprecation warning and call the VerifyStunClientTranslation test. + + Examples + -------- + ```yaml + anta.tests.stun: + - VerifyStunClient: + stun_clients: + - source_address: 172.18.3.2 + public_address: 172.18.3.21 + source_port: 4500 + public_port: 6006 + ``` + """ + + # TODO: Remove this class in ANTA v2.0.0. + + # required to redefine name an description to overwrite parent class. + name = "VerifyStunClient" + description = "(Deprecated) Verifies the translation for a source address on a STUN client." + + +class VerifyStunServer(AntaTest): + """Verifies the STUN server status is enabled and running. + + Expected Results + ---------------- + * Success: The test will pass if the STUN server status is enabled and running. + * Failure: The test will fail if the STUN server is disabled or not running. + + Examples + -------- + ```yaml + anta.tests.stun: + - VerifyStunServer: + ``` + """ + + categories: ClassVar[list[str]] = ["stun"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show stun server status", revision=1)] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyStunServer.""" + command_output = self.instance_commands[0].json_output + status_disabled = not command_output.get("enabled") + not_running = command_output.get("pid") == 0 + + if status_disabled and not_running: + self.result.is_failure("STUN server status is disabled and not running") + elif status_disabled: + self.result.is_failure("STUN server status is disabled") + elif not_running: + self.result.is_failure("STUN server is not running") + else: + self.result.is_success() diff --git a/anta/tests/system.py b/anta/tests/system.py index 49d2dd25d..e37aa7eac 100644 --- a/anta/tests/system.py +++ b/anta/tests/system.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to system-level features and protocols tests.""" @@ -8,21 +8,33 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar -from anta.custom_types import PositiveInteger +from pydantic import Field, model_validator + +from anta.custom_types import Hostname, PositiveInteger, ReloadCause +from anta.input_models.system import NTPPool, NTPServer from anta.models import AntaCommand, AntaTest +from anta.tools import get_value if TYPE_CHECKING: + import sys + from ipaddress import IPv4Address + from anta.models import AntaTemplate + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + CPU_IDLE_THRESHOLD = 25 MEMORY_THRESHOLD = 0.25 DISK_SPACE_THRESHOLD = 75 class VerifyUptime(AntaTest): - """Verifies if the device uptime is higher than the provided minimum uptime value. + """Verifies the device uptime. Expected Results ---------------- @@ -38,8 +50,6 @@ class VerifyUptime(AntaTest): ``` """ - name = "VerifyUptime" - description = "Verifies the device uptime." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show uptime", revision=1)] @@ -52,11 +62,10 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyUptime.""" + self.result.is_success() command_output = self.instance_commands[0].json_output - if command_output["upTime"] > self.inputs.minimum: - self.result.is_success() - else: - self.result.is_failure(f"Device uptime is {command_output['upTime']} seconds") + if command_output["upTime"] < self.inputs.minimum: + self.result.is_failure(f"Device uptime is incorrect - Expected: {self.inputs.minimum}s Actual: {command_output['upTime']}s") class VerifyReloadCause(AntaTest): @@ -64,8 +73,8 @@ class VerifyReloadCause(AntaTest): Expected Results ---------------- - * Success: The test will pass if there are NO reload causes or if the last reload was caused by the user or after an FPGA upgrade. - * Failure: The test will fail if the last reload was NOT caused by the user or after an FPGA upgrade. + * Success: The test passes if there is no reload cause, or if the last reload cause was one of the provided inputs. + * Failure: The test will fail if the last reload cause was NOT one of the provided inputs. * Error: The test will report an error if the reload cause is NOT available. Examples @@ -73,58 +82,60 @@ class VerifyReloadCause(AntaTest): ```yaml anta.tests.system: - VerifyReloadCause: + allowed_causes: + - USER + - FPGA + - ZTP ``` """ - name = "VerifyReloadCause" - description = "Verifies the last reload cause of the device." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show reload cause", revision=1)] + class Input(AntaTest.Input): + """Input model for the VerifyReloadCause test.""" + + allowed_causes: list[ReloadCause] = Field(default=["USER", "FPGA"], validate_default=True) + """A list of allowed system reload causes.""" + @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyReloadCause.""" command_output = self.instance_commands[0].json_output - if "resetCauses" not in command_output: - self.result.is_error(message="No reload causes available") - return if len(command_output["resetCauses"]) == 0: # No reload causes self.result.is_success() return + reset_causes = command_output["resetCauses"] command_output_data = reset_causes[0].get("description") - if command_output_data in [ - "Reload requested by the user.", - "Reload requested after FPGA upgrade", - ]: + if command_output_data in self.inputs.allowed_causes: self.result.is_success() else: - self.result.is_failure(f"Reload cause is: '{command_output_data}'") + causes = ", ".join(f"'{c}'" for c in self.inputs.allowed_causes) + self.result.is_failure(f"Invalid reload cause - Expected: {causes} Actual: '{command_output_data}'") class VerifyCoredump(AntaTest): - """Verifies if there are core dump files in the /var/core directory. + """Verifies there are no core dump files. Expected Results ---------------- * Success: The test will pass if there are NO core dump(s) in /var/core. * Failure: The test will fail if there are core dump(s) in /var/core. - Info - ---- + Notes + ----- * This test will NOT check for minidump(s) generated by certain agents in /var/core/minidump. Examples -------- ```yaml anta.tests.system: - - VerifyCoreDump: + - VerifyCoredump: ``` """ - name = "VerifyCoredump" - description = "Verifies there are no core dump files." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system coredump", revision=1)] @@ -138,11 +149,11 @@ def test(self) -> None: if not core_files: self.result.is_success() else: - self.result.is_failure(f"Core dump(s) have been found: {core_files}") + self.result.is_failure(f"Core dump(s) have been found: {', '.join(core_files)}") class VerifyAgentLogs(AntaTest): - """Verifies that no agent crash reports are present on the device. + """Verifies there are no agent crash reports. Expected Results ---------------- @@ -157,8 +168,6 @@ class VerifyAgentLogs(AntaTest): ``` """ - name = "VerifyAgentLogs" - description = "Verifies there are no agent crash reports." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show agent logs crash", ofmt="text")] @@ -190,20 +199,17 @@ class VerifyCPUUtilization(AntaTest): ``` """ - name = "VerifyCPUUtilization" - description = "Verifies whether the CPU utilization is below 75%." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show processes top once", revision=1)] @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyCPUUtilization.""" + self.result.is_success() command_output = self.instance_commands[0].json_output command_output_data = command_output["cpuInfo"]["%Cpu(s)"]["idle"] - if command_output_data > CPU_IDLE_THRESHOLD: - self.result.is_success() - else: - self.result.is_failure(f"Device has reported a high CPU utilization: {100 - command_output_data}%") + if command_output_data < CPU_IDLE_THRESHOLD: + self.result.is_failure(f"Device has reported a high CPU utilization - Expected: < 75% Actual: {100 - command_output_data}%") class VerifyMemoryUtilization(AntaTest): @@ -222,20 +228,17 @@ class VerifyMemoryUtilization(AntaTest): ``` """ - name = "VerifyMemoryUtilization" - description = "Verifies whether the memory utilization is below 75%." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)] @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyMemoryUtilization.""" + self.result.is_success() command_output = self.instance_commands[0].json_output memory_usage = command_output["memFree"] / command_output["memTotal"] - if memory_usage > MEMORY_THRESHOLD: - self.result.is_success() - else: - self.result.is_failure(f"Device has reported a high memory usage: {(1 - memory_usage)*100:.2f}%") + if memory_usage < MEMORY_THRESHOLD: + self.result.is_failure(f"Device has reported a high memory usage - Expected: < 75% Actual: {(1 - memory_usage) * 100:.2f}%") class VerifyFileSystemUtilization(AntaTest): @@ -254,8 +257,6 @@ class VerifyFileSystemUtilization(AntaTest): ``` """ - name = "VerifyFileSystemUtilization" - description = "Verifies that no partition is utilizing more than 75% of its disk space." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="bash timeout 10 df -h", ofmt="text")] @@ -266,11 +267,11 @@ def test(self) -> None: self.result.is_success() for line in command_output.split("\n")[1:]: if "loop" not in line and len(line) > 0 and (percentage := int(line.split()[4].replace("%", ""))) > DISK_SPACE_THRESHOLD: - self.result.is_failure(f"Mount point {line} is higher than 75%: reported {percentage}%") + self.result.is_failure(f"Mount point: {line} - Higher disk space utilization - Expected: {DISK_SPACE_THRESHOLD}% Actual: {percentage}%") class VerifyNTP(AntaTest): - """Verifies that the Network Time Protocol (NTP) is synchronized. + """Verifies if NTP is synchronised. Expected Results ---------------- @@ -285,8 +286,6 @@ class VerifyNTP(AntaTest): ``` """ - name = "VerifyNTP" - description = "Verifies if NTP is synchronised." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ntp status", ofmt="text")] @@ -298,4 +297,196 @@ def test(self) -> None: self.result.is_success() else: data = command_output.split("\n")[0] - self.result.is_failure(f"The device is not synchronized with the configured NTP server(s): '{data}'") + self.result.is_failure(f"NTP status mismatch - Expected: synchronised Actual: {data}") + + +class VerifyNTPAssociations(AntaTest): + """Verifies the Network Time Protocol (NTP) associations. + + This test performs the following checks: + + 1. For the NTP servers: + - The primary NTP server (marked as preferred) has the condition 'sys.peer'. + - All other NTP servers have the condition 'candidate'. + - All the NTP servers have the expected stratum level. + 2. For the NTP servers pool: + - All the NTP servers belong to the specified NTP pool. + - All the NTP servers have valid condition (sys.peer | candidate). + - All the NTP servers have the stratum level within the specified startum level. + + Expected Results + ---------------- + * Success: The test will pass if all the NTP servers meet the expected state. + * Failure: The test will fail if any of the NTP server does not meet the expected state. + + Examples + -------- + ```yaml + anta.tests.system: + - VerifyNTPAssociations: + ntp_servers: + - server_address: 1.1.1.1 + preferred: True + stratum: 1 + - server_address: 2.2.2.2 + stratum: 2 + - server_address: 3.3.3.3 + stratum: 2 + - VerifyNTPAssociations: + ntp_pool: + server_addresses: [1.1.1.1, 2.2.2.2] + preferred_stratum_range: [1,3] + ``` + """ + + categories: ClassVar[list[str]] = ["system"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ntp associations")] + + class Input(AntaTest.Input): + """Input model for the VerifyNTPAssociations test.""" + + ntp_servers: list[NTPServer] | None = None + """List of NTP servers.""" + ntp_pool: NTPPool | None = None + """NTP servers pool.""" + NTPServer: ClassVar[type[NTPServer]] = NTPServer + + @model_validator(mode="after") + def validate_inputs(self) -> Self: + """Validate the inputs provided to the VerifyNTPAssociations test. + + Either `ntp_servers` or `ntp_pool` can be provided at the same time. + """ + if not self.ntp_servers and not self.ntp_pool: + msg = "'ntp_servers' or 'ntp_pool' must be provided" + raise ValueError(msg) + if self.ntp_servers and self.ntp_pool: + msg = "Either 'ntp_servers' or 'ntp_pool' can be provided at the same time" + raise ValueError(msg) + + # Verifies the len of preferred_stratum_range in NTP Pool should be 2 as this is the range. + stratum_range = 2 + if self.ntp_pool and len(self.ntp_pool.preferred_stratum_range) > stratum_range: + msg = "'preferred_stratum_range' list should have at most 2 items" + raise ValueError(msg) + return self + + def _validate_ntp_server(self, ntp_server: NTPServer, peers: dict[str, Any]) -> list[str]: + """Validate the NTP server, condition and stratum level.""" + failure_msgs: list[str] = [] + server_address = str(ntp_server.server_address) + + # We check `peerIpAddr` in the peer details - covering IPv4Address input, or the peer key - covering Hostname input. + matching_peer = next((peer for peer, peer_details in peers.items() if (server_address in {peer_details["peerIpAddr"], peer})), None) + + if not matching_peer: + failure_msgs.append(f"{ntp_server} - Not configured") + return failure_msgs + + # Collecting the expected/actual NTP peer details. + exp_condition = "sys.peer" if ntp_server.preferred else "candidate" + exp_stratum = ntp_server.stratum + act_condition = get_value(peers[matching_peer], "condition") + act_stratum = get_value(peers[matching_peer], "stratumLevel") + + if act_condition != exp_condition: + failure_msgs.append(f"{ntp_server} - Incorrect condition - Expected: {exp_condition} Actual: {act_condition}") + + if act_stratum != exp_stratum: + failure_msgs.append(f"{ntp_server} - Incorrect stratum level - Expected: {exp_stratum} Actual: {act_stratum}") + + return failure_msgs + + def _validate_ntp_pool(self, server_addresses: list[Hostname | IPv4Address], peer: str, stratum_range: list[int], peer_details: dict[str, Any]) -> list[str]: + """Validate the NTP server pool, condition and stratum level.""" + failure_msgs: list[str] = [] + + # We check `peerIpAddr` and `peer` in the peer details - covering server_addresses input + if (peer_ip := peer_details["peerIpAddr"]) not in server_addresses and peer not in server_addresses: + failure_msgs.append(f"NTP Server: {peer_ip} Hostname: {peer} - Associated but not part of the provided NTP pool") + return failure_msgs + + act_condition = get_value(peer_details, "condition") + act_stratum = get_value(peer_details, "stratumLevel") + + if act_condition not in ["sys.peer", "candidate"]: + failure_msgs.append(f"NTP Server: {peer_ip} Hostname: {peer} - Incorrect condition - Expected: sys.peer, candidate Actual: {act_condition}") + + if int(act_stratum) not in range(stratum_range[0], stratum_range[1] + 1): + msg = f"Expected Stratum Range: {stratum_range[0]} to {stratum_range[1]} Actual: {act_stratum}" + failure_msgs.append(f"NTP Server: {peer_ip} Hostname: {peer} - Incorrect stratum level - {msg}") + + return failure_msgs + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyNTPAssociations.""" + self.result.is_success() + + if not (peers := get_value(self.instance_commands[0].json_output, "peers")): + self.result.is_failure("No NTP peers configured") + return + + if self.inputs.ntp_servers: + # Iterate over each NTP server. + for ntp_server in self.inputs.ntp_servers: + failure_msgs = self._validate_ntp_server(ntp_server, peers) + for msg in failure_msgs: + self.result.is_failure(msg) + return + + # Verifies the NTP pool details + server_addresses = self.inputs.ntp_pool.server_addresses + exp_stratum_range = self.inputs.ntp_pool.preferred_stratum_range + for peer, peer_details in peers.items(): + failure_msgs = self._validate_ntp_pool(server_addresses, peer, exp_stratum_range, peer_details) + for msg in failure_msgs: + self.result.is_failure(msg) + + +class VerifyMaintenance(AntaTest): + """Verifies that the device is not currently under or entering maintenance. + + Expected Results + ---------------- + * Success: The test will pass if the device is not under or entering maintenance. + * Failure: The test will fail if the device is under or entering maintenance. + + Examples + -------- + ```yaml + anta.tests.system: + - VerifyMaintenance: + ``` + """ + + categories: ClassVar[list[str]] = ["system"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show maintenance", revision=1)] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyMaintenance.""" + self.result.is_success() + + # If units is not empty we have to examine the output for details. + if not (units := get_value(self.instance_commands[0].json_output, "units")): + return + units_under_maintenance = [unit for unit, info in units.items() if info["state"] == "underMaintenance"] + units_entering_maintenance = [unit for unit, info in units.items() if info["state"] == "maintenanceModeEnter"] + causes = set() + # Iterate over units, check for units under or entering maintenance, and examine the causes. + for info in units.values(): + if info["adminState"] == "underMaintenance": + causes.add("Quiesce is configured") + if info["onBootMaintenance"]: + causes.add("On-boot maintenance is configured") + if info["intfsViolatingTrafficThreshold"]: + causes.add("Interface traffic threshold violation") + + # Building the error message. + if units_under_maintenance: + self.result.is_failure(f"Units under maintenance: '{', '.join(units_under_maintenance)}'") + if units_entering_maintenance: + self.result.is_failure(f"Units entering maintenance: '{', '.join(units_entering_maintenance)}'") + if causes: + self.result.is_failure(f"Possible causes: '{', '.join(sorted(causes))}'") diff --git a/anta/tests/vlan.py b/anta/tests/vlan.py index fdf91d896..461a215c6 100644 --- a/anta/tests/vlan.py +++ b/anta/tests/vlan.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to VLAN tests.""" @@ -9,9 +9,10 @@ from typing import TYPE_CHECKING, ClassVar, Literal -from anta.custom_types import Vlan +from anta.custom_types import DynamicVlanSource, VlanId +from anta.input_models.vlan import Vlan from anta.models import AntaCommand, AntaTest -from anta.tools import get_failed_logs, get_value +from anta.tools import get_value if TYPE_CHECKING: from anta.models import AntaTemplate @@ -38,7 +39,6 @@ class VerifyVlanInternalPolicy(AntaTest): ``` """ - name = "VerifyVlanInternalPolicy" description = "Verifies the VLAN internal allocation policy and the range of VLANs." categories: ClassVar[list[str]] = ["vlan"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vlan internal allocation policy", revision=1)] @@ -48,24 +48,146 @@ class Input(AntaTest.Input): policy: Literal["ascending", "descending"] """The VLAN internal allocation policy. Supported values: ascending, descending.""" - start_vlan_id: Vlan + start_vlan_id: VlanId """The starting VLAN ID in the range.""" - end_vlan_id: Vlan + end_vlan_id: VlanId """The ending VLAN ID in the range.""" @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyVlanInternalPolicy.""" + self.result.is_success() command_output = self.instance_commands[0].json_output - keys_to_verify = ["policy", "startVlanId", "endVlanId"] - actual_policy_output = {key: get_value(command_output, key) for key in keys_to_verify} - expected_policy_output = {"policy": self.inputs.policy, "startVlanId": self.inputs.start_vlan_id, "endVlanId": self.inputs.end_vlan_id} - - # Check if the actual output matches the expected output - if actual_policy_output != expected_policy_output: - failed_log = "The VLAN internal allocation policy is not configured properly:" - failed_log += get_failed_logs(expected_policy_output, actual_policy_output) - self.result.is_failure(failed_log) - else: - self.result.is_success() + if (policy := self.inputs.policy) != (act_policy := get_value(command_output, "policy")): + self.result.is_failure(f"Incorrect VLAN internal allocation policy configured - Expected: {policy} Actual: {act_policy}") + return + + if (start_vlan_id := self.inputs.start_vlan_id) != (act_vlan_id := get_value(command_output, "startVlanId")): + self.result.is_failure( + f"VLAN internal allocation policy: {self.inputs.policy} - Incorrect start VLAN id configured - Expected: {start_vlan_id} Actual: {act_vlan_id}" + ) + + if (end_vlan_id := self.inputs.end_vlan_id) != (act_vlan_id := get_value(command_output, "endVlanId")): + self.result.is_failure( + f"VLAN internal allocation policy: {self.inputs.policy} - Incorrect end VLAN id configured - Expected: {end_vlan_id} Actual: {act_vlan_id}" + ) + + +class VerifyDynamicVlanSource(AntaTest): + """Verifies dynamic VLAN allocation for specified VLAN sources. + + This test performs the following checks for each specified VLAN source: + + 1. Validates source exists in dynamic VLAN table. + 2. Verifies at least one VLAN is allocated to the source. + 3. When strict mode is enabled (`strict: true`), ensures no other sources have VLANs allocated. + + Expected Results + ---------------- + * Success: The test will pass if all of the following conditions are met: + - Each specified source exists in dynamic VLAN table. + - Each specified source has at least one VLAN allocated. + - In strict mode: No other sources have VLANs allocated. + * Failure: The test will fail if any of the following conditions is met: + - Specified source not found in configuration. + - Source exists but has no VLANs allocated. + - In strict mode: Non-specified sources have VLANs allocated. + + Examples + -------- + ```yaml + anta.tests.vlan: + - VerifyDynamicVlanSource: + sources: + - evpn + - mlagsync + strict: False + ``` + """ + + categories: ClassVar[list[str]] = ["vlan"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vlan dynamic", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyDynamicVlanSource test.""" + + sources: list[DynamicVlanSource] + """The dynamic VLAN source list.""" + strict: bool = False + """If True, only specified sources are allowed to have VLANs allocated. Default is False.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyDynamicVlanSource.""" + self.result.is_success() + command_output = self.instance_commands[0].json_output + dynamic_vlans = command_output.get("dynamicVlans", {}) + + # Get all configured sources and sources with VLANs allocated + configured_sources = set(dynamic_vlans.keys()) + sources_with_vlans = {source for source, data in dynamic_vlans.items() if data.get("vlanIds")} + expected_sources = set(self.inputs.sources) + + # Check if all specified sources exist in configuration + missing_sources = expected_sources - configured_sources + if missing_sources: + self.result.is_failure(f"Dynamic VLAN source(s) not found in configuration: {', '.join(sorted(missing_sources))}") + return + + # Check if configured sources have VLANs allocated + sources_without_vlans = expected_sources - sources_with_vlans + if sources_without_vlans: + self.result.is_failure(f"Dynamic VLAN source(s) exist but have no VLANs allocated: {', '.join(sorted(sources_without_vlans))}") + return + + # In strict mode, verify no other sources have VLANs allocated + if self.inputs.strict: + unexpected_sources = sources_with_vlans - expected_sources + if unexpected_sources: + self.result.is_failure(f"Strict mode enabled: Unexpected sources have VLANs allocated: {', '.join(sorted(unexpected_sources))}") + + +class VerifyVlanStatus(AntaTest): + """Verifies the administrative status of specified VLANs. + + Expected Results + ---------------- + * Success: The test will pass if all specified VLANs exist in the configuration and their administrative status is correct. + * Failure: The test will fail if any of the specified VLANs is not found in the configuration or if its administrative status is incorrect. + + Examples + -------- + ```yaml + anta.tests.vlan: + - VerifyVlanStatus: + vlans: + - vlan_id: 10 + status: suspended + - vlan_id: 4094 + status: active + ``` + """ + + categories: ClassVar[list[str]] = ["vlan"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vlan", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyVlanStatus test.""" + + vlans: list[Vlan] + """List of VLAN details.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyVlanStatus.""" + self.result.is_success() + command_output = self.instance_commands[0].json_output + + for vlan in self.inputs.vlans: + if (vlan_detail := get_value(command_output, f"vlans.{vlan.vlan_id}")) is None: + self.result.is_failure(f"{vlan} - Not configured") + continue + + if (act_status := vlan_detail["status"]) != vlan.status: + self.result.is_failure(f"{vlan} - Incorrect administrative status - Expected: {vlan.status} Actual: {act_status}") diff --git a/anta/tests/vxlan.py b/anta/tests/vxlan.py index fe5381670..8c04d642e 100644 --- a/anta/tests/vxlan.py +++ b/anta/tests/vxlan.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Module related to VXLAN tests.""" @@ -12,7 +12,7 @@ from pydantic import Field -from anta.custom_types import Vlan, Vni, VxlanSrcIntf +from anta.custom_types import VlanId, Vni, VxlanSrcIntf from anta.models import AntaCommand, AntaTest from anta.tools import get_value @@ -21,10 +21,10 @@ class VerifyVxlan1Interface(AntaTest): - """Verifies if the Vxlan1 interface is configured and 'up/up'. + """Verifies the Vxlan1 interface status. - Warning - ------- + Warnings + -------- The name of this test has been updated from 'VerifyVxlan' for better representation. Expected Results @@ -41,31 +41,30 @@ class VerifyVxlan1Interface(AntaTest): ``` """ - name = "VerifyVxlan1Interface" - description = "Verifies the Vxlan1 interface status." categories: ClassVar[list[str]] = ["vxlan"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces description", revision=1)] @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyVxlan1Interface.""" + self.result.is_success() command_output = self.instance_commands[0].json_output - if "Vxlan1" not in command_output["interfaceDescriptions"]: - self.result.is_skipped("Vxlan1 interface is not configured") - elif ( - command_output["interfaceDescriptions"]["Vxlan1"]["lineProtocolStatus"] == "up" - and command_output["interfaceDescriptions"]["Vxlan1"]["interfaceStatus"] == "up" - ): - self.result.is_success() - else: - self.result.is_failure( - f"Vxlan1 interface is {command_output['interfaceDescriptions']['Vxlan1']['lineProtocolStatus']}" - f"/{command_output['interfaceDescriptions']['Vxlan1']['interfaceStatus']}", - ) + + # Skipping the test if the Vxlan1 interface is not configured + if "Vxlan1" not in (interface_details := command_output["interfaceDescriptions"]): + self.result.is_skipped("Interface: Vxlan1 - Not configured") + return + + line_protocol_status = interface_details["Vxlan1"]["lineProtocolStatus"] + interface_status = interface_details["Vxlan1"]["interfaceStatus"] + + # Checking against both status and line protocol status + if interface_status != "up" or line_protocol_status != "up": + self.result.is_failure(f"Interface: Vxlan1 - Incorrect Line protocol status/Status - Expected: up/up Actual: {line_protocol_status}/{interface_status}") class VerifyVxlanConfigSanity(AntaTest): - """Verifies that no issues are detected with the VXLAN configuration. + """Verifies there are no VXLAN config-sanity inconsistencies. Expected Results ---------------- @@ -81,35 +80,33 @@ class VerifyVxlanConfigSanity(AntaTest): ``` """ - name = "VerifyVxlanConfigSanity" - description = "Verifies there are no VXLAN config-sanity inconsistencies." categories: ClassVar[list[str]] = ["vxlan"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan config-sanity", revision=1)] @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyVxlanConfigSanity.""" + self.result.is_success() command_output = self.instance_commands[0].json_output + + # Skipping the test if VXLAN is not configured if "categories" not in command_output or len(command_output["categories"]) == 0: self.result.is_skipped("VXLAN is not configured") return - failed_categories = { - category: content - for category, content in command_output["categories"].items() - if category in ["localVtep", "mlag", "pd"] and content["allCheckPass"] is not True - } - if len(failed_categories) > 0: - self.result.is_failure(f"VXLAN config sanity check is not passing: {failed_categories}") - else: - self.result.is_success() + + # Verifies the Vxlan config sanity + categories_to_check = ["localVtep", "mlag", "pd"] + for category in categories_to_check: + if not get_value(command_output, f"categories.{category}.allCheckPass"): + self.result.is_failure(f"Vxlan Category: {category} - Config sanity check is not passing") class VerifyVxlanVniBinding(AntaTest): - """Verifies the VNI-VLAN bindings of the Vxlan1 interface. + """Verifies the VNI-VLAN, VNI-VRF bindings of the Vxlan1 interface. Expected Results ---------------- - * Success: The test will pass if the VNI-VLAN bindings provided are properly configured. + * Success: The test will pass if the VNI-VLAN and VNI-VRF bindings provided are properly configured. * Failure: The test will fail if any VNI lacks bindings or if any bindings are incorrect. * Skipped: The test will be skipped if the Vxlan1 interface is not configured. @@ -121,54 +118,51 @@ class VerifyVxlanVniBinding(AntaTest): bindings: 10010: 10 10020: 20 + 500: PROD ``` """ - name = "VerifyVxlanVniBinding" - description = "Verifies the VNI-VLAN bindings of the Vxlan1 interface." categories: ClassVar[list[str]] = ["vxlan"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan vni", revision=1)] class Input(AntaTest.Input): """Input model for the VerifyVxlanVniBinding test.""" - bindings: dict[Vni, Vlan] - """VNI to VLAN bindings to verify.""" + bindings: dict[Vni, VlanId | str] + """VNI-VLAN or VNI-VRF bindings to verify.""" @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyVxlanVniBinding.""" self.result.is_success() - no_binding = [] - wrong_binding = [] - if (vxlan1 := get_value(self.instance_commands[0].json_output, "vxlanIntfs.Vxlan1")) is None: - self.result.is_skipped("Vxlan1 interface is not configured") + self.result.is_skipped("Interface: Vxlan1 - Not configured") return - for vni, vlan in self.inputs.bindings.items(): + for vni, vlan_vrf in self.inputs.bindings.items(): str_vni = str(vni) - if str_vni in vxlan1["vniBindings"]: - retrieved_vlan = vxlan1["vniBindings"][str_vni]["vlan"] + retrieved_vlan = "" + retrieved_vrf = "" + if all([str_vni in vxlan1["vniBindings"], isinstance(vlan_vrf, int)]): + retrieved_vlan = get_value(vxlan1, f"vniBindings..{str_vni}..vlan", separator="..") elif str_vni in vxlan1["vniBindingsToVrf"]: - retrieved_vlan = vxlan1["vniBindingsToVrf"][str_vni]["vlan"] - else: - no_binding.append(str_vni) - retrieved_vlan = None - - if retrieved_vlan and vlan != retrieved_vlan: - wrong_binding.append({str_vni: retrieved_vlan}) + if isinstance(vlan_vrf, int): + retrieved_vlan = get_value(vxlan1, f"vniBindingsToVrf..{str_vni}..vlan", separator="..") + else: + retrieved_vrf = get_value(vxlan1, f"vniBindingsToVrf..{str_vni}..vrfName", separator="..") + if not any([retrieved_vlan, retrieved_vrf]): + self.result.is_failure(f"Interface: Vxlan1 VNI: {str_vni} - Binding not found") - if no_binding: - self.result.is_failure(f"The following VNI(s) have no binding: {no_binding}") + elif retrieved_vlan and vlan_vrf != retrieved_vlan: + self.result.is_failure(f"Interface: Vxlan1 VNI: {str_vni} - Wrong VLAN binding - Expected: {vlan_vrf} Actual: {retrieved_vlan}") - if wrong_binding: - self.result.is_failure(f"The following VNI(s) have the wrong VLAN binding: {wrong_binding}") + elif retrieved_vrf and vlan_vrf != retrieved_vrf: + self.result.is_failure(f"Interface: Vxlan1 VNI: {str_vni} - Wrong VRF binding - Expected: {vlan_vrf} Actual: {retrieved_vrf}") class VerifyVxlanVtep(AntaTest): - """Verifies the VTEP peers of the Vxlan1 interface. + """Verifies Vxlan1 VTEP peers. Expected Results ---------------- @@ -187,8 +181,6 @@ class VerifyVxlanVtep(AntaTest): ``` """ - name = "VerifyVxlanVtep" - description = "Verifies the VTEP peers of the Vxlan1 interface" categories: ClassVar[list[str]] = ["vxlan"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan vtep", revision=1)] @@ -206,21 +198,21 @@ def test(self) -> None: inputs_vteps = [str(input_vtep) for input_vtep in self.inputs.vteps] if (vxlan1 := get_value(self.instance_commands[0].json_output, "interfaces.Vxlan1")) is None: - self.result.is_skipped("Vxlan1 interface is not configured") + self.result.is_skipped("Interface: Vxlan1 - Not configured") return difference1 = set(inputs_vteps).difference(set(vxlan1["vteps"])) difference2 = set(vxlan1["vteps"]).difference(set(inputs_vteps)) if difference1: - self.result.is_failure(f"The following VTEP peer(s) are missing from the Vxlan1 interface: {sorted(difference1)}") + self.result.is_failure(f"The following VTEP peer(s) are missing from the Vxlan1 interface: {', '.join(sorted(difference1))}") if difference2: - self.result.is_failure(f"Unexpected VTEP peer(s) on Vxlan1 interface: {sorted(difference2)}") + self.result.is_failure(f"Unexpected VTEP peer(s) on Vxlan1 interface: {', '.join(sorted(difference2))}") class VerifyVxlan1ConnSettings(AntaTest): - """Verifies the interface vxlan1 source interface and UDP port. + """Verifies Vxlan1 source interface and UDP port. Expected Results ---------------- @@ -238,8 +230,6 @@ class VerifyVxlan1ConnSettings(AntaTest): ``` """ - name = "VerifyVxlan1ConnSettings" - description = "Verifies the interface vxlan1 source interface and UDP port." categories: ClassVar[list[str]] = ["vxlan"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces", revision=1)] @@ -260,7 +250,7 @@ def test(self) -> None: # Skip the test case if vxlan1 interface is not configured vxlan_output = get_value(command_output, "interfaces.Vxlan1") if not vxlan_output: - self.result.is_skipped("Vxlan1 interface is not configured.") + self.result.is_skipped("Interface: Vxlan1 - Not configured") return src_intf = vxlan_output.get("srcIpIntf") @@ -268,6 +258,6 @@ def test(self) -> None: # Check vxlan1 source interface and udp port if src_intf != self.inputs.source_interface: - self.result.is_failure(f"Source interface is not correct. Expected `{self.inputs.source_interface}` as source interface but found `{src_intf}` instead.") + self.result.is_failure(f"Interface: Vxlan1 - Incorrect Source interface - Expected: {self.inputs.source_interface} Actual: {src_intf}") if port != self.inputs.udp_port: - self.result.is_failure(f"UDP port is not correct. Expected `{self.inputs.udp_port}` as UDP port but found `{port}` instead.") + self.result.is_failure(f"Interface: Vxlan1 - Incorrect UDP port - Expected: {self.inputs.udp_port} Actual: {port}") diff --git a/anta/tools.py b/anta/tools.py index d1d394a64..c7fd4ab65 100644 --- a/anta/tools.py +++ b/anta/tools.py @@ -1,11 +1,33 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. +# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Common functions used in ANTA tests.""" from __future__ import annotations -from typing import Any +import cProfile +import os +import pstats +import re +from functools import wraps +from time import perf_counter +from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast + +from anta.constants import ACRONYM_CATEGORIES +from anta.custom_types import REGEXP_PATH_MARKERS +from anta.logger import format_td + +if TYPE_CHECKING: + import sys + from logging import Logger + from types import TracebackType + + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + +F = TypeVar("F", bound=Callable[..., Any]) def get_failed_logs(expected_output: dict[Any, Any], actual_output: dict[Any, Any]) -> str: @@ -13,14 +35,17 @@ def get_failed_logs(expected_output: dict[Any, Any], actual_output: dict[Any, An Returns the failed log or an empty string if there is no difference between the expected and actual output. - Args: - ---- - expected_output (dict): Expected output of a test. - actual_output (dict): Actual output of a test + Parameters + ---------- + expected_output + Expected output of a test. + actual_output + Actual output of a test Returns ------- - str: Failed log of a test. + str + Failed log of a test. """ failed_logs = [] @@ -28,15 +53,38 @@ def get_failed_logs(expected_output: dict[Any, Any], actual_output: dict[Any, An for element, expected_data in expected_output.items(): actual_data = actual_output.get(element) + if actual_data == expected_data: + continue if actual_data is None: failed_logs.append(f"\nExpected `{expected_data}` as the {element}, but it was not found in the actual output.") - elif actual_data != expected_data: - failed_logs.append(f"\nExpected `{expected_data}` as the {element}, but found `{actual_data}` instead.") + continue + # actual_data != expected_data: and actual_data is not None + failed_logs.append(f"\nExpected `{expected_data}` as the {element}, but found `{actual_data}` instead.") return "".join(failed_logs) -# pylint: disable=too-many-arguments +def custom_division(numerator: float, denominator: float) -> int | float: + """Get the custom division of numbers. + + Custom division that returns an integer if the result is an integer, otherwise a float. + + Parameters + ---------- + numerator + The numerator. + denominator + The denominator. + + Returns + ------- + Union[int, float] + The result of the division. + """ + result = numerator / denominator + return int(result) if result.is_integer() else result + + def get_dict_superset( list_of_dicts: list[dict[Any, Any]], input_dict: dict[Any, Any], @@ -46,8 +94,7 @@ def get_dict_superset( *, required: bool = False, ) -> Any: - """ - Get the first dictionary from a list of dictionaries that is a superset of the input dict. + """Get the first dictionary from a list of dictionaries that is a superset of the input dict. Returns the supplied default value or None if there is no match and "required" is False. @@ -96,7 +143,6 @@ def get_dict_superset( return default -# pylint: disable=too-many-arguments def get_value( dictionary: dict[Any, Any], key: str, @@ -153,7 +199,6 @@ def get_value( return value -# pylint: disable=too-many-arguments def get_item( list_of_dicts: list[dict[Any, Any]], key: Any, @@ -228,3 +273,187 @@ def get_item( if required is True: raise ValueError(custom_error_msg or var_name) return default + + +class Catchtime: + """A class working as a context to capture time differences.""" + + start: float + raw_time: float + time: str + + def __init__(self, logger: Logger | None = None, message: str | None = None) -> None: + self.logger = logger + self.message = message + + def __enter__(self) -> Self: + """__enter__ method.""" + self.start = perf_counter() + if self.logger and self.message: + self.logger.debug("%s ...", self.message) + return self + + def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> None: + """__exit__ method.""" + self.raw_time = perf_counter() - self.start + self.time = format_td(self.raw_time, 3) + if self.logger and self.message: + self.logger.debug("%s completed in: %s.", self.message, self.time) + + +def cprofile(sort_by: str = "cumtime") -> Callable[[F], F]: + """Profile a function with cProfile. + + profile is conditionally enabled based on the presence of ANTA_CPROFILE environment variable. + Expect to decorate an async function. + + Parameters + ---------- + sort_by + The criterion to sort the profiling results. Default is 'cumtime'. + + Returns + ------- + Callable + The decorated function with conditional profiling. + """ + + def decorator(func: F) -> F: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + """Enable cProfile or not. + + If `ANTA_CPROFILE` is set, cProfile is enabled and dumps the stats to the file. + + Parameters + ---------- + *args + Arbitrary positional arguments. + **kwargs + Arbitrary keyword arguments. + + Returns + ------- + Any + The result of the function call. + """ + cprofile_file = os.environ.get("ANTA_CPROFILE") + + if cprofile_file is not None: + profiler = cProfile.Profile() + profiler.enable() + + try: + result = await func(*args, **kwargs) + finally: + if cprofile_file is not None: + profiler.disable() + stats = pstats.Stats(profiler).sort_stats(sort_by) + stats.dump_stats(cprofile_file) + + return result + + return cast("F", wrapper) + + return decorator + + +def safe_command(command: str) -> str: + """Return a sanitized command. + + Parameters + ---------- + command + The command to sanitize. + + Returns + ------- + str + The sanitized command. + """ + return re.sub(rf"{REGEXP_PATH_MARKERS}", "_", command) + + +def convert_categories(categories: list[str]) -> list[str]: + """Convert categories for reports. + + If the category is part of the defined acronym, transform it to upper case + otherwise capitalize the first letter. + + Parameters + ---------- + categories + A list of categories + + Returns + ------- + list[str] + The list of converted categories + """ + if isinstance(categories, list): + return [" ".join(word.upper() if word.lower() in ACRONYM_CATEGORIES else word.title() for word in category.split()) for category in categories] + msg = f"Wrong input type '{type(categories)}' for convert_categories." + raise TypeError(msg) + + +def format_data(data: dict[str, bool]) -> str: + """Format a data dictionary for logging purposes. + + Parameters + ---------- + data + A dictionary containing the data to format. + + Returns + ------- + str + The formatted data. + + Example + ------- + >>> format_data({"advertised": True, "received": True, "enabled": True}) + "Advertised: True, Received: True, Enabled: True" + """ + return ", ".join(f"{k.capitalize()}: {v}" for k, v in data.items()) + + +def is_interface_ignored(interface: str, ignored_interfaces: list[str] | None = None) -> bool | None: + """Verify if an interface is present in the ignored interfaces list. + + Parameters + ---------- + interface + This is a string containing the interface name. + ignored_interfaces + A list containing the interfaces or interface types to ignore. + + Returns + ------- + bool + True if the interface is in the list of ignored interfaces, false otherwise. + Example + ------- + ```python + >>> _is_interface_ignored(interface="Ethernet1", ignored_interfaces=["Ethernet", "Port-Channel1"]) + True + >>> _is_interface_ignored(interface="Ethernet2", ignored_interfaces=["Ethernet1", "Port-Channel"]) + False + >>> _is_interface_ignored(interface="Port-Channel1", ignored_interfaces=["Ethernet1", "Port-Channel"]) + True + >>> _is_interface_ignored(interface="Ethernet1/1", ignored_interfaces: ["Ethernet1/1", "Port-Channel"]) + True + >>> _is_interface_ignored(interface="Ethernet1/1", ignored_interfaces: ["Ethernet1", "Port-Channel"]) + False + >>> _is_interface_ignored(interface="Ethernet1.100", ignored_interfaces: ["Ethernet1.100", "Port-Channel"]) + True + ``` + """ + interface_prefix = re.findall(r"^[a-zA-Z-]+", interface, re.IGNORECASE)[0] + interface_exact_match = False + if ignored_interfaces: + for ignored_interface in ignored_interfaces: + if interface == ignored_interface: + interface_exact_match = True + break + return bool(any([interface_exact_match, interface_prefix in ignored_interfaces])) + return None diff --git a/asynceapi/__init__.py b/asynceapi/__init__.py new file mode 100644 index 000000000..fedb07f8e --- /dev/null +++ b/asynceapi/__init__.py @@ -0,0 +1,12 @@ +# Copyright (c) 2024-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +# Initially written by Jeremy Schulman at https://github.com/jeremyschulman/aio-eapi + +"""Arista EOS eAPI asyncio client.""" + +from .config_session import SessionConfig +from .device import Device +from .errors import EapiCommandError + +__all__ = ["Device", "EapiCommandError", "SessionConfig"] diff --git a/asynceapi/_constants.py b/asynceapi/_constants.py new file mode 100644 index 000000000..2904038a3 --- /dev/null +++ b/asynceapi/_constants.py @@ -0,0 +1,22 @@ +# Copyright (c) 2024-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Constants and Enums for the asynceapi package.""" + +from __future__ import annotations + +from enum import Enum + + +class EapiCommandFormat(str, Enum): + """Enum for the eAPI command format. + + NOTE: This could be updated to StrEnum when Python 3.11 is the minimum supported version in ANTA. + """ + + JSON = "json" + TEXT = "text" + + def __str__(self) -> str: + """Override the __str__ method to return the value of the Enum, mimicking the behavior of StrEnum.""" + return self.value diff --git a/asynceapi/_errors.py b/asynceapi/_errors.py new file mode 100644 index 000000000..321843dbe --- /dev/null +++ b/asynceapi/_errors.py @@ -0,0 +1,36 @@ +# Copyright (c) 2024-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Exceptions for the asynceapi package.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from asynceapi._models import EapiResponse + + +class EapiReponseError(Exception): + """Exception raised when an eAPI response contains errors. + + Attributes + ---------- + response : EapiResponse + The eAPI response that contains the error. + """ + + def __init__(self, response: EapiResponse) -> None: + """Initialize the EapiReponseError exception.""" + self.response = response + + # Build a descriptive error message + message = "Error in eAPI response" + + if response.error_code is not None: + message += f" (code: {response.error_code})" + + if response.error_message is not None: + message += f": {response.error_message}" + + super().__init__(message) diff --git a/asynceapi/_models.py b/asynceapi/_models.py new file mode 100644 index 000000000..21ad661c1 --- /dev/null +++ b/asynceapi/_models.py @@ -0,0 +1,237 @@ +# Copyright (c) 2024-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Models for the asynceapi package.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from logging import getLogger +from typing import TYPE_CHECKING, Any, Literal +from uuid import uuid4 + +from ._constants import EapiCommandFormat +from ._errors import EapiReponseError + +if TYPE_CHECKING: + from collections.abc import Iterator + + from ._types import EapiComplexCommand, EapiJsonOutput, EapiSimpleCommand, EapiTextOutput, JsonRpc + +LOGGER = getLogger(__name__) + + +@dataclass(frozen=True) +class EapiRequest: + """Model for an eAPI request. + + Attributes + ---------- + commands : list[EapiSimpleCommand | EapiComplexCommand] + A list of commands to execute. + version : int | Literal["latest"] + The eAPI version to use. Defaults to "latest". + format : EapiCommandFormat + The command output format. Defaults "json". + timestamps : bool + Include timestamps in the command output. Defaults to False. + auto_complete : bool + Enable command auto-completion. Defaults to False. + expand_aliases : bool + Expand command aliases. Defaults to False. + stop_on_error : bool + Stop command execution on first error. Defaults to True. + id : int | str + The request ID. Defaults to a random hex string. + """ + + commands: list[EapiSimpleCommand | EapiComplexCommand] + version: int | Literal["latest"] = "latest" + format: EapiCommandFormat = EapiCommandFormat.JSON + timestamps: bool = False + auto_complete: bool = False + expand_aliases: bool = False + stop_on_error: bool = True + id: int | str = field(default_factory=lambda: uuid4().hex) + + def to_jsonrpc(self) -> JsonRpc: + """Return the JSON-RPC dictionary payload for the request.""" + return { + "jsonrpc": "2.0", + "method": "runCmds", + "params": { + "version": self.version, + "cmds": self.commands, + "format": self.format, + "timestamps": self.timestamps, + "autoComplete": self.auto_complete, + "expandAliases": self.expand_aliases, + "stopOnError": self.stop_on_error, + }, + "id": self.id, + } + + +@dataclass(frozen=True) +class EapiResponse: + """Model for an eAPI response. + + Construct an EapiResponse from a JSON-RPC response dictionary using the `from_jsonrpc` class method. + + Can be iterated over to access command results in order of execution. + + Attributes + ---------- + request_id : str + The ID of the original request this response corresponds to. + _results : dict[int, EapiCommandResult] + Dictionary mapping request command indices to their respective results. + error_code : int | None + The JSON-RPC error code, if any. + error_message : str | None + The JSON-RPC error message, if any. + """ + + request_id: str + _results: dict[int, EapiCommandResult] = field(default_factory=dict) + error_code: int | None = None + error_message: str | None = None + + @property + def success(self) -> bool: + """Return True if the response has no errors.""" + return self.error_code is None + + @property + def results(self) -> list[EapiCommandResult]: + """Get all results as a list. Results are ordered by the command indices in the request.""" + return list(self._results.values()) + + def __len__(self) -> int: + """Return the number of results.""" + return len(self._results) + + def __iter__(self) -> Iterator[EapiCommandResult]: + """Enable iteration over the results. Results are yielded in the same order as provided in the request.""" + yield from self._results.values() + + @classmethod + def from_jsonrpc(cls, response: dict[str, Any], request: EapiRequest, *, raise_on_error: bool = False) -> EapiResponse: + """Build an EapiResponse from a JSON-RPC eAPI response. + + Parameters + ---------- + response + The JSON-RPC eAPI response dictionary. + request + The corresponding EapiRequest. + raise_on_error + Raise an EapiReponseError if the response contains errors, by default False. + + Returns + ------- + EapiResponse + The EapiResponse object. + """ + has_error = "error" in response + response_data = response["error"]["data"] if has_error else response["result"] + + # Handle case where we have fewer results than commands (stop_on_error=True) + executed_count = min(len(response_data), len(request.commands)) + + # Process the results we have + results = {} + for i in range(executed_count): + cmd = request.commands[i] + cmd_str = cmd["cmd"] if isinstance(cmd, dict) else cmd + data = response_data[i] + + output = None + errors = [] + success = True + start_time = None + duration = None + + # Parse the output based on the data type, no output when errors are present + if isinstance(data, dict): + if "errors" in data: + errors = data["errors"] + success = False + else: + output = data["output"] if request.format == EapiCommandFormat.TEXT and "output" in data else data + + # Add timestamps if available + if request.timestamps and "_meta" in data: + meta = data.pop("_meta") + start_time = meta.get("execStartTime") + duration = meta.get("execDuration") + + elif isinstance(data, str): + # Handle case where eAPI returns a JSON string response (serialized JSON) for certain commands + try: + from json import JSONDecodeError, loads + + output = loads(data) + except (JSONDecodeError, TypeError): + # If it's not valid JSON, store as is + LOGGER.warning("Invalid JSON response for command: %s. Storing as text: %s", cmd_str, data) + output = data + + results[i] = EapiCommandResult( + command=cmd_str, + output=output, + errors=errors, + success=success, + start_time=start_time, + duration=duration, + ) + + # If stop_on_error is True and we have an error, indicate commands not executed + if has_error and request.stop_on_error and executed_count < len(request.commands): + for i in range(executed_count, len(request.commands)): + cmd = request.commands[i] + cmd_str = cmd["cmd"] if isinstance(cmd, dict) else cmd + results[i] = EapiCommandResult(command=cmd_str, output=None, errors=["Command not executed due to previous error"], success=False, executed=False) + + response_obj = cls( + request_id=response["id"], + _results=results, + error_code=response["error"]["code"] if has_error else None, + error_message=response["error"]["message"] if has_error else None, + ) + + if raise_on_error and has_error: + raise EapiReponseError(response_obj) + + return response_obj + + +@dataclass(frozen=True) +class EapiCommandResult: + """Model for an eAPI command result. + + Attributes + ---------- + command : str + The command that was executed. + output : EapiJsonOutput | EapiTextOutput | None + The command result output. None if the command returned errors. + errors : list[str] + A list of error messages, if any. + success : bool + True if the command was successful. + executed : bool + True if the command was executed. When `stop_on_error` is True in the request, some commands may not be executed. + start_time : float | None + Command execution start time in seconds. Uses Unix epoch format. `timestamps` must be True in the request. + duration : float | None + Command execution duration in seconds. `timestamps` must be True in the request. + """ + + command: str + output: EapiJsonOutput | EapiTextOutput | None + errors: list[str] = field(default_factory=list) + success: bool = True + executed: bool = True + start_time: float | None = None + duration: float | None = None diff --git a/asynceapi/_types.py b/asynceapi/_types.py new file mode 100644 index 000000000..ebebf04b5 --- /dev/null +++ b/asynceapi/_types.py @@ -0,0 +1,53 @@ +# Copyright (c) 2024-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Type definitions used for the asynceapi package.""" + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from ._constants import EapiCommandFormat + +if sys.version_info >= (3, 11): + from typing import NotRequired, TypedDict +else: + from typing_extensions import NotRequired, TypedDict + +EapiJsonOutput = dict[str, Any] +"""Type definition of an eAPI JSON output response.""" +EapiTextOutput = str +"""Type definition of an eAPI text output response.""" +EapiSimpleCommand = str +"""Type definition of an eAPI simple command. A simple command is the CLI command to run as a string.""" + + +class EapiComplexCommand(TypedDict): + """Type definition of an eAPI complex command. A complex command is a dictionary with the CLI command to run with additional parameters.""" + + cmd: str + input: NotRequired[str] + revision: NotRequired[int] + + +class JsonRpc(TypedDict): + """Type definition of a JSON-RPC payload.""" + + jsonrpc: Literal["2.0"] + method: Literal["runCmds"] + params: JsonRpcParams + id: NotRequired[int | str] + + +class JsonRpcParams(TypedDict): + """Type definition of JSON-RPC parameters.""" + + version: NotRequired[int | Literal["latest"]] + cmds: list[EapiSimpleCommand | EapiComplexCommand] + format: NotRequired[EapiCommandFormat] + autoComplete: NotRequired[bool] + expandAliases: NotRequired[bool] + timestamps: NotRequired[bool] + stopOnError: NotRequired[bool] diff --git a/asynceapi/aio_portcheck.py b/asynceapi/aio_portcheck.py new file mode 100644 index 000000000..be9a79fe2 --- /dev/null +++ b/asynceapi/aio_portcheck.py @@ -0,0 +1,62 @@ +# Copyright (c) 2024-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +# Initially written by Jeremy Schulman at https://github.com/jeremyschulman/aio-eapi +"""Utility function to check if a port is open.""" +# ----------------------------------------------------------------------------- +# System Imports +# ----------------------------------------------------------------------------- + +from __future__ import annotations + +import asyncio +import socket +from typing import TYPE_CHECKING + +# ----------------------------------------------------------------------------- +# Public Imports +# ----------------------------------------------------------------------------- + +if TYPE_CHECKING: + from httpx import URL + +# ----------------------------------------------------------------------------- +# Exports +# ----------------------------------------------------------------------------- + +__all__ = ["port_check_url"] + +# ----------------------------------------------------------------------------- +# +# CODE BEGINS +# +# ----------------------------------------------------------------------------- + + +async def port_check_url(url: URL, timeout: int = 5) -> bool: + """Open the port designated by the URL given the timeout in seconds. + + Parameters + ---------- + url + The URL that provides the target system. + timeout + Time to await for the port to open in seconds. + + Returns + ------- + bool + If the port is available then return True; False otherwise. + """ + port = url.port or socket.getservbyname(url.scheme) + + try: + wr: asyncio.StreamWriter + _, wr = await asyncio.wait_for(asyncio.open_connection(host=url.host, port=port), timeout=timeout) + + # MUST close if opened! + wr.close() + + except TimeoutError: + return False + return True diff --git a/asynceapi/config_session.py b/asynceapi/config_session.py new file mode 100644 index 000000000..e5e1d0851 --- /dev/null +++ b/asynceapi/config_session.py @@ -0,0 +1,299 @@ +# Copyright (c) 2024-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +# Initially written by Jeremy Schulman at https://github.com/jeremyschulman/aio-eapi +"""asynceapi.SessionConfig definition.""" + +# ----------------------------------------------------------------------------- +# System Imports +# ----------------------------------------------------------------------------- +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ._types import EapiComplexCommand, EapiJsonOutput, EapiSimpleCommand + from .device import Device + +# ----------------------------------------------------------------------------- +# Exports +# ----------------------------------------------------------------------------- + +__all__ = ["SessionConfig"] + +# ----------------------------------------------------------------------------- +# +# CODE BEGINS +# +# ----------------------------------------------------------------------------- + + +class SessionConfig: + """Send configuration to a device using the EOS session mechanism. + + This is the preferred way of managing configuration changes. + + Notes + ----- + This class definition is used by the parent Device class definition as + defined by `config_session`. A Caller can use the SessionConfig directly + as well, but it is not required. + """ + + CLI_CFG_FACTORY_RESET = "rollback clean-config" + + def __init__(self, device: Device, name: str) -> None: + """Create a new instance of SessionConfig. + + The session config instance bound + to the given device instance, and using the session `name`. + + Parameters + ---------- + device + The associated device instance. + name + The name of the config session. + """ + self._device = device + self._cli = device.cli + self._name = name + self._cli_config_session = f"configure session {self.name}" + + # ------------------------------------------------------------------------- + # properties for read-only attributes + # ------------------------------------------------------------------------- + + @property + def name(self) -> str: + """Return read-only session name attribute.""" + return self._name + + @property + def device(self) -> Device: + """Return read-only device instance attribute.""" + return self._device + + # ------------------------------------------------------------------------- + # Public Methods + # ------------------------------------------------------------------------- + + async def status_all(self) -> EapiJsonOutput: + """Get the status of all the session config on the device. + + Run the following command on the device: + # show configuration sessions detail + + Returns + ------- + EapiJsonOutput + Dictionary of native EOS eAPI response; see `status` method for + details. + + Examples + -------- + Return example: + + ``` + { + "maxSavedSessions": 1, + "maxOpenSessions": 5, + "sessions": { + "jeremy1": { + "instances": {}, + "state": "pending", + "commitUser": "", + "description": "" + }, + "ansible_167510439362": { + "instances": {}, + "state": "completed", + "commitUser": "joe.bob", + "description": "", + "completedTime": 1675104396.4500246 + } + } + } + ``` + """ + return await self._cli(command="show configuration sessions detail") + + async def status(self) -> EapiJsonOutput | None: + """Get the status of a session config on the device. + + Run the following command on the device: + # show configuration sessions detail + + And return only the status dictionary for this session. If you want + all sessions, then use the `status_all` method. + + Returns + ------- + EapiJsonOutput | None + Dictionary instance of the session status. If the session does not exist, + then this method will return None. + + Examples + -------- + The return is the native eAPI results from JSON output: + + ``` + all results: + { + "maxSavedSessions": 1, + "maxOpenSessions": 5, + "sessions": { + "jeremy1": { + "instances": {}, + "state": "pending", + "commitUser": "", + "description": "" + }, + "ansible_167510439362": { + "instances": {}, + "state": "completed", + "commitUser": "joe.bob", + "description": "", + "completedTime": 1675104396.4500246 + } + } + } + ``` + + If the session name was 'jeremy1', then this method would return: + + ``` + { + "instances": {}, + "state": "pending", + "commitUser": "", + "description": "" + } + ``` + """ + res = await self.status_all() + return res["sessions"].get(self.name) + + async def push(self, content: list[str] | str, *, replace: bool = False) -> None: + """Send the configuration content to the device. + + If `replace` is true, then the command "rollback clean-config" is issued + before sending the configuration content. + + Parameters + ---------- + content + The text configuration CLI commands, as a list of strings, that + will be sent to the device. If the parameter is a string, and not + a list, then split the string across linebreaks. In either case + any empty lines will be discarded before they are send to the + device. + replace + When True, the content will replace the existing configuration + on the device. + """ + # if given s string, we need to break it up into individual command + # lines. + + if isinstance(content, str): + content = content.splitlines() + + # prepare the initial set of command to enter the config session and + # rollback clean if the `replace` argument is True. + + commands: list[EapiSimpleCommand | EapiComplexCommand] = [self._cli_config_session] + if replace: + commands.append(self.CLI_CFG_FACTORY_RESET) + + # add the Caller's commands, filtering out any blank lines. any command + # lines (!) are still included. + + commands.extend(filter(None, content)) + + await self._cli(commands=commands) + + async def commit(self, timer: str | None = None) -> None: + """Commit the session config. + + Run the following command on the device: + # configure session + # commit + + Parameters + ---------- + timer + If the timer is specified, format is "hh:mm:ss", then a commit timer is + started. A second commit action must be made to confirm the config + session before the timer expires; otherwise the config-session is + automatically aborted. + """ + command = f"{self._cli_config_session} commit" + + if timer: + command += f" timer {timer}" + + await self._cli(command=command) + + async def abort(self) -> None: + """Abort the configuration session. + + Run the following command on the device: + # configure session abort + """ + await self._cli(command=f"{self._cli_config_session} abort") + + async def diff(self) -> str: + """Return the "diff" of the session config relative to the running config. + + Run the following command on the device: + # show session-config named diffs + + Returns + ------- + str + Return a string in diff-patch format. + + References + ---------- + * https://www.gnu.org/software/diffutils/manual/diffutils.txt + """ + return await self._cli(command=f"show session-config named {self.name} diffs", ofmt="text") + + async def load_file(self, filename: str, *, replace: bool = False) -> None: + """Load the configuration from into the session configuration. + + If the replace parameter is True then the file contents will replace the existing session config (load-replace). + + Parameters + ---------- + filename + The name of the configuration file. The caller is required to + specify the filesystem, for example, the + filename="flash:thisfile.cfg". + + replace + When True, the contents of the file will completely replace the + session config for a load-replace behavior. + + Raises + ------ + RuntimeError + If there are any issues with loading the configuration file then a + RuntimeError is raised with the error messages content. + """ + commands: list[EapiSimpleCommand | EapiComplexCommand] = [self._cli_config_session] + if replace: + commands.append(self.CLI_CFG_FACTORY_RESET) + + commands.append(f"copy {filename} session-config") + res = await self._cli(commands=commands) + checks_re = re.compile(r"error|abort|invalid", flags=re.IGNORECASE) + messages = res[-1]["messages"] + + if any(map(checks_re.search, messages)): + raise RuntimeError("".join(messages)) + + async def write(self) -> None: + """Save the running config to the startup config by issuing the command "write" to the device.""" + await self._cli(command="write") diff --git a/asynceapi/device.py b/asynceapi/device.py new file mode 100644 index 000000000..32529e5e5 --- /dev/null +++ b/asynceapi/device.py @@ -0,0 +1,492 @@ +# Copyright (c) 2024-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +# Initially written by Jeremy Schulman at https://github.com/jeremyschulman/aio-eapi +"""asynceapi.Device definition.""" +# ----------------------------------------------------------------------------- +# System Imports +# ----------------------------------------------------------------------------- + +from __future__ import annotations + +from socket import getservbyname +from typing import TYPE_CHECKING, Any, Literal, overload + +# ----------------------------------------------------------------------------- +# Public Imports +# ----------------------------------------------------------------------------- +import httpx +from typing_extensions import deprecated + +# ----------------------------------------------------------------------------- +# Private Imports +# ----------------------------------------------------------------------------- +from ._constants import EapiCommandFormat +from .aio_portcheck import port_check_url +from .config_session import SessionConfig +from .errors import EapiCommandError + +if TYPE_CHECKING: + from ._types import EapiComplexCommand, EapiJsonOutput, EapiSimpleCommand, EapiTextOutput, JsonRpc + +# ----------------------------------------------------------------------------- +# Exports +# ----------------------------------------------------------------------------- + + +__all__ = ["Device"] + + +# ----------------------------------------------------------------------------- +# +# CODE BEGINS +# +# ----------------------------------------------------------------------------- + + +class Device(httpx.AsyncClient): + """Represent the async JSON-RPC client that communicates with an Arista EOS device. + + This class inherits directly from the + httpx.AsyncClient, so any initialization options can be passed directly. + """ + + EAPI_COMMAND_API_URL = "/command-api" + EAPI_OFMT_OPTIONS = ("json", "text") + EAPI_DEFAULT_OFMT = "json" + + def __init__( + self, + host: str | None = None, + username: str | None = None, + password: str | None = None, + proto: str = "https", + port: str | int | None = None, + **kwargs: Any, # noqa: ANN401 + ) -> None: + """Initialize the Device class. + + As a subclass to httpx.AsyncClient, the caller can provide any of those initializers. + Specific parameters for Device class are all optional and described below. + + Parameters + ---------- + host + The EOS target device, either hostname (DNS) or ipaddress. + username + The login user-name; requires the password parameter. + password + The login password; requires the username parameter. + proto + The protocol, http or https, to communicate eAPI with the device. + port + If not provided, the proto value is used to look up the associated + port (http=80, https=443). If provided, overrides the port used to + communite with the device. + kwargs + Other named keyword arguments, some of them are being used in the function + cf Other Parameters section below, others are just passed as is to the httpx.AsyncClient. + + Other Parameters + ---------------- + base_url : str + If provided, the complete URL to the device eAPI endpoint. + + auth : + If provided, used as the httpx authorization initializer value. If + not provided, then username+password is assumed by the Caller and + used to create a BasicAuth instance. + """ + self.port = port or getservbyname(proto) + self.host = host + kwargs.setdefault("base_url", httpx.URL(f"{proto}://{self.host}:{self.port}")) + kwargs.setdefault("verify", False) + + auth_object = httpx.BasicAuth(username, password) if username and password else None + kwargs.setdefault("auth", auth_object) + + super().__init__(**kwargs) + self.headers["Content-Type"] = "application/json-rpc" + + @deprecated("This method is deprecated, use `Device.check_api_endpoint` method instead. This will be removed in ANTA v2.0.0.", category=DeprecationWarning) + async def check_connection(self) -> bool: + """Check the target device to ensure that the eAPI port is open and accepting connections. + + It is recommended that a Caller checks the connection before involving cli commands, + but this step is not required. + + Returns + ------- + bool + True when the device eAPI is accessible, False otherwise. + """ + return await port_check_url(self.base_url) + + async def check_api_endpoint(self) -> bool: + """Check the target device eAPI HTTP endpoint with a HEAD request. + + It is recommended that a Caller checks the connection before involving cli commands, + but this step is not required. + + Returns + ------- + bool + True when the device eAPI HTTP endpoint is accessible (2xx status code), + otherwise an HTTPStatusError exception is raised. + """ + response = await self.head(self.EAPI_COMMAND_API_URL, timeout=5) + response.raise_for_status() + return True + + # Single command, JSON output, no suppression + @overload + async def cli( + self, + *, + command: EapiSimpleCommand | EapiComplexCommand, + commands: None = None, + ofmt: Literal["json"] = "json", + version: int | Literal["latest"] = "latest", + suppress_error: Literal[False] = False, + auto_complete: bool = False, + expand_aliases: bool = False, + req_id: int | str | None = None, + ) -> EapiJsonOutput: ... + + # Multiple commands, JSON output, no suppression + @overload + async def cli( + self, + *, + command: None = None, + commands: list[EapiSimpleCommand | EapiComplexCommand], + ofmt: Literal["json"] = "json", + version: int | Literal["latest"] = "latest", + suppress_error: Literal[False] = False, + auto_complete: bool = False, + expand_aliases: bool = False, + req_id: int | str | None = None, + ) -> list[EapiJsonOutput]: ... + + # Single command, TEXT output, no suppression + @overload + async def cli( + self, + *, + command: EapiSimpleCommand | EapiComplexCommand, + commands: None = None, + ofmt: Literal["text"], + version: int | Literal["latest"] = "latest", + suppress_error: Literal[False] = False, + auto_complete: bool = False, + expand_aliases: bool = False, + req_id: int | str | None = None, + ) -> EapiTextOutput: ... + + # Multiple commands, TEXT output, no suppression + @overload + async def cli( + self, + *, + command: None = None, + commands: list[EapiSimpleCommand | EapiComplexCommand], + ofmt: Literal["text"], + version: int | Literal["latest"] = "latest", + suppress_error: Literal[False] = False, + auto_complete: bool = False, + expand_aliases: bool = False, + req_id: int | str | None = None, + ) -> list[EapiTextOutput]: ... + + # Single command, JSON output, with suppression + @overload + async def cli( + self, + *, + command: EapiSimpleCommand | EapiComplexCommand, + commands: None = None, + ofmt: Literal["json"] = "json", + version: int | Literal["latest"] = "latest", + suppress_error: Literal[True], + auto_complete: bool = False, + expand_aliases: bool = False, + req_id: int | str | None = None, + ) -> EapiJsonOutput | None: ... + + # Multiple commands, JSON output, with suppression + @overload + async def cli( + self, + *, + command: None = None, + commands: list[EapiSimpleCommand | EapiComplexCommand], + ofmt: Literal["json"] = "json", + version: int | Literal["latest"] = "latest", + suppress_error: Literal[True], + auto_complete: bool = False, + expand_aliases: bool = False, + req_id: int | str | None = None, + ) -> list[EapiJsonOutput] | None: ... + + # Single command, TEXT output, with suppression + @overload + async def cli( + self, + *, + command: EapiSimpleCommand | EapiComplexCommand, + commands: None = None, + ofmt: Literal["text"], + version: int | Literal["latest"] = "latest", + suppress_error: Literal[True], + auto_complete: bool = False, + expand_aliases: bool = False, + req_id: int | str | None = None, + ) -> EapiTextOutput | None: ... + + # Multiple commands, TEXT output, with suppression + @overload + async def cli( + self, + *, + command: None = None, + commands: list[EapiSimpleCommand | EapiComplexCommand], + ofmt: Literal["text"], + version: int | Literal["latest"] = "latest", + suppress_error: Literal[True], + auto_complete: bool = False, + expand_aliases: bool = False, + req_id: int | str | None = None, + ) -> list[EapiTextOutput] | None: ... + + # Actual implementation + async def cli( + self, + command: EapiSimpleCommand | EapiComplexCommand | None = None, + commands: list[EapiSimpleCommand | EapiComplexCommand] | None = None, + ofmt: Literal["json", "text"] = "json", + version: int | Literal["latest"] = "latest", + *, + suppress_error: bool = False, + auto_complete: bool = False, + expand_aliases: bool = False, + req_id: int | str | None = None, + ) -> EapiJsonOutput | EapiTextOutput | list[EapiJsonOutput] | list[EapiTextOutput] | None: + """Execute one or more CLI commands. + + Parameters + ---------- + command + A single command to execute; results in a single output response. + commands + A list of commands to execute; results in a list of output responses. + ofmt + Either 'json' or 'text'; indicates the output format for the CLI commands. + eAPI defaults to 'json'. + version + By default the eAPI will use "version 1" for all API object models. + This driver will, by default, always set version to "latest" so + that the behavior matches the CLI of the device. The caller can + override the "latest" behavior by explicitly setting the version. + suppress_error + When not False, then if the execution of the command would-have + raised an EapiCommandError, rather than raising this exception this + routine will return the value None. + + For example, if the following command had raised + EapiCommandError, now response would be set to None instead. + + response = dev.cli(..., suppress_error=True) + auto_complete + Enabled/disables the command auto-compelete feature of the eAPI. Per the + documentation: + Allows users to use shorthand commands in eAPI calls. With this + parameter included a user can send 'sh ver' via eAPI to get the + output of 'show version'. + expand_aliases + Enables/disables the command use of user-defined alias. Per the + documentation: + Allowed users to provide the expandAliases parameter to eAPI + calls. This allows users to use aliased commands via the API. + For example if an alias is configured as 'sv' for 'show version' + then an API call with sv and the expandAliases parameter will + return the output of show version. + req_id + A unique identifier that will be echoed back by the switch. May be a string or number. + + Returns + ------- + dict[str, Any] + Single command, JSON output, suppress_error=False + list[dict[str, Any]] + Multiple commands, JSON output, suppress_error=False + str + Single command, TEXT output, suppress_error=False + list[str] + Multiple commands, TEXT output, suppress_error=False + dict[str, Any] | None + Single command, JSON output, suppress_error=True + list[dict[str, Any]] | None + Multiple commands, JSON output, suppress_error=True + str | None + Single command, TEXT output, suppress_error=True + list[str] | None + Multiple commands, TEXT output, suppress_error=True + """ + if not any((command, commands)): + msg = "Required 'command' or 'commands'" + raise RuntimeError(msg) + + jsonrpc = self._jsonrpc_command( + commands=[command] if command else commands if commands else [], + ofmt=ofmt, + version=version, + auto_complete=auto_complete, + expand_aliases=expand_aliases, + req_id=req_id, + ) + + try: + res = await self.jsonrpc_exec(jsonrpc) + return res[0] if command else res + except EapiCommandError: + if suppress_error: + return None + raise + + def _jsonrpc_command( + self, + commands: list[EapiSimpleCommand | EapiComplexCommand], + ofmt: Literal["json", "text"] = "json", + version: int | Literal["latest"] = "latest", + *, + auto_complete: bool = False, + expand_aliases: bool = False, + req_id: int | str | None = None, + ) -> JsonRpc: + """Create the JSON-RPC command dictionary object. + + Parameters + ---------- + commands + A list of commands to execute; results in a list of output responses. + ofmt + Either 'json' or 'text'; indicates the output format for the CLI commands. + eAPI defaults to 'json'. + version + By default the eAPI will use "version 1" for all API object models. + This driver will, by default, always set version to "latest" so + that the behavior matches the CLI of the device. The caller can + override the "latest" behavior by explicitly setting the version. + auto_complete + Enabled/disables the command auto-compelete feature of the EAPI. Per the + documentation: + Allows users to use shorthand commands in eAPI calls. With this + parameter included a user can send 'sh ver' via eAPI to get the + output of 'show version'. + expand_aliases + Enables/disables the command use of User defined alias. Per the + documentation: + Allowed users to provide the expandAliases parameter to eAPI + calls. This allows users to use aliased commands via the API. + For example if an alias is configured as 'sv' for 'show version' + then an API call with sv and the expandAliases parameter will + return the output of show version. + req_id + A unique identifier that will be echoed back by the switch. May be a string or number. + + Returns + ------- + dict[str, Any]: + dict containing the JSON payload to run the command. + + """ + return { + "jsonrpc": "2.0", + "method": "runCmds", + "params": { + "version": version, + "cmds": commands, + "format": EapiCommandFormat(ofmt), + "autoComplete": auto_complete, + "expandAliases": expand_aliases, + }, + "id": req_id or id(self), + } + + async def jsonrpc_exec(self, jsonrpc: JsonRpc) -> list[EapiJsonOutput] | list[EapiTextOutput]: + """Execute the JSON-RPC dictionary object. + + Parameters + ---------- + jsonrpc + The JSON-RPC as created by the `meth`:_jsonrpc_command(). + + Raises + ------ + EapiCommandError + In the event that a command resulted in an error response. + + Returns + ------- + list[dict[str, Any] | str] + The list of command results; either dict or text depending on the + JSON-RPC format parameter. + """ + res = await self.post(self.EAPI_COMMAND_API_URL, json=jsonrpc) + res.raise_for_status() + body = res.json() + + commands = jsonrpc["params"]["cmds"] + ofmt = jsonrpc["params"]["format"] + + get_output = (lambda _r: _r["output"]) if ofmt == "text" else (lambda _r: _r) + + # if there are no errors then return the list of command results. + if (err_data := body.get("error")) is None: + return [get_output(cmd_res) for cmd_res in body["result"]] + + # --------------------------------------------------------------------- + # if we are here, then there were some command errors. Raise a + # EapiCommandError exception with args (commands that failed, passed, + # not-executed). + # --------------------------------------------------------------------- + + # -------------------------- eAPI specification ---------------------- + # On an error, no result object is present, only an error object, which + # is guaranteed to have the following attributes: code, messages, and + # data. Similar to the result object in the successful response, the + # data object is a list of objects corresponding to the results of all + # commands up to, and including, the failed command. If there was a an + # error before any commands were executed (e.g. bad credentials), data + # will be empty. The last object in the data array will always + # correspond to the failed command. The command failure details are + # always stored in the errors array. + + cmd_data = err_data["data"] + len_data = len(cmd_data) + err_at = len_data - 1 + err_msg = err_data["message"] + failed_cmd = commands[err_at] + + raise EapiCommandError( + passed=[get_output(cmd_data[i]) for i in range(err_at)], + failed=failed_cmd["cmd"] if isinstance(failed_cmd, dict) else failed_cmd, + errors=cmd_data[err_at]["errors"], + errmsg=err_msg, + not_exec=commands[err_at + 1 :], + ) + + def config_session(self, name: str) -> SessionConfig: + """Return a SessionConfig instance bound to this device with the given session name. + + Parameters + ---------- + name + The config-session name. + + Returns + ------- + SessionConfig + SessionConfig instance bound to this device with the given session name. + """ + return SessionConfig(self, name) diff --git a/asynceapi/errors.py b/asynceapi/errors.py new file mode 100644 index 000000000..50b02c6fd --- /dev/null +++ b/asynceapi/errors.py @@ -0,0 +1,51 @@ +# Copyright (c) 2024-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +# Initially written by Jeremy Schulman at https://github.com/jeremyschulman/aio-eapi +"""asynceapi module exceptions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import httpx + +if TYPE_CHECKING: + from ._types import EapiComplexCommand, EapiJsonOutput, EapiSimpleCommand, EapiTextOutput + + +class EapiCommandError(RuntimeError): + """Exception class for eAPI command errors. + + Attributes + ---------- + failed: the failed command + errmsg: a description of the failure reason + errors: the command failure details + passed: a list of command results of the commands that passed + not_exec: a list of commands that were not executed + """ + + def __init__( + self, + failed: str, + errors: list[str], + errmsg: str, + passed: list[EapiJsonOutput] | list[EapiTextOutput], + not_exec: list[EapiSimpleCommand | EapiComplexCommand], + ) -> None: + """Initialize for the EapiCommandError exception.""" + self.failed = failed + self.errmsg = errmsg + self.errors = errors + self.passed = passed + self.not_exec = not_exec + super().__init__() + + def __str__(self) -> str: + """Return the error message associated with the exception.""" + return self.errmsg + + +# alias for exception during sending-receiving +EapiTransportError = httpx.HTTPStatusError diff --git a/docs/README.md b/docs/README.md index 1fe8734bb..b6e00c71d 100755 --- a/docs/README.md +++ b/docs/README.md @@ -1,32 +1,51 @@ # Arista Network Test Automation (ANTA) Framework -| **Code** | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![Numpy](https://img.shields.io/badge/Docstring_format-numpy-blue)](https://numpydoc.readthedocs.io/en/latest/format.html) | +| **Code** | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![Numpy](https://img.shields.io/badge/Docstring_format-numpy-blue)](https://numpydoc.readthedocs.io/en/latest/format.html) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=aristanetworks_anta&metric=alert_status&branch=main)](https://sonarcloud.io/summary/new_code?id=aristanetworks_anta) [![Coverage](https://img.shields.io/sonar/coverage/aristanetworks_anta/main?server=https%3A%2F%2Fsonarcloud.io&logo=sonarcloud&link=https%3A%2F%2Fsonarcloud.io%2Fsummary%2Foverall%3Fid%3Daristanetworks_anta)](https://sonarcloud.io/summary/overall?id=aristanetworks_anta) | | :------------: | :-------| -| **License** | [![License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](https://github.com/arista-netdevops-community/anta/blob/main/LICENSE) | -| **GitHub** | [![CI](https://github.com/arista-netdevops-community/anta/actions/workflows/code-testing.yml/badge.svg)](https://github.com/arista-netdevops-community/anta/actions/workflows/code-testing.yml) ![Coverage](https://raw.githubusercontent.com/arista-netdevops-community/anta/coverage-badge/latest-release-coverage.svg) ![Commit](https://img.shields.io/github/last-commit/arista-netdevops-community/anta) ![GitHub commit activity (branch)](https://img.shields.io/github/commit-activity/m/arista-netdevops-community/anta) [![Github release](https://img.shields.io/github/release/arista-netdevops-community/anta.svg)](https://github.com/arista-netdevops-community/anta/releases/) [![Contributors](https://img.shields.io/github/contributors/arista-netdevops-community/anta)](https://github.com/arista-netdevops-community/anta/graphs/contributors) | +| **License** | [![License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](https://github.com/aristanetworks/anta/blob/main/LICENSE) | +| **GitHub** | [![CI](https://github.com/aristanetworks/anta/actions/workflows/code-testing.yml/badge.svg)](https://github.com/aristanetworks/anta/actions/workflows/code-testing.yml) ![Commit](https://img.shields.io/github/last-commit/aristanetworks/anta) ![GitHub commit activity (branch)](https://img.shields.io/github/commit-activity/m/aristanetworks/anta) [![Github release](https://img.shields.io/github/release/aristanetworks/anta.svg)](https://github.com/aristanetworks/anta/releases/) [![Contributors](https://img.shields.io/github/contributors/aristanetworks/anta)](https://github.com/aristanetworks/anta/graphs/contributors) | | **PyPi** | ![PyPi Version](https://img.shields.io/pypi/v/anta) ![Python Versions](https://img.shields.io/pypi/pyversions/anta) ![Python format](https://img.shields.io/pypi/format/anta) ![PyPI - Downloads](https://img.shields.io/pypi/dm/anta) | ANTA is Python framework that automates tests for Arista devices. - ANTA provides a [set of tests](api/tests.md) to validate the state of your network - ANTA can be used to: - - Automate NRFU (Network Ready For Use) test on a preproduction network - - Automate tests on a live network (periodically or on demand) + - Automate NRFU (Network Ready For Use) test on a preproduction network + - Automate tests on a live network (periodically or on demand) - ANTA can be used with: - - The [ANTA CLI](cli/overview.md) - - As a [Python library](advanced_usages/as-python-lib.md) in your own application + - As a [Python library](advanced_usages/as-python-lib.md) in your own application + - The [ANTA CLI](cli/overview.md) -![anta nrfu](https://raw.githubusercontent.com/arista-netdevops-community/anta/main/docs/imgs/anta-nrfu.svg) +![anta nrfu](https://raw.githubusercontent.com/aristanetworks/anta/main/docs/imgs/anta-nrfu.svg) + +## Install ANTA library + +The library will **NOT** install the necessary dependencies for the CLI. + +```bash +# Install ANTA as a library +pip install anta +``` + +## Install ANTA CLI + +If you plan to use ANTA only as a CLI tool you can use `pipx` to install it. +[`pipx`](https://pipx.pypa.io/stable/) is a tool to install and run python applications in isolated environments. Refer to `pipx` instructions to install on your system. +`pipx` installs ANTA in an isolated python environment and makes it available globally. + + +**This is not recommended if you plan to contribute to ANTA** + ```bash -# Install ANTA CLI -$ pip install anta +# Install ANTA CLI with pipx +$ pipx install anta[cli] # Run ANTA CLI $ anta --help @@ -52,12 +71,15 @@ Commands: nrfu Run ANTA tests on devices ``` -> [!WARNING] -> The ANTA CLI options have changed after version 0.11 and have moved away from the top level `anta` and are now required at their respective commands (e.g. `anta nrfu`). This breaking change occurs after users feedback on making the CLI more intuitive. This change should not affect user experience when using environment variables. +You can also still choose to install it with directly with `pip`: + +```bash +pip install anta[cli] +``` ## Documentation -The documentation is published on [ANTA package website](https://www.anta.ninja). Also, a [demo repository](https://github.com/titom73/atd-anta-demo) is available to facilitate your journey with ANTA. +The documentation is published on [ANTA package website](https://anta.arista.com). ## Contribution guide @@ -65,4 +87,6 @@ Contributions are welcome. Please refer to the [contribution guide](contribution ## Credits +Thank you to [Jeremy Schulman](https://github.com/jeremyschulman) for [aio-eapi](https://github.com/jeremyschulman/aio-eapi/tree/main/aioeapi). + Thank you to [Angélique Phillipps](https://github.com/aphillipps), [Colin MacGiollaEáin](https://github.com/colinmacgiolla), [Khelil Sator](https://github.com/ksator), [Matthieu Tache](https://github.com/mtache), [Onur Gashi](https://github.com/onurgashi), [Paul Lavelle](https://github.com/paullavelle), [Guillaume Mulocher](https://github.com/gmuloc) and [Thomas Grimonet](https://github.com/titom73) for their contributions and guidances. diff --git a/docs/advanced_usages/as-python-lib.md b/docs/advanced_usages/as-python-lib.md index f8d67348b..271c4d871 100644 --- a/docs/advanced_usages/as-python-lib.md +++ b/docs/advanced_usages/as-python-lib.md @@ -1,21 +1,21 @@ ANTA is a Python library that can be used in user applications. This section describes how you can leverage ANTA Python modules to help you create your own NRFU solution. -!!! tip - If you are unfamiliar with asyncio, refer to the Python documentation relevant to your Python version - https://docs.python.org/3/library/asyncio.html +> [!TIP] +> If you are unfamiliar with asyncio, refer to the Python documentation relevant to your Python version - ## [AntaDevice](../api/device.md#anta.device.AntaDevice) Abstract Class -A device is represented in ANTA as a instance of a subclass of the [AntaDevice](../api/device.md### ::: anta.device.AntaDevice) abstract class. +A device is represented in ANTA as a instance of a subclass of the [AntaDevice](../api/device.md#anta.device.AntaDevice) abstract class. There are few abstract methods that needs to be implemented by child classes: -- The [collect()](../api/device.md#anta.device.AntaDevice.collect) coroutine is in charge of collecting outputs of [AntaCommand](../api/models.md#anta.models.AntaCommand) instances. -- The [refresh()](../api/device.md#anta.device.AntaDevice.refresh) coroutine is in charge of updating attributes of the [AntaDevice](../api/device.md### ::: anta.device.AntaDevice) instance. These attributes are used by [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) to filter out unreachable devices or by [AntaTest](../api/models.md#anta.models.AntaTest) to skip devices based on their hardware models. +- The [collect()](../api/device.md#anta.device.AntaDevice.collect) coroutine is in charge of collecting outputs of [AntaCommand](../api/commands.md#anta.models.AntaCommand) instances. +- The [refresh()](../api/device.md#anta.device.AntaDevice.refresh) coroutine is in charge of updating attributes of the [AntaDevice](../api/device.md#anta.device.AntaDevice) instance. These attributes are used by [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) to filter out unreachable devices or by [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) to skip devices based on their hardware models. The [copy()](../api/device.md#anta.device.AntaDevice.copy) coroutine is used to copy files to and from the device. It does not need to be implemented if tests are not using it. @@ -24,7 +24,7 @@ The [copy()](../api/device.md#anta.device.AntaDevice.copy) coroutine is used to The [AsyncEOSDevice](../api/device.md#anta.device.AsyncEOSDevice) class is an implementation of [AntaDevice](../api/device.md#anta.device.AntaDevice) for Arista EOS. It uses the [aio-eapi](https://github.com/jeremyschulman/aio-eapi) eAPI client and the [AsyncSSH](https://github.com/ronf/asyncssh) library. -- The [collect()](../api/device.md#anta.device.AsyncEOSDevice.collect) coroutine collects [AntaCommand](../api/models.md#anta.models.AntaCommand) outputs using eAPI. +- The [\_collect()](../api/device.md#anta.device.AsyncEOSDevice._collect) coroutine collects [AntaCommand](../api/commands.md#anta.models.AntaCommand) outputs using eAPI. - The [refresh()](../api/device.md#anta.device.AsyncEOSDevice.refresh) coroutine tries to open a TCP connection on the eAPI port and update the `is_online` attribute accordingly. If the TCP connection succeeds, it sends a `show version` command to gather the hardware model of the device and updates the `established` and `hw_model` attributes. - The [copy()](../api/device.md#anta.device.AsyncEOSDevice.copy) coroutine copies files to and from the device using the SCP protocol. @@ -32,282 +32,28 @@ It uses the [aio-eapi](https://github.com/jeremyschulman/aio-eapi) eAPI client a The [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) class is a subclass of the standard Python type [dict](https://docs.python.org/3/library/stdtypes.html#dict). The keys of this dictionary are the device names, the values are [AntaDevice](../api/device.md#anta.device.AntaDevice) instances. - [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) provides methods to interact with the ANTA inventory: -- The [add_device()](../api/inventory.md#anta.inventory.AntaInventory.add_device) method adds an [AntaDevice](../api/device.md### ::: anta.device.AntaDevice) instance to the inventory. Adding an entry to [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) with a key different from the device name is not allowed. +- The [add_device()](../api/inventory.md#anta.inventory.AntaInventory.add_device) method adds an [AntaDevice](../api/device.md#anta.device.AntaDevice) instance to the inventory. Adding an entry to [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) with a key different from the device name is not allowed. - The [get_inventory()](../api/inventory.md#anta.inventory.AntaInventory.get_inventory) returns a new [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) instance with filtered out devices based on the method inputs. - The [connect_inventory()](../api/inventory.md#anta.inventory.AntaInventory.connect_inventory) coroutine will execute the [refresh()](../api/device.md#anta.device.AntaDevice.refresh) coroutines of all the devices in the inventory. - The [parse()](../api/inventory.md#anta.inventory.AntaInventory.parse) static method creates an [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) instance from a YAML file and returns it. The devices are [AsyncEOSDevice](../api/device.md#anta.device.AsyncEOSDevice) instances. +## Examples -To parse a YAML inventory file and print the devices connection status: +### Parse an ANTA inventory file ```python -""" -Example -""" -import asyncio - -from anta.inventory import AntaInventory - - -async def main(inv: AntaInventory) -> None: - """ - Take an AntaInventory and: - 1. try to connect to every device in the inventory - 2. print a message for every device connection status - """ - await inv.connect_inventory() - - for device in inv.values(): - if device.established: - print(f"Device {device.name} is online") - else: - print(f"Could not connect to device {device.name}") - -if __name__ == "__main__": - # Create the AntaInventory instance - inventory = AntaInventory.parse( - filename="inv.yml", - username="arista", - password="@rista123", - ) - - # Run the main coroutine - res = asyncio.run(main(inventory)) +--8<-- "parse_anta_inventory_file.py" ``` -??? note "How to create your inventory file" - Please visit this [dedicated section](../usage-inventory-catalog.md) for how to use inventory and catalog files. - -To run an EOS commands list on the reachable devices from the inventory: -```python -""" -Example -""" -# This is needed to run the script for python < 3.10 for typing annotations -from __future__ import annotations - -import asyncio -from pprint import pprint - -from anta.inventory import AntaInventory -from anta.models import AntaCommand - - -async def main(inv: AntaInventory, commands: list[str]) -> dict[str, list[AntaCommand]]: - """ - Take an AntaInventory and a list of commands as string and: - 1. try to connect to every device in the inventory - 2. collect the results of the commands from each device - - Returns: - a dictionary where key is the device name and the value is the list of AntaCommand ran towards the device - """ - await inv.connect_inventory() - - # Make a list of coroutine to run commands towards each connected device - coros = [] - # dict to keep track of the commands per device - result_dict = {} - for name, device in inv.get_inventory(established_only=True).items(): - anta_commands = [AntaCommand(command=command, ofmt="json") for command in commands] - result_dict[name] = anta_commands - coros.append(device.collect_commands(anta_commands)) - - # Run the coroutines - await asyncio.gather(*coros) - - return result_dict - - -if __name__ == "__main__": - # Create the AntaInventory instance - inventory = AntaInventory.parse( - filename="inv.yml", - username="arista", - password="@rista123", - ) - - # Create a list of commands with json output - commands = ["show version", "show ip bgp summary"] +> [!NOTE] +> **How to create your inventory file** +> +> Please visit this [dedicated section](../usage-inventory-catalog.md) for how to use inventory and catalog files. - # Run the main asyncio entry point - res = asyncio.run(main(inventory, commands)) - - pprint(res) -``` - - -## Use tests from ANTA - -All the test classes inherit from the same abstract Base Class AntaTest. The Class definition indicates which commands are required for the test and the user should focus only on writing the `test` function with optional keywords argument. The instance of the class upon creation instantiates a TestResult object that can be accessed later on to check the status of the test ([unset, skipped, success, failure, error]). - -### Test structure - -All tests are built on a class named `AntaTest` which provides a complete toolset for a test: - -- Object creation -- Test definition -- TestResult definition -- Abstracted method to collect data - -This approach means each time you create a test it will be based on this `AntaTest` class. Besides that, you will have to provide some elements: - -- `name`: Name of the test -- `description`: A human readable description of your test -- `categories`: a list of categories to sort test. -- `commands`: a list of command to run. This list _must_ be a list of `AntaCommand` which is described in the next part of this document. - -Here is an example of a hardware test related to device temperature: +### Run EOS commands ```python -from __future__ import annotations - -import logging -from typing import Any, Dict, List, Optional, cast - -from anta.models import AntaTest, AntaCommand - - -class VerifyTemperature(AntaTest): - """ - Verifies device temparture is currently OK. - """ - - # The test name - name = "VerifyTemperature" - # A small description of the test, usually the first line of the class docstring - description = "Verifies device temparture is currently OK" - # The category of the test, usually the module name - categories = ["hardware"] - # The command(s) used for the test. Could be a template instead - commands = [AntaCommand(command="show system environment temperature", ofmt="json")] - - # Decorator - @AntaTest.anta_test - # abstract method that must be defined by the child Test class - def test(self) -> None: - """Run VerifyTemperature validation""" - command_output = cast(Dict[str, Dict[Any, Any]], self.instance_commands[0].output) - temperature_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else "" - if temperature_status == "temperatureOk": - self.result.is_success() - else: - self.result.is_failure(f"Device temperature is not OK, systemStatus: {temperature_status }") +--8<-- "run_eos_commands.py" ``` - -When you run the test, object will automatically call its `anta.models.AntaTest.collect()` method to get device output for each command if no pre-collected data was given to the test. This method does a loop to call `anta.inventory.models.InventoryDevice.collect()` methods which is in charge of managing device connection and how to get data. - -??? info "run test offline" - You can also pass eos data directly to your test if you want to validate data collected in a different workflow. An example is provided below just for information: - - ```python - test = VerifyTemperature(device, eos_data=test_data["eos_data"]) - asyncio.run(test.test()) - ``` - -The `test` function is always the same and __must__ be defined with the `@AntaTest.anta_test` decorator. This function takes at least one argument which is a `anta.inventory.models.InventoryDevice` object. -In some cases a test would rely on some additional inputs from the user, for instance the number of expected peers or some expected numbers. All parameters __must__ come with a default value and the test function __should__ validate the parameters values (at this stage this is the only place where validation can be done but there are future plans to make this better). - -```python -class VerifyTemperature(AntaTest): - ... - @AntaTest.anta_test - def test(self) -> None: - pass - -class VerifyTransceiversManufacturers(AntaTest): - ... - @AntaTest.anta_test - def test(self, manufacturers: Optional[List[str]] = None) -> None: - # validate the manufactures parameter - pass -``` - -The test itself does not return any value, but the result is directly available from your AntaTest object and exposes a `anta.result_manager.models.TestResult` object with result, name of the test and optional messages: - - -- `name` (str): Device name where the test has run. -- `test` (str): Test name runs on the device. -- `categories` (List[str]): List of categories the TestResult belongs to, by default the AntaTest categories. -- `description` (str): TestResult description, by default the AntaTest description. -- `results` (str): Result of the test. Can be one of ["unset", "success", "failure", "error", "skipped"]. -- `message` (str, optional): Message to report after the test if any. -- `custom_field` (str, optional): Custom field to store a string for flexibility in integrating with ANTA - -```python -from anta.tests.hardware import VerifyTemperature - -test = VerifyTemperature(device, eos_data=test_data["eos_data"]) -asyncio.run(test.test()) -assert test.result.result == "success" -``` - -### Classes for commands - -To make it easier to get data, ANTA defines 2 different classes to manage commands to send to devices: - -#### [AntaCommand](../api/models.md#anta.models.AntaCommand) Class - -Represent a command with following information: - -- Command to run -- Output format expected -- eAPI version -- Output of the command - -Usage example: - -```python -from anta.models import AntaCommand - -cmd1 = AntaCommand(command="show zerotouch") -cmd2 = AntaCommand(command="show running-config diffs", ofmt="text") -``` - -!!! tip "Command revision and version" - * Most of EOS commands return a JSON structure according to a model (some commands may not be modeled hence the necessity to use `text` outformat sometimes. - * The model can change across time (adding feature, ... ) and when the model is changed in a non backward-compatible way, the __revision__ number is bumped. The initial model starts with __revision__ 1. - * A __revision__ applies to a particular CLI command whereas a __version__ is global to an eAPI call. The __version__ is internally translated to a specific __revision__ for each CLI command in the RPC call. The currently supported __version__ values are `1` and `latest`. - * A __revision takes precedence over a version__ (e.g. if a command is run with version="latest" and revision=1, the first revision of the model is returned) - * By default, eAPI returns the first revision of each model to ensure that when upgrading, integrations with existing tools are not broken. This is done by using by default `version=1` in eAPI calls. - - By default, ANTA uses `version="latest"` in AntaCommand, but when developing tests, the revision MUST be provided when the outformat of the command is `json`. As explained earlier, this is to ensure that the eAPI always returns the same output model and that the test remains always valid from the day it was created. For some commands, you may also want to run them with a different revision or version. - - For instance, the `VerifyBFDPeersHealth` test leverages the first revision of `show bfd peers`: - - ``` - # revision 1 as later revision introduce additional nesting for type - commands = [AntaCommand(command="show bfd peers", revision=1)] - ``` - -#### [AntaTemplate](../api/models.md#anta.models.AntaTemplate) Class - -Because some command can require more dynamic than just a command with no parameter provided by user, ANTA supports command template: you define a template in your test class and user provide parameters when creating test object. - -```python - -class RunArbitraryTemplateCommand(AntaTest): - """ - Run an EOS command and return result - Based on AntaTest to build relevant output for pytest - """ - - name = "Run aributrary EOS command" - description = "To be used only with anta debug commands" - template = AntaTemplate(template="show interfaces {ifd}") - categories = ["debug"] - - @AntaTest.anta_test - def test(self) -> None: - errdisabled_interfaces = [interface for interface, value in response["interfaceStatuses"].items() if value["linkStatus"] == "errdisabled"] - ... - - -params = [{"ifd": "Ethernet2"}, {"ifd": "Ethernet49/1"}] -run_command1 = RunArbitraryTemplateCommand(device_anta, params) -``` - -In this example, test waits for interfaces to check from user setup and will only check for interfaces in `params` diff --git a/docs/advanced_usages/caching.md b/docs/advanced_usages/caching.md index ce4a7877c..628376b9d 100644 --- a/docs/advanced_usages/caching.md +++ b/docs/advanced_usages/caching.md @@ -1,5 +1,5 @@ @@ -8,20 +8,7 @@ ANTA is a streamlined Python framework designed for efficient interaction with n ## Configuration -By default, ANTA utilizes [aiocache](https://github.com/aio-libs/aiocache)'s memory cache backend, also called [`SimpleMemoryCache`](https://aiocache.aio-libs.org/en/v0.12.2/caches.html#simplememorycache). This library aims for simplicity and supports asynchronous operations to go along with Python `asyncio` used in ANTA. - -The `_init_cache()` method of the [AntaDevice](../advanced_usages/as-python-lib.md#antadevice-abstract-class) abstract class initializes the cache. Child classes can override this method to tweak the cache configuration: - -```python -def _init_cache(self) -> None: - """ - Initialize cache for the device, can be overridden by subclasses to manipulate how it works - """ - self.cache = Cache(cache_class=Cache.MEMORY, ttl=60, namespace=self.name, plugins=[HitMissRatioPlugin()]) - self.cache_locks = defaultdict(asyncio.Lock) -``` - -The cache is also configured with `aiocache`'s [`HitMissRatioPlugin`](https://aiocache.aio-libs.org/en/v0.12.2/plugins.html#hitmissratioplugin) plugin to calculate the ratio of hits the cache has and give useful statistics for logging purposes in ANTA. +The `_init_cache()` method of the [AntaDevice](../api/device.md#anta.device.AntaDevice) abstract class initializes the cache. Child classes can override this method to tweak the cache configuration: ## Cache key design @@ -29,9 +16,9 @@ The cache is initialized per `AntaDevice` and uses the following cache key desig `:` -The `uid` is an attribute of [AntaCommand](../advanced_usages/as-python-lib.md#antacommand-class), which is a unique identifier generated from the command, version, revision and output format. +The `uid` is an attribute of [AntaCommand](../api/commands.md#anta.models.AntaCommand), which is a unique identifier generated from the command, version, revision and output format. -Each UID has its own asyncio lock. This design allows coroutines that need to access the cache for different UIDs to do so concurrently. The locks are managed by the `self.cache_locks` dictionary. +Each UID has its own asyncio lock. This design allows coroutines that need to access the cache for different UIDs to do so concurrently. The locks are managed by the `AntaCache.locks` dictionary. ## Mechanisms @@ -44,34 +31,38 @@ Caching is enabled by default in ANTA following the previous configuration and m There might be scenarios where caching is not wanted. You can disable caching in multiple ways in ANTA: 1. Caching can be disabled globally, for **ALL** commands on **ALL** devices, using the `--disable-cache` global flag when invoking anta at the [CLI](../cli/overview.md#invoking-anta-cli): - ```bash - anta --disable-cache --username arista --password arista nrfu table - ``` -2. Caching can be disabled per device, network or range by setting the `disable_cache` key to `True` when defining the ANTA [Inventory](../usage-inventory-catalog.md#create-an-inventory-file) file: - ```yaml - anta_inventory: - hosts: - - host: 172.20.20.101 - name: DC1-SPINE1 - tags: ["SPINE", "DC1"] - disable_cache: True # Set this key to True - - host: 172.20.20.102 - name: DC1-SPINE2 - tags: ["SPINE", "DC1"] - disable_cache: False # Optional since it's the default - - networks: - - network: "172.21.21.0/24" - disable_cache: True - - ranges: - - start: 172.22.22.10 - end: 172.22.22.19 - disable_cache: True - ``` - This approach effectively disables caching for **ALL** commands sent to devices targeted by the `disable_cache` key. - -3. For tests developers, caching can be disabled for a specific [`AntaCommand`](../advanced_usages/as-python-lib.md#antacommand-class) or [`AntaTemplate`](../advanced_usages/as-python-lib.md#antatemplate-class) by setting the `use_cache` attribute to `False`. That means the command output will always be collected on the device and therefore, never use caching. + + ```bash + anta --disable-cache --username arista --password arista nrfu table + ``` + +2. Caching can be disabled per device, network or range by setting the `disable_cache` key to `True` when defining the ANTA [Inventory](../usage-inventory-catalog.md#device-inventory) file: + + ```yaml + anta_inventory: + hosts: + - host: 172.20.20.101 + name: DC1-SPINE1 + tags: ["SPINE", "DC1"] + disable_cache: True # Set this key to True + - host: 172.20.20.102 + name: DC1-SPINE2 + tags: ["SPINE", "DC1"] + disable_cache: False # Optional since it's the default + + networks: + - network: "172.21.21.0/24" + disable_cache: True + + ranges: + - start: 172.22.22.10 + end: 172.22.22.19 + disable_cache: True + ``` + + This approach effectively disables caching for **ALL** commands sent to devices targeted by the `disable_cache` key. + +3. For tests developers, caching can be disabled for a specific [`AntaCommand`](../api/commands.md#anta.models.AntaCommand) or [`AntaTemplate`](../api/commands.md#anta.models.AntaTemplate) by setting the `use_cache` attribute to `False`. That means the command output will always be collected on the device and therefore, never use caching. ### Disable caching in a child class of `AntaDevice` diff --git a/docs/advanced_usages/custom-tests.md b/docs/advanced_usages/custom-tests.md index df17c1cc9..088074356 100644 --- a/docs/advanced_usages/custom-tests.md +++ b/docs/advanced_usages/custom-tests.md @@ -1,11 +1,11 @@ -!!! info "" - This documentation applies for both creating tests in ANTA or creating your own test package. +> [!INFO] +> This documentation applies for both creating tests in ANTA or creating your own test package. ANTA is not only a Python library with a CLI and a collection of built-in tests, it is also a framework you can extend by building your own tests. @@ -13,9 +13,9 @@ ANTA is not only a Python library with a CLI and a collection of built-in tests, A test is a Python class where a test function is defined and will be run by the framework. -ANTA provides an abstract class [AntaTest](../api/models.md#anta.models.AntaTest). This class does the heavy lifting and provide the logic to define, collect and test data. The code below is an example of a simple test in ANTA, which is an [AntaTest](../api/models.md#anta.models.AntaTest) subclass: +ANTA provides an abstract class [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest). This class does the heavy lifting and provide the logic to define, collect and test data. The code below is an example of a simple test in ANTA, which is an [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) subclass: -```python +````python from anta.models import AntaTest, AntaCommand from anta.decorators import skip_on_platforms @@ -36,8 +36,6 @@ class VerifyTemperature(AntaTest): ``` """ - name = "VerifyTemperature" - description = "Verifies the device temperature." categories: ClassVar[list[str]] = ["hardware"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature", revision=1)] @@ -51,27 +49,26 @@ class VerifyTemperature(AntaTest): self.result.is_success() else: self.result.is_failure(f"Device temperature exceeds acceptable limits. Current system status: '{temperature_status}'") -``` +```` + +[AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) also provide more advanced capabilities like [AntaCommand](../api/commands.md#anta.models.AntaCommand) templating using the [AntaTemplate](../api/commands.md#anta.models.AntaTemplate) class or test inputs definition and validation using [AntaTest.Input](../api/tests/anta_test.md#anta.models.AntaTest.Input) [pydantic](https://docs.pydantic.dev/latest/) model. This will be discussed in the sections below. -[AntaTest](../api/models.md#anta.models.AntaTest) also provide more advanced capabilities like [AntaCommand](../api/models.md#anta.models.AntaCommand) templating using the [AntaTemplate](../api/models.md#anta.models.AntaTemplate) class or test inputs definition and validation using [AntaTest.Input](../api/models.md#anta.models.AntaTest.Input) [pydantic](https://docs.pydantic.dev/latest/) model. This will be discussed in the sections below. +## AntaTest structure -## [AntaTest](../api/models.md#anta.models.AntaTest) structure +Full AntaTest API documentation is available in the [API documentation section](../api/tests/anta_test.md#anta.models.AntaTest) ### Class Attributes -- `name` (`str`): Name of the test. Used during reporting. -- `description` (`str`): A human readable description of your test. +- `name` (`str`, `optional`): Name of the test. Used during reporting. By default set to the Class name. +- `description` (`str`, `optional`): A human readable description of your test. By default set to the first line of the docstring. - `categories` (`list[str]`): A list of categories in which the test belongs. -- `commands` (`[list[AntaCommand | AntaTemplate]]`): A list of command to collect from devices. This list __must__ be a list of [AntaCommand](../api/models.md#anta.models.AntaCommand) or [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances. Rendering [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances will be discussed later. +- `commands` (`[list[AntaCommand | AntaTemplate]]`): A list of command to collect from devices. This list **must** be a list of [AntaCommand](../api/commands.md#anta.models.AntaCommand) or [AntaTemplate](../api/commands.md#anta.models.AntaTemplate) instances. Rendering [AntaTemplate](../api/commands.md#anta.models.AntaTemplate) instances will be discussed later. -!!! info - All these class attributes are mandatory. If any attribute is missing, a `NotImplementedError` exception will be raised during class instantiation. +> [!INFO] +> All these class attributes are mandatory. If any attribute is missing, a `NotImplementedError` exception will be raised during class instantiation. ### Instance Attributes -!!! info - You can access an instance attribute in your code using the `self` reference. E.g. you can access the test input values using `self.inputs`. - ::: anta.models.AntaTest options: show_docstring_attributes: true @@ -85,20 +82,25 @@ class VerifyTemperature(AntaTest): show_root_toc_entry: false heading_level: 10 - -!!! note "Logger object" - ANTA already provides comprehensive logging at every steps of a test execution. The [AntaTest](../api/models.md#anta.models.AntaTest) class also provides a `logger` attribute that is a Python logger specific to the test instance. See [Python documentation](https://docs.python.org/3/library/logging.html) for more information. - -!!! note "AntaDevice object" - Even if `device` is not a private attribute, you should not need to access this object in your code. +> [!NOTE] +> +> - **Logger object** +> +> ANTA already provides comprehensive logging at every steps of a test execution. The [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) class also provides a `logger` attribute that is a Python logger specific to the test instance. See [Python documentation](https://docs.python.org/3/library/logging.html) for more information. +> +> - **AntaDevice object** +> +> Even if `device` is not a private attribute, you should not need to access this object in your code. ### Test Inputs -[AntaTest.Input](../api/models.md#anta.models.AntaTest.Input) is a [pydantic model](https://docs.pydantic.dev/latest/usage/models/) that allow test developers to define their test inputs. [pydantic](https://docs.pydantic.dev/latest/) provides out of the box [error handling](https://docs.pydantic.dev/latest/usage/models/#error-handling) for test input validation based on the type hints defined by the test developer. +[AntaTest.Input](../api/tests/anta_test.md#anta.models.AntaTest.Input) is a [pydantic model](https://docs.pydantic.dev/latest/usage/models/) that allow test developers to define their test inputs. [pydantic](https://docs.pydantic.dev/latest/) provides out of the box [error handling](https://docs.pydantic.dev/latest/usage/models/#error-handling) for test input validation based on the type hints defined by the test developer. -The base definition of [AntaTest.Input](../api/models.md#anta.models.AntaTest.Input) provides common test inputs for all [AntaTest](../api/models.md#anta.models.AntaTest) instances: +The base definition of [AntaTest.Input](../api/tests/anta_test.md#anta.models.AntaTest.Input) provides common test inputs for all [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) instances: -#### [Input](../api/models.md#anta.models.AntaTest.Input) model +#### Input model + +Full `Input` model documentation is available in [API documentation section](../api/tests/anta_test.md#anta.models.AntaTest.Input) ::: anta.models.AntaTest.Input options: @@ -114,7 +116,9 @@ The base definition of [AntaTest.Input](../api/models.md#anta.models.AntaTest.In show_root_toc_entry: false heading_level: 10 -#### [ResultOverwrite](../api/models.md#anta.models.AntaTest.Input.ResultOverwrite) model +#### ResultOverwrite model + +Full `ResultOverwrite` model documentation is available in [API documentation section](../api/tests/anta_test.md#anta.models.AntaTest.Input.ResultOverwrite) ::: anta.models.AntaTest.Input.ResultOverwrite options: @@ -129,39 +133,39 @@ The base definition of [AntaTest.Input](../api/models.md#anta.models.AntaTest.In show_root_toc_entry: false heading_level: 10 -!!! note - The pydantic model is configured using the [`extra=forbid`](https://docs.pydantic.dev/latest/usage/model_config/#extra-attributes) that will fail input validation if extra fields are provided. +> [!NOTE] +> The pydantic model is configured using the [`extra=forbid`](https://docs.pydantic.dev/latest/usage/model_config/#extra-attributes) that will fail input validation if extra fields are provided. ### Methods -- [test(self) -> None](../api/models.md#anta.models.AntaTest.test): This is an abstract method that __must__ be implemented. It contains the test logic that can access the collected command outputs using the `instance_commands` instance attribute, access the test inputs using the `inputs` instance attribute and __must__ set the `result` instance attribute accordingly. It must be implemented using the `AntaTest.anta_test` decorator that provides logging and will collect commands before executing the `test()` method. -- [render(self, template: AntaTemplate) -> list[AntaCommand]](../api/models.md#anta.models.AntaTest.render): This method only needs to be implemented if [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances are present in the `commands` class attribute. It will be called for every [AntaTemplate](../api/models.md#anta.models.AntaTemplate) occurrence and __must__ return a list of [AntaCommand](../api/models.md#anta.models.AntaCommand) using the [AntaTemplate.render()](../api/models.md#anta.models.AntaTemplate.render) method. It can access test inputs using the `inputs` instance attribute. +- [test(self) -> None](../api/tests/anta_test.md#anta.models.AntaTest.test): This is an abstract method that **must** be implemented. It contains the test logic that can access the collected command outputs using the `instance_commands` instance attribute, access the test inputs using the `inputs` instance attribute and **must** set the `result` instance attribute accordingly. It must be implemented using the `AntaTest.anta_test` decorator that provides logging and will collect commands before executing the `test()` method. +- [render(self, template: AntaTemplate) -> list[AntaCommand]](../api/tests/anta_test.md#anta.models.AntaTest.render): This method only needs to be implemented if [AntaTemplate](../api/commands.md#anta.models.AntaTemplate) instances are present in the `commands` class attribute. It will be called for every [AntaTemplate](../api/commands.md#anta.models.AntaTemplate) occurrence and **must** return a list of [AntaCommand](../api/commands.md#anta.models.AntaCommand) using the [AntaTemplate.render()](../api/commands.md#anta.models.AntaTemplate.render) method. It can access test inputs using the `inputs` instance attribute. ## Test execution Below is a high level description of the test execution flow in ANTA: -1. ANTA will parse the test catalog to get the list of [AntaTest](../api/models.md#anta.models.AntaTest) subclasses to instantiate and their associated input values. We consider a single [AntaTest](../api/models.md#anta.models.AntaTest) subclass in the following steps. +1. ANTA will parse the test catalog to get the list of [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) subclasses to instantiate and their associated input values. We consider a single [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) subclass in the following steps. -2. ANTA will instantiate the [AntaTest](../api/models.md#anta.models.AntaTest) subclass and a single device will be provided to the test instance. The `Input` model defined in the class will also be instantiated at this moment. If any [ValidationError](https://docs.pydantic.dev/latest/errors/errors/) is raised, the test execution will be stopped. +2. ANTA will instantiate the [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) subclass and a single device will be provided to the test instance. The `Input` model defined in the class will also be instantiated at this moment. If any [ValidationError](https://docs.pydantic.dev/latest/errors/errors/) is raised, the test execution will be stopped. -3. If there is any [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instance in the `commands` class attribute, [render()](../api/models.md#anta.models.AntaTest.render) will be called for every occurrence. At this moment, the `instance_commands` attribute has been initialized. If any rendering error occurs, the test execution will be stopped. +3. If there is any [AntaTemplate](../api/commands.md#anta.models.AntaTemplate) instance in the `commands` class attribute, [render()](../api/tests/anta_test.md#anta.models.AntaTest.render) will be called for every occurrence. At this moment, the `instance_commands` attribute has been initialized. If any rendering error occurs, the test execution will be stopped. 4. The `AntaTest.anta_test` decorator will collect the commands from the device and update the `instance_commands` attribute with the outputs. If any collection error occurs, the test execution will be stopped. -5. The [test()](../api/models.md#anta.models.AntaTest.test) method is executed. +5. The [test()](../api/tests/anta_test.md#anta.models.AntaTest.test) method is executed. ## Writing an AntaTest subclass -In this section, we will go into all the details of writing an [AntaTest](../api/models.md#anta.models.AntaTest) subclass. +In this section, we will go into all the details of writing an [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) subclass. ### Class definition -Import [anta.models.AntaTest](../api/models.md#anta.models.AntaTest) and define your own class. -Define the mandatory class attributes using [anta.models.AntaCommand](../api/models.md#anta.models.AntaCommand), [anta.models.AntaTemplate](../api/models.md#anta.models.AntaTemplate) or both. +Import [anta.models.AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) and define your own class. +Define the mandatory class attributes using [anta.models.AntaCommand](../api/commands.md#anta.models.AntaCommand), [anta.models.AntaTemplate](../api/commands.md#anta.models.AntaTemplate) or both. -!!! info - Caching can be disabled per `AntaCommand` or `AntaTemplate` by setting the `use_cache` argument to `False`. For more details about how caching is implemented in ANTA, please refer to [Caching in ANTA](../advanced_usages/caching.md). +> [!NOTE] +> Caching can be disabled per `AntaCommand` or `AntaTemplate` by setting the `use_cache` argument to `False`. For more details about how caching is implemented in ANTA, please refer to [Caching in ANTA](../advanced_usages/caching.md). ```python from anta.models import AntaTest, AntaCommand, AntaTemplate @@ -169,11 +173,11 @@ from anta.models import AntaTest, AntaCommand, AntaTemplate class (AntaTest): """ - + """ - name = "YourTestName" # should be your class name - description = "" + # name = # uncomment to override default behavior of name=Class Name + # description = # uncomment to override default behavior of description=first line of docstring categories = ["", ""] commands = [ AntaCommand( @@ -193,6 +197,24 @@ class (AntaTest): ] ``` +> [!TIP] +> **Command revision and version** +> +> - Most of EOS commands return a JSON structure according to a model (some commands may not be modeled hence the necessity to use `text` outformat sometimes. +> - The model can change across time (adding feature, ... ) and when the model is changed in a non backward-compatible way, the **revision** number is bumped. The initial model starts with **revision** 1. +> - A **revision** applies to a particular CLI command whereas a **version** is global to an eAPI call. The **version** is internally translated to a specific **revision** for each CLI command in the RPC call. The currently supported **version** values are `1` and `latest`. +> - A **revision takes precedence over a version** (e.g. if a command is run with version="latest" and revision=1, the first revision of the model is returned) +> - By default, eAPI returns the first revision of each model to ensure that when upgrading, integrations with existing tools are not broken. This is done by using by default `version=1` in eAPI calls. +> +> By default, ANTA uses `version="latest"` in AntaCommand, but when developing tests, the revision MUST be provided when the outformat of the command is `json`. As explained earlier, this is to ensure that the eAPI always returns the same output model and that the test remains always valid from the day it was created. For some commands, you may also want to run them with a different revision or version. +> +> For instance, the `VerifyBFDPeersHealth` test leverages the first revision of `show bfd peers`: +> +> ```python +> # revision 1 as later revision introduce additional nesting for type +> commands = [AntaCommand(command="show bfd peers", revision=1)] +> ``` + ### Inputs definition If the user needs to provide inputs for your test, you need to define a [pydantic model](https://docs.pydantic.dev/latest/usage/models/) that defines the schema of the test inputs: @@ -222,16 +244,16 @@ class (AntaTest): ``` To define an input field type, refer to the [pydantic documentation](https://docs.pydantic.dev/latest/usage/types/types/) about types. -You can also leverage [anta.custom_types](../api/types.md) that provides reusable types defined in ANTA tests. +You can also leverage [anta.custom_types](../api/tests/types.md) that provides reusable types defined in ANTA tests. Regarding required, optional and nullable fields, refer to this [documentation](https://docs.pydantic.dev/latest/migration/#required-optional-and-nullable-fields) on how to define them. -!!! note - All the `pydantic` features are supported. For instance you can define [validators](https://docs.pydantic.dev/latest/usage/validators/) for complex input validation. +> [!NOTE] +> All the `pydantic` features are supported. For instance you can define [validators](https://docs.pydantic.dev/latest/usage/validators/) for complex input validation. ### Template rendering -Define the `render()` method if you have [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances in your `commands` class attribute: +Define the `render()` method if you have [AntaTemplate](../api/commands.md#anta.models.AntaTemplate) instances in your `commands` class attribute: ```python class (AntaTest): @@ -240,7 +262,7 @@ class (AntaTest): return [template.render(