1
0
Fork 0

Compare commits

..

No commits in common. "6fd6eb426ad53cf13d991416d858980496c9437d" and "ecf5ca3300bcde3de445661dc131042dab11214a" have entirely different histories.

340 changed files with 11675 additions and 107316 deletions

View file

@ -1,10 +0,0 @@
# Arista Secret Scanner allow list
version: v1.0
allowed_secrets:
- secret_pattern: "https://ansible:ansible@192.168.0.2"
category: FALSE_POSITIVE
reason: Used as example in documentation
- secret_pattern: "https://ansible:ansible@192.168.0.17"
category: FALSE_POSITIVE
reason: Used as example in documentation

View file

@ -15,23 +15,15 @@
"vscode": { "vscode": {
"settings": {}, "settings": {},
"extensions": [ "extensions": [
"ms-python.black-formatter",
"ms-python.isort",
"formulahendry.github-actions", "formulahendry.github-actions",
"matangover.mypy", "matangover.mypy",
"ms-python.mypy-type-checker", "ms-python.mypy-type-checker",
"ms-python.pylint", "ms-python.pylint",
"LittleFoxTeam.vscode-python-test-adapter", "LittleFoxTeam.vscode-python-test-adapter",
"njqdev.vscode-python-typehint", "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"
] ]
} }
}, },

View file

@ -9,8 +9,5 @@ pip install --upgrade pip
echo "Installing ANTA package from git" echo "Installing ANTA package from git"
pip install -e . pip install -e .
echo "Installing ANTA CLI package from git"
pip install -e ".[cli]"
echo "Installing development tools" echo "Installing development tools"
pip install -e ".[dev]" pip install -e ".[dev]"

View file

@ -1,5 +1,6 @@
#!/usr/bin/env python #!/usr/bin/env python
"""generate_release.py. """
generate_release.py
This script is used to generate the release.yml file as per This script is used to generate the release.yml file as per
https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes
@ -19,18 +20,21 @@ CATEGORIES = {
"fix": "Bug Fixes", "fix": "Bug Fixes",
"cut": "Cut", "cut": "Cut",
"doc": "Documentation", "doc": "Documentation",
# "CI": "CI",
"bump": "Bump", "bump": "Bump",
# "test": "Test",
"revert": "Revert", "revert": "Revert",
"refactor": "Refactoring", "refactor": "Refactoring",
} }
class SafeDumper(yaml.SafeDumper): class SafeDumper(yaml.SafeDumper):
"""Make yamllint happy """
https://github.com/yaml/pyyaml/issues/234#issuecomment-765894586. Make yamllint happy
https://github.com/yaml/pyyaml/issues/234#issuecomment-765894586
""" """
# pylint: disable=R0901 # pylint: disable=R0901,W0613,W1113
def increase_indent(self, flow=False, *args, **kwargs): def increase_indent(self, flow=False, *args, **kwargs):
return super().increase_indent(flow=flow, indentless=False) return super().increase_indent(flow=flow, indentless=False)
@ -56,7 +60,7 @@ if __name__ == "__main__":
{ {
"title": "Breaking Changes", "title": "Breaking Changes",
"labels": breaking_labels, "labels": breaking_labels,
}, }
) )
# Add new features # Add new features
@ -67,7 +71,7 @@ if __name__ == "__main__":
{ {
"title": "New features and enhancements", "title": "New features and enhancements",
"labels": feat_labels, "labels": feat_labels,
}, }
) )
# Add fixes # Add fixes
@ -78,7 +82,7 @@ if __name__ == "__main__":
{ {
"title": "Fixed issues", "title": "Fixed issues",
"labels": fixes_labels, "labels": fixes_labels,
}, }
) )
# Add Documentation # Add Documentation
@ -89,7 +93,7 @@ if __name__ == "__main__":
{ {
"title": "Documentation", "title": "Documentation",
"labels": doc_labels, "labels": doc_labels,
}, }
) )
# Add the catch all # Add the catch all
@ -97,7 +101,7 @@ if __name__ == "__main__":
{ {
"title": "Other Changes", "title": "Other Changes",
"labels": ["*"], "labels": ["*"],
}, }
) )
with open(r"release.yml", "w", encoding="utf-8") as release_file: with open(r"release.yml", "w", encoding="utf-8") as release_file:
yaml.dump( yaml.dump(
@ -105,7 +109,7 @@ if __name__ == "__main__":
"changelog": { "changelog": {
"exclude": {"labels": exclude_list}, "exclude": {"labels": exclude_list},
"categories": categories_list, "categories": categories_list,
}, }
}, },
release_file, release_file,
Dumper=SafeDumper, Dumper=SafeDumper,

View file

@ -1,98 +0,0 @@
# 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
# <https://eos.arista.com>
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

View file

2
.github/release.md vendored
View file

@ -83,7 +83,7 @@ This is to be executed at the top of the repo
git push origin HEAD git push origin HEAD
gh pr create --title 'bump: ANTA vx.x.x' gh pr create --title 'bump: ANTA vx.x.x'
``` ```
9. Merge PR after review and wait for [workflow](https://github.com/aristanetworks/anta/actions/workflows/release.yml) to be executed. 9. Merge PR after review and wait for [workflow](https://github.com/arista-netdevops-community/anta/actions/workflows/release.yml) to be executed.
```bash ```bash
gh pr merge --squash gh pr merge --squash

View file

@ -23,8 +23,6 @@ jobs:
- 'anta/**' - 'anta/**'
- 'tests/*' - 'tests/*'
- 'tests/**' - 'tests/**'
# detect dependency changes
- 'pyproject.toml'
core: core:
- 'anta/*' - 'anta/*'
- 'anta/reporter/*' - 'anta/reporter/*'
@ -46,7 +44,7 @@ jobs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
strategy: strategy:
matrix: matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
needs: file-changes needs: file-changes
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -59,21 +57,32 @@ jobs:
pip install . pip install .
- name: install dev requirements - name: install dev requirements
run: pip install .[dev] run: pip install .[dev]
# @gmuloc: commenting this out for now missing-documentation:
#missing-documentation: name: "Warning documentation is missing"
# name: "Warning documentation is missing" runs-on: ubuntu-20.04
# runs-on: ubuntu-20.04 needs: [file-changes]
# needs: [file-changes] if: needs.file-changes.outputs.cli == 'true' && needs.file-changes.outputs.docs == 'false'
# if: needs.file-changes.outputs.cli == 'true' && needs.file-changes.outputs.docs == 'false' steps:
# steps: - name: Documentation is missing
# - name: Documentation is missing uses: GrantBirki/comment@v2.0.9
# uses: GrantBirki/comment@v2.0.10 with:
# with: body: |
# body: | Please consider that documentation is missing under `docs/` folder.
# Please consider that documentation is missing under `docs/` folder. You should update documentation to reflect your change, or maybe not :)
# 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: lint-python:
name: Check the code style name: Run isort, black, flake8 and pylint
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: file-changes needs: file-changes
if: needs.file-changes.outputs.code == 'true' if: needs.file-changes.outputs.code == 'true'
@ -88,7 +97,7 @@ jobs:
- name: "Run tox linting environment" - name: "Run tox linting environment"
run: tox -e lint run: tox -e lint
type-python: type-python:
name: Check typing name: Run mypy
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: file-changes needs: file-changes
if: needs.file-changes.outputs.code == 'true' if: needs.file-changes.outputs.code == 'true'
@ -108,7 +117,7 @@ jobs:
needs: [lint-python, type-python] needs: [lint-python, type-python]
strategy: strategy:
matrix: matrix:
python: ["3.9", "3.10", "3.11", "3.12", "3.13"] python: ["3.8", "3.9", "3.10", "3.11", "3.12"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Python - name: Setup Python
@ -119,27 +128,10 @@ jobs:
run: pip install tox tox-gh-actions run: pip install tox tox-gh-actions
- name: "Run pytest via tox for ${{ matrix.python }}" - name: "Run pytest via tox for ${{ matrix.python }}"
run: tox run: tox
test-python-windows:
name: Pytest on 3.12 for windows
runs-on: windows-2022
needs: [lint-python, type-python]
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: test-documentation:
name: Build offline documentation for testing name: Build offline documentation for testing
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [test-python] needs: [lint-python, type-python, test-python]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Python - name: Setup Python
@ -150,20 +142,3 @@ jobs:
run: pip install .[doc] run: pip install .[doc]
- name: "Build mkdocs documentation offline" - name: "Build mkdocs documentation offline"
run: mkdocs build run: mkdocs build
benchmarks:
name: Benchmark ANTA for Python 3.12
runs-on: ubuntu-latest
needs: [test-python]
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

View file

@ -1,22 +0,0 @@
---
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

View file

@ -7,9 +7,9 @@ on:
- main - main
paths: paths:
# Run only if any of the following paths are changed when pushing to main # Run only if any of the following paths are changed when pushing to main
# May need to update this
- "docs/**" - "docs/**"
- "mkdocs.yml" - "mkdocs.yml"
- "anta/**"
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View file

@ -39,7 +39,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: Dockerfile file: Dockerfile

View file

@ -13,7 +13,7 @@ jobs:
# https://github.com/marketplace/actions/auto-author-assign # https://github.com/marketplace/actions/auto-author-assign
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: toshimaru/auto-author-assign@v2.1.1 - uses: toshimaru/auto-author-assign@v2.1.0
with: with:
repo-token: "${{ secrets.GITHUB_TOKEN }}" repo-token: "${{ secrets.GITHUB_TOKEN }}"
@ -22,7 +22,7 @@ jobs:
steps: steps:
# Please look up the latest version from # Please look up the latest version from
# https://github.com/amannn/action-semantic-pull-request/releases # https://github.com/amannn/action-semantic-pull-request/releases
- uses: amannn/action-semantic-pull-request@v5.5.3 - uses: amannn/action-semantic-pull-request@v5.4.0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:

View file

@ -7,13 +7,8 @@ on:
jobs: jobs:
pypi: pypi:
name: Publish Python 🐍 distribution 📦 to PyPI name: Publish version to Pypi servers
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment:
name: production
url: https://pypi.org/p/anta
permissions:
id-token: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -24,8 +19,11 @@ jobs:
- name: Build package - name: Build package
run: | run: |
python -m build python -m build
- name: Publish distribution 📦 to PyPI - name: Publish package to Pypi
uses: pypa/gh-action-pypi-publish@release/v1 uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
release-coverage: release-coverage:
name: Updated ANTA release coverage badge name: Updated ANTA release coverage badge
@ -102,7 +100,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: Dockerfile file: Dockerfile

View file

@ -1,15 +0,0 @@
# Secret-scanner workflow from Arista Networks.
on:
pull_request:
types: [synchronize]
push:
branches:
- main
name: Secret Scanner (go/secret-scanner)
jobs:
scan_secret:
name: Scan incoming changes
runs-on: ubuntu-latest
steps:
- name: Run scanner
uses: aristanetworks/secret-scanner-service-public@main

View file

@ -1,44 +0,0 @@
---
name: Analysis with Sonarlint and publish to SonarCloud
on:
push:
branches:
- main
# Need to do this to be able to have coverage on PR across forks.
pull_request_target:
# TODO this can be made better by running only coverage, it happens that today
# in tox gh-actions we have configured 3.11 to run the report side in
# pyproject.toml
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.pull_request.head.sha }}
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: 3.11
- name: Install dependencies
run: pip install tox tox-gh-actions
- name: "Run pytest via tox for ${{ matrix.python }}"
run: tox
- name: SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
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.pull_request.head.sha }}
-Dsonar.pullrequest.key=${{ github.event.number }}
-Dsonar.pullrequest.branch=${{ github.event.pull_request.head.ref }}
-Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }}
-Dsonar.verbose=${{ secrets.ACTIONS_STEP_DEBUG }}

21
.gitignore vendored
View file

@ -1,10 +1,8 @@
__pycache__ __pycache__
*.pyc *.pyc
.pages .pages
.coverage
.pytest_cache .pytest_cache
.mypy_cache
.ruff_cache
.cache
build build
dist dist
*.egg-info *.egg-info
@ -32,6 +30,7 @@ share/python-wheels/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
.flake8
# PyInstaller # PyInstaller
# Usually these files are written by a python script from a template # Usually these files are written by a python script from a template
@ -48,13 +47,14 @@ htmlcov/
.tox/ .tox/
.nox/ .nox/
.coverage .coverage
coverage_html_report
.coverage.* .coverage.*
.cache
nosetests.xml nosetests.xml
coverage.xml coverage.xml
*.cover *.cover
*.py,cover *.py,cover
.hypothesis/ .hypothesis/
.pytest_cache/
cover/ cover/
report.html report.html
@ -99,3 +99,16 @@ venv.bak/
# VScode settings # VScode settings
.vscode .vscode
test.env
tech-support/
tech-support/*
2*
**/report.html
.*report.html
# direnv file
.envrc
clab-atd-anta/*
clab-atd-anta/

View file

@ -1,24 +1,19 @@
--- ---
# See https://pre-commit.com for more information # See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks # See https://pre-commit.com/hooks.html for more hooks
ci: files: ^(anta|docs|scripts|tests)/
autoupdate_commit_msg: "ci: pre-commit autoupdate"
files: ^(anta|docs|scripts|tests|asynceapi)/
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v4.4.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
exclude: docs/.*.svg
- id: end-of-file-fixer - id: end-of-file-fixer
- id: check-added-large-files - id: check-added-large-files
exclude: tests/data/.*$
- id: check-merge-conflict - id: check-merge-conflict
- repo: https://github.com/Lucas-C/pre-commit-hooks - repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.5.5 rev: v1.5.4
hooks: hooks:
- name: Check and insert license on Python files - name: Check and insert license on Python files
id: insert-license id: insert-license
@ -35,7 +30,7 @@ repos:
- name: Check and insert license on Markdown files - name: Check and insert license on Markdown files
id: insert-license id: insert-license
files: .*\.md$ files: .*\.md$
exclude: ^tests/data/.*\.md$ # exclude:
args: args:
- --license-filepath - --license-filepath
- .github/license-short.txt - .github/license-short.txt
@ -45,82 +40,66 @@ repos:
- --comment-style - --comment-style
- '<!--| ~| -->' - '<!--| ~| -->'
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/pycqa/isort
rev: v0.8.4 rev: 5.13.2
hooks: hooks:
- id: ruff - id: isort
name: Run Ruff linter name: Check for changes when running isort on all python files
args: [ --fix ]
- id: ruff-format
name: Run Ruff formatter
- repo: https://github.com/pycqa/pylint - repo: https://github.com/psf/black
rev: "v3.3.2" rev: 24.1.1
hooks:
- id: black
name: Check for changes when running Black on all python files
- repo: https://github.com/pycqa/flake8
rev: 7.0.0
hooks:
- id: flake8
name: Check for PEP8 error on Python files
args:
- --config=/dev/null
- --max-line-length=165
- repo: local # as per https://pylint.pycqa.org/en/latest/user_guide/installation/pre-commit-integration.html
hooks: hooks:
- id: pylint - id: pylint
name: Check code style with pylint entry: pylint
language: python
name: Check for Linting error on Python files
description: This hook runs pylint. description: This hook runs pylint.
types: [python] types: [python]
args: args:
- -rn # Only display messages - -rn # Only display messages
- -sn # Don't display the score - -sn # Don't display the score
- --rcfile=pyproject.toml # Link to config file - --rcfile=pylintrc # Link to config file
additional_dependencies:
- anta[cli]
- types-PyYAML
- types-requests
- types-pyOpenSSL
- pylint_pydantic
- pytest
- pytest-codspeed
- respx
- repo: https://github.com/codespell-project/codespell # Prepare to turn on ruff
rev: v2.3.0 # - repo: https://github.com/astral-sh/ruff-pre-commit
hooks: # # Ruff version.
- id: codespell # rev: v0.0.280
name: Checks for common misspellings in text files. # hooks:
entry: codespell # - id: ruff
language: python
types: [text]
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.14.0 rev: v1.7.1
hooks: hooks:
- id: mypy - id: mypy
name: Check typing with mypy
args: args:
- --config-file=pyproject.toml - --config-file=pyproject.toml
additional_dependencies: additional_dependencies:
- anta[cli] - "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"
- types-PyYAML - types-PyYAML
- types-paramiko
- types-requests - types-requests
- types-pyOpenSSL
- pytest
files: ^(anta|tests)/ files: ^(anta|tests)/
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.43.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]
# TODO: next can go once we have it added to anta properly
- numpydoc

34
.vscode/settings.json vendored
View file

@ -1,14 +1,30 @@
{ {
"ruff.enable": true, "black-formatter.importStrategy": "fromEnvironment",
"ruff.configuration": "pyproject.toml", "pylint.importStrategy": "fromEnvironment",
"python.testing.pytestEnabled": true, "pylint.args": [
"--rcfile=pylintrc"
],
"flake8.importStrategy": "fromEnvironment",
"flake8.args": [
"--config=/dev/null",
"--max-line-length=165"
],
"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": [ "python.testing.pytestArgs": [
"tests" "tests"
], ],
"githubIssues.issueBranchTitle": "issues/${issueNumber}-${issueTitle}", "python.testing.unittestEnabled": false,
"pylint.importStrategy": "fromEnvironment", "python.testing.pytestEnabled": true,
"pylint.args": [ "isort.importStrategy": "fromEnvironment",
"--rcfile=pyproject.toml" "isort.check": true,
],
} }

View file

@ -3,30 +3,23 @@ ARG IMG_OPTION=alpine
### BUILDER ### BUILDER
FROM python:${PYTHON_VER}-${IMG_OPTION} AS BUILDER FROM python:${PYTHON_VER}-${IMG_OPTION} as BUILDER
RUN pip install --upgrade pip RUN pip install --upgrade pip
WORKDIR /local WORKDIR /local
COPY . /local COPY . /local
RUN python -m venv /opt/venv ENV PYTHONPATH=/local
ENV PATH=$PATH:/root/.local/bin
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 ### 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 # Opencontainer labels
# Labels version and revision will be updating # Labels version and revision will be updating
@ -37,22 +30,17 @@ RUN adduser --system anta
LABEL "org.opencontainers.image.title"="anta" \ 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.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.description"="network-test-automation in a Python package and Python scripts to test Arista devices." \
"org.opencontainers.image.source"="https://github.com/aristanetworks/anta" \ "org.opencontainers.image.source"="https://github.com/arista-netdevops-community/anta" \
"org.opencontainers.image.url"="https://www.anta.ninja" \ "org.opencontainers.image.url"="https://www.anta.ninja" \
"org.opencontainers.image.documentation"="https://anta.arista.com" \ "org.opencontainers.image.documentation"="https://www.anta.ninja" \
"org.opencontainers.image.licenses"="Apache-2.0" \ "org.opencontainers.image.licenses"="Apache-2.0" \
"org.opencontainers.image.vendor"="Arista Networks" \ "org.opencontainers.image.vendor"="The anta contributors." \
"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.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.base.name"="python" \
"org.opencontainers.image.revision"="dev" \ "org.opencontainers.image.revision"="dev" \
"org.opencontainers.image.version"="dev" "org.opencontainers.image.version"="dev"
# Copy artifacts from builder COPY --from=BUILDER /root/.local/ /root/.local
COPY --from=BUILDER /opt/venv /opt/venv ENV PATH=$PATH:/root/.local/bin
# Define PATH and default user ENTRYPOINT [ "/root/.local/bin/anta" ]
ENV PATH="/opt/venv/bin:$PATH"
USER anta
ENTRYPOINT [ "/opt/venv/bin/anta" ]

28
NOTICE
View file

@ -1,28 +0,0 @@
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.

View file

@ -2,7 +2,6 @@
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Arista Network Test Automation (ANTA) Framework.""" """Arista Network Test Automation (ANTA) Framework."""
import importlib.metadata import importlib.metadata
import os import os
@ -17,13 +16,10 @@ __credits__ = [
"Guillaume Mulocher", "Guillaume Mulocher",
"Thomas Grimonet", "Thomas Grimonet",
] ]
__copyright__ = "Copyright 2022-2024, Arista Networks, Inc." __copyright__ = "Copyright 2022, Arista EMEA AS"
# ANTA Debug Mode environment variable # Global ANTA debug mode environment variable
__DEBUG__ = os.environ.get("ANTA_DEBUG", "").lower() == "true" __DEBUG__ = bool(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 # Source: https://rich.readthedocs.io/en/stable/appendix/colors.html
@ -48,4 +44,4 @@ RICH_COLOR_THEME = {
"unset": RICH_COLOR_PALETTE.UNSET, "unset": RICH_COLOR_PALETTE.UNSET,
} }
GITHUB_SUGGESTION = "Please reach out to the maintainer team or open an issue on Github: https://github.com/aristanetworks/anta." GITHUB_SUGGESTION = "Please reach out to the maintainer team or open an issue on Github: https://github.com/arista-netdevops-community/anta."

108
anta/aioeapi.py Normal file
View file

@ -0,0 +1,108 @@
# 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]]):
"""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 :], # noqa: E203
)
aioeapi.Device.jsonrpc_exec = jsonrpc_exec

View file

@ -1,84 +1,51 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Catalog related functions.""" """
Catalog related functions
"""
from __future__ import annotations from __future__ import annotations
import importlib import importlib
import logging import logging
import math
from collections import defaultdict
from inspect import isclass from inspect import isclass
from itertools import chain
from json import load as json_load
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Optional, Union from types import ModuleType
from warnings import warn from typing import Any, Dict, List, Optional, Tuple, Type, Union
from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_serializer, model_validator from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_validator
from pydantic.types import ImportString from pydantic.types import ImportString
from pydantic_core import PydanticCustomError from yaml import YAMLError, safe_load
from yaml import YAMLError, safe_dump, safe_load
from anta.logger import anta_log_exception from anta.logger import anta_log_exception
from anta.models import AntaTest 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__) logger = logging.getLogger(__name__)
# { <module_name> : [ { <test_class_name>: <input_as_dict_or_None> }, ... ] } # { <module_name> : [ { <test_class_name>: <input_as_dict_or_None> }, ... ] }
RawCatalogInput = dict[str, list[dict[str, Optional[dict[str, Any]]]]] RawCatalogInput = Dict[str, List[Dict[str, Optional[Dict[str, Any]]]]]
# [ ( <AntaTest class>, <input_as AntaTest.Input or dict or None > ), ... ] # [ ( <AntaTest class>, <input_as AntaTest.Input or dict or None > ), ... ]
ListAntaTestTuples = list[tuple[type[AntaTest], Optional[Union[AntaTest.Input, dict[str, Any]]]]] ListAntaTestTuples = List[Tuple[Type[AntaTest], Optional[Union[AntaTest.Input, Dict[str, Any]]]]]
class AntaTestDefinition(BaseModel): class AntaTestDefinition(BaseModel):
"""Define a test with its associated inputs. """
Define a test with its associated inputs.
Attributes test: An AntaTest concrete subclass
---------- inputs: The associated AntaTest.Input subclass instance
test
An AntaTest concrete subclass.
inputs
The associated AntaTest.Input subclass instance.
""" """
model_config = ConfigDict(frozen=True) model_config = ConfigDict(frozen=True)
test: type[AntaTest] test: Type[AntaTest]
inputs: AntaTest.Input inputs: AntaTest.Input
@model_serializer() def __init__(self, **data: Any) -> None:
def serialize_model(self) -> dict[str, AntaTest.Input]:
"""Serialize the AntaTestDefinition model.
The dictionary representing the model will be look like:
```
<AntaTest subclass name>:
<AntaTest.Input compliant dictionary>
```
Returns
-------
dict
A dictionary representing the model.
""" """
return {self.test.__name__: self.inputs} Inject test in the context to allow to instantiate Input in the BeforeValidator
https://docs.pydantic.dev/2.0/usage/validators/#using-validation-context-with-basemodel-initialization
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.
https://docs.pydantic.dev/2.0/usage/validators/#using-validation-context-with-basemodel-initialization.
""" """
self.__pydantic_validator__.validate_python( self.__pydantic_validator__.validate_python(
data, data,
@ -89,79 +56,65 @@ class AntaTestDefinition(BaseModel):
@field_validator("inputs", mode="before") @field_validator("inputs", mode="before")
@classmethod @classmethod
def instantiate_inputs( def instantiate_inputs(cls, data: AntaTest.Input | dict[str, Any] | None, info: ValidationInfo) -> AntaTest.Input:
cls: type[AntaTestDefinition], """
data: AntaTest.Input | dict[str, Any] | None,
info: ValidationInfo,
) -> AntaTest.Input:
"""Ensure the test inputs can be instantiated and thus are valid.
If the test has no inputs, allow the user to omit providing the `inputs` field. If the test has no inputs, allow the user to omit providing the `inputs` field.
If the test has inputs, allow the user to provide a valid dictionary of the input fields. If the test has inputs, allow the user to provide a valid dictionary of the input fields.
This model validator will instantiate an Input class from the `test` class field. This model validator will instantiate an Input class from the `test` class field.
""" """
if info.context is None: if info.context is None:
msg = "Could not validate inputs as no test class could be identified" raise ValueError("Could not validate inputs as no test class could be identified")
raise ValueError(msg)
# Pydantic guarantees at this stage that test_class is a subclass of AntaTest because of the ordering # Pydantic guarantees at this stage that test_class is a subclass of AntaTest because of the ordering
# of fields in the class definition - so no need to check for this # of fields in the class definition - so no need to check for this
test_class = info.context["test"] test_class = info.context["test"]
if not (isclass(test_class) and issubclass(test_class, AntaTest)): if not (isclass(test_class) and issubclass(test_class, AntaTest)):
msg = f"Could not validate inputs as no test class {test_class} is not a subclass of AntaTest" raise ValueError(f"Could not validate inputs as no test class {test_class} is not a subclass of AntaTest")
raise ValueError(msg)
if isinstance(data, AntaTest.Input):
return data
try:
if data is None: if data is None:
return test_class.Input() return test_class.Input()
if isinstance(data, AntaTest.Input):
return data
if isinstance(data, dict): if isinstance(data, dict):
return test_class.Input(**data) return test_class.Input(**data)
except ValidationError as e: raise ValueError(f"Coud not instantiate inputs as type {type(data).__name__} is not valid")
inputs_msg = str(e).replace("\n", "\n\t")
err_type = "wrong_test_inputs"
raise PydanticCustomError(
err_type,
f"{test_class.name} test inputs are not valid: {inputs_msg}\n",
{"errors": e.errors()},
) from e
msg = f"Could not instantiate inputs as type {type(data).__name__} is not valid"
raise ValueError(msg)
@model_validator(mode="after") @model_validator(mode="after")
def check_inputs(self) -> Self: def check_inputs(self) -> "AntaTestDefinition":
"""Check the `inputs` field typing. """
The `inputs` class attribute needs to be an instance of the AntaTest.Input subclass defined in the class `test`. The `inputs` class attribute needs to be an instance of the AntaTest.Input subclass defined in the class `test`.
""" """
if not isinstance(self.inputs, self.test.Input): if not isinstance(self.inputs, self.test.Input):
msg = f"Test input has type {self.inputs.__class__.__qualname__} but expected type {self.test.Input.__qualname__}" raise ValueError(f"Test input has type {self.inputs.__class__.__qualname__} but expected type {self.test.Input.__qualname__}")
raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError
return self return self
class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods class AntaCatalogFile(RootModel[Dict[ImportString[Any], List[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods
"""Represents an ANTA Test Catalog File. """
This model represents an ANTA Test Catalog File.
Example
-------
A valid test catalog file must have the following structure: A valid test catalog file must have the following structure:
```
<Python module>: <Python module>:
- <AntaTest subclass>: - <AntaTest subclass>:
<AntaTest.Input compliant dictionary> <AntaTest.Input compliant dictionary>
```
""" """
root: dict[ImportString[Any], list[AntaTestDefinition]] root: Dict[ImportString[Any], List[AntaTestDefinition]]
@model_validator(mode="before")
@classmethod
def check_tests(cls, data: Any) -> Any:
"""
Allow the user to provide a Python data structure that only has string values.
This validator will try to flatten and import Python modules, check if the tests classes
are actually defined in their respective Python module and instantiate Input instances
with provided value to validate test inputs.
"""
@staticmethod
def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[ModuleType, list[Any]]: 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. """
Allow the user to provide a data structure with nested Python modules.
Example Example:
-------
``` ```
anta.tests.routing: anta.tests.routing:
generic: generic:
@ -170,16 +123,14 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
- <AntaTestDefinition> - <AntaTestDefinition>
``` ```
`anta.tests.routing.generic` and `anta.tests.routing.bgp` are importable Python modules. `anta.tests.routing.generic` and `anta.tests.routing.bgp` are importable Python modules.
""" """
modules: dict[ModuleType, list[Any]] = {} modules: dict[ModuleType, list[Any]] = {}
for module_name, tests in data.items(): for module_name, tests in data.items():
if package and not module_name.startswith("."): if package and not module_name.startswith("."):
# PLW2901 - we redefine the loop variable on purpose here. module_name = f".{module_name}"
module_name = f".{module_name}" # noqa: PLW2901
try: try:
module: ModuleType = importlib.import_module(name=module_name, package=package) module: ModuleType = importlib.import_module(name=module_name, package=package)
except Exception as e: except Exception as e: # pylint: disable=broad-exception-caught
# A test module is potentially user-defined code. # A test module is potentially user-defined code.
# We need to catch everything if we want to have meaningful logs # 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[1:] if module_name.startswith('.') else module_name}{f' from package {package}' if package else ''}"
@ -188,103 +139,50 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
raise ValueError(message) from e raise ValueError(message) from e
if isinstance(tests, dict): if isinstance(tests, dict):
# This is an inner Python module # This is an inner Python module
modules.update(AntaCatalogFile.flatten_modules(data=tests, package=module.__name__)) modules.update(flatten_modules(data=tests, package=module.__name__))
elif isinstance(tests, list): else:
if not isinstance(tests, list):
raise ValueError(f"Syntax error when parsing: {tests}\nIt must be a list of ANTA tests. Check the test catalog.")
# This is a list of AntaTestDefinition # This is a list of AntaTestDefinition
modules[module] = tests 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 return modules
# ANN401 - Any ok for this validator as we are validating the received data
# and cannot know in advance what it is.
@model_validator(mode="before")
@classmethod
def check_tests(cls: type[AntaCatalogFile], data: Any) -> Any: # noqa: ANN401
"""Allow the user to provide a Python data structure that only has string values.
This validator will try to flatten and import Python modules, check if the tests classes
are actually defined in their respective Python module and instantiate Input instances
with provided value to validate test inputs.
"""
if isinstance(data, dict): if isinstance(data, dict):
if not data: typed_data: dict[ModuleType, list[Any]] = flatten_modules(data)
return data
typed_data: dict[ModuleType, list[Any]] = AntaCatalogFile.flatten_modules(data)
for module, tests in typed_data.items(): for module, tests in typed_data.items():
test_definitions: list[AntaTestDefinition] = [] test_definitions: list[AntaTestDefinition] = []
for test_definition in tests: for test_definition in tests:
if isinstance(test_definition, AntaTestDefinition):
test_definitions.append(test_definition)
continue
if not isinstance(test_definition, dict): 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(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: if len(test_definition) != 1:
msg = ( raise ValueError(
f"Syntax error when parsing: {test_definition}\n" f"Syntax error when parsing: {test_definition}\nIt must be a dictionary with a single entry. Check the indentation in the test catalog."
"It 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(): for test_name, test_inputs in test_definition.copy().items():
test: type[AntaTest] | None = getattr(module, test_name, None) test: type[AntaTest] | None = getattr(module, test_name, None)
if test is None: if test is None:
msg = ( raise ValueError(
f"{test_name} is not defined in Python module {module.__name__}" f"{test_name} is not defined in Python module {module.__name__}{f' (from {module.__file__})' if module.__file__ is not None else ''}"
f"{f' (from {module.__file__})' if module.__file__ is not None else ''}"
) )
raise ValueError(msg)
test_definitions.append(AntaTestDefinition(test=test, inputs=test_inputs)) test_definitions.append(AntaTestDefinition(test=test, inputs=test_inputs))
typed_data[module] = test_definitions 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)), indent=2, 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: class AntaCatalog:
"""Class representing an ANTA Catalog. """
Class representing an ANTA Catalog.
It can be instantiated using its constructor or one of the static methods: `parse()`, `from_list()` or `from_dict()` It can be instantiated using its contructor or one of the static methods: `parse()`, `from_list()` or `from_dict()`
""" """
def __init__( def __init__(self, tests: list[AntaTestDefinition] | None = None, filename: str | Path | None = None) -> None:
self, """
tests: list[AntaTestDefinition] | None = None, Constructor of AntaCatalog.
filename: str | Path | None = None,
) -> None:
"""Instantiate an AntaCatalog instance.
Parameters
----------
tests
A list of AntaTestDefinition instances.
filename
The path from which the catalog is loaded.
Args:
tests: A list of AntaTestDefinition instances.
filename: The path from which the catalog is loaded.
""" """
self._tests: list[AntaTestDefinition] = [] self._tests: list[AntaTestDefinition] = []
if tests is not None: if tests is not None:
@ -296,61 +194,37 @@ class AntaCatalog:
else: else:
self._filename = Path(filename) self._filename = 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 @property
def filename(self) -> Path | None: def filename(self) -> Path | None:
"""Path of the file used to create this AntaCatalog instance.""" """Path of the file used to create this AntaCatalog instance"""
return self._filename return self._filename
@property @property
def tests(self) -> list[AntaTestDefinition]: def tests(self) -> list[AntaTestDefinition]:
"""List of AntaTestDefinition in this catalog.""" """List of AntaTestDefinition in this catalog"""
return self._tests return self._tests
@tests.setter @tests.setter
def tests(self, value: list[AntaTestDefinition]) -> None: def tests(self, value: list[AntaTestDefinition]) -> None:
if not isinstance(value, list): if not isinstance(value, list):
msg = "The catalog must contain a list of tests" raise ValueError("The catalog must contain a list of tests")
raise TypeError(msg)
for t in value: for t in value:
if not isinstance(t, AntaTestDefinition): if not isinstance(t, AntaTestDefinition):
msg = "A test in the catalog must be an AntaTestDefinition instance" raise ValueError("A test in the catalog must be an AntaTestDefinition instance")
raise TypeError(msg)
self._tests = value self._tests = value
@staticmethod @staticmethod
def parse(filename: str | Path, file_format: Literal["yaml", "json"] = "yaml") -> AntaCatalog: def parse(filename: str | Path) -> AntaCatalog:
"""Create an AntaCatalog instance from a test catalog 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"]: Create an AntaCatalog instance from a test catalog file.
message = f"'{file_format}' is not a valid format for an AntaCatalog file. Only 'yaml' and 'json' are supported."
raise ValueError(message)
Args:
filename: Path to test catalog YAML file
"""
try: try:
file: Path = filename if isinstance(filename, Path) else Path(filename) with open(file=filename, mode="r", encoding="UTF-8") as file:
with file.open(encoding="UTF-8") as f: data = safe_load(file)
data = safe_load(f) if file_format == "yaml" else json_load(f) except (TypeError, YAMLError, OSError) as e:
except (TypeError, YAMLError, OSError, ValueError) as e:
message = f"Unable to parse ANTA Test Catalog file '{filename}'" message = f"Unable to parse ANTA Test Catalog file '{filename}'"
anta_log_exception(e, message, logger) anta_log_exception(e, message, logger)
raise raise
@ -359,23 +233,15 @@ class AntaCatalog:
@staticmethod @staticmethod
def from_dict(data: RawCatalogInput, filename: str | Path | None = None) -> AntaCatalog: def from_dict(data: RawCatalogInput, filename: str | Path | None = None) -> AntaCatalog:
"""Create an AntaCatalog instance from a dictionary data structure. """
Create an AntaCatalog instance from a dictionary data structure.
See RawCatalogInput type alias for details. See RawCatalogInput type alias for details.
It is the data structure returned by `yaml.load()` function of a valid It is the data structure returned by `yaml.load()` function of a valid
YAML Test Catalog file. YAML Test Catalog file.
Parameters Args:
---------- data: Python dictionary used to instantiate the AntaCatalog instance
data filename: value to be set as AntaCatalog instance attribute
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] = [] tests: list[AntaTestDefinition] = []
if data is None: if data is None:
@ -383,17 +249,12 @@ class AntaCatalog:
return AntaCatalog(filename=filename) return AntaCatalog(filename=filename)
if not isinstance(data, dict): if not isinstance(data, dict):
msg = f"Wrong input type for catalog data{f' (from {filename})' if filename is not None else ''}, must be a dict, got {type(data).__name__}" raise ValueError(f"Wrong input type for catalog data{f' (from {filename})' if filename is not None else ''}, must be a dict, got {type(data).__name__}")
raise TypeError(msg)
try: try:
catalog_data = AntaCatalogFile(data) # type: ignore[arg-type] catalog_data = AntaCatalogFile(**data) # type: ignore[arg-type]
except ValidationError as e: except ValidationError as e:
anta_log_exception( anta_log_exception(e, f"Test catalog is invalid!{f' (from {filename})' if filename is not None else ''}", logger)
e,
f"Test catalog is invalid!{f' (from {filename})' if filename is not None else ''}",
logger,
)
raise raise
for t in catalog_data.root.values(): for t in catalog_data.root.values():
tests.extend(t) tests.extend(t)
@ -401,19 +262,12 @@ class AntaCatalog:
@staticmethod @staticmethod
def from_list(data: ListAntaTestTuples) -> AntaCatalog: def from_list(data: ListAntaTestTuples) -> AntaCatalog:
"""Create an AntaCatalog instance from a list data structure. """
Create an AntaCatalog instance from a list data structure.
See ListAntaTestTuples type alias for details. See ListAntaTestTuples type alias for details.
Parameters Args:
---------- data: Python list used to instantiate the AntaCatalog instance
data
Python list used to instantiate the AntaCatalog instance.
Returns
-------
AntaCatalog
An AntaCatalog populated with the 'data' list content.
""" """
tests: list[AntaTestDefinition] = [] tests: list[AntaTestDefinition] = []
try: try:
@ -423,120 +277,15 @@ class AntaCatalog:
raise raise
return AntaCatalog(tests) return AntaCatalog(tests)
@classmethod def get_tests_by_tags(self, tags: list[str], strict: bool = False) -> list[AntaTestDefinition]:
def merge_catalogs(cls, catalogs: list[AntaCatalog]) -> AntaCatalog:
"""Merge multiple AntaCatalog instances.
Parameters
----------
catalogs
A list of AntaCatalog instances to merge.
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 all the tests that have matching tags in their input filters.
return cls(tests=combined_tests) If strict=True, returns 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.
def merge(self, catalog: AntaCatalog) -> AntaCatalog:
"""Merge two AntaCatalog instances.
Warning
-------
This method is deprecated and will be removed in ANTA v2.0. Use `AntaCatalog.merge_catalogs()` instead.
Parameters
----------
catalog
AntaCatalog instance to merge to this instance.
Returns
-------
AntaCatalog
A new AntaCatalog instance containing the tests of the two instances.
""" """
# TODO: Use a decorator to deprecate this method instead. See https://github.com/aristanetworks/anta/issues/754 result: list[AntaTestDefinition] = []
warn(
message="AntaCatalog.merge() is deprecated and will be removed in ANTA v2.0. Use AntaCatalog.merge_catalogs() instead.",
category=DeprecationWarning,
stacklevel=2,
)
return self.merge_catalogs([self, catalog])
def dump(self) -> AntaCatalogFile:
"""Return an AntaCatalogFile instance from this AntaCatalog instance.
Returns
-------
AntaCatalogFile
An AntaCatalogFile instance containing tests of this AntaCatalog instance.
"""
root: dict[ImportString[Any], list[AntaTestDefinition]] = {}
for test in self.tests: for test in self.tests:
# Cannot use AntaTest.module property as the class is not instantiated if test.inputs.filters and (f := test.inputs.filters.tags):
root.setdefault(test.test.__module__, []).append(test) if (strict and all(t in tags for t in f)) or (not strict and any(t in tags for t in f)):
return AntaCatalogFile(root=root) result.append(test)
return result
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.
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)
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
-------
set[AntaTestDefinition]
A set of tests that match the given tags.
Raises
------
ValueError
If the indexes have not been built prior to method call.
"""
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)

View file

@ -1,41 +1,72 @@
#!/usr/bin/env python
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""ANTA CLI.""" """
ANTA CLI
"""
from __future__ import annotations from __future__ import annotations
import logging
import pathlib
import sys import sys
from typing import Callable
from anta import __DEBUG__ import click
# Note: need to separate this file from _main to be able to fail on the import. from anta import GITHUB_SUGGESTION, __version__
try: from anta.cli.check import check as check_command
from ._main import anta, cli 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
except ImportError as exc: logger = logging.getLogger(__name__)
def build_cli(exception: Exception) -> Callable[[], None]:
"""Build CLI function using the caught exception."""
def wrap() -> None: @click.group(cls=AliasedGroup)
"""Error message if any CLI dependency is missing.""" @click.pass_context
print( @click.version_option(__version__)
"The ANTA command line client could not run because the required " @click.option(
"dependencies were not installed.\nMake sure you've installed " "--log-file",
"everything with: pip install 'anta[cli]'" help="Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout.",
) show_envvar=True,
if __DEBUG__: type=click.Path(file_okay=True, dir_okay=False, writable=True, path_type=pathlib.Path),
print(f"The caught exception was: {exception}") )
@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)
sys.exit(1)
return wrap 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)
cli = build_cli(exc)
__all__ = ["anta", "cli"] def cli() -> None:
"""Entrypoint for pyproject.toml"""
try:
anta(obj={}, auto_envvar_prefix="ANTA")
except Exception as e: # pylint: disable=broad-exception-caught
anta_log_exception(e, f"Uncaught Exception when running ANTA CLI\n{GITHUB_SUGGESTION}", logger)
sys.exit(ExitCode.INTERNAL_ERROR)
if __name__ == "__main__": if __name__ == "__main__":
cli() cli()

View file

@ -1,71 +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.
"""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)

View file

@ -1,8 +1,9 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Click commands to validate configuration files.""" """
Click commands to validate configuration files
"""
import click import click
from anta.cli.check import commands from anta.cli.check import commands
@ -10,7 +11,7 @@ from anta.cli.check import commands
@click.group @click.group
def check() -> None: def check() -> None:
"""Commands to validate configuration files.""" """Commands to validate configuration files"""
check.add_command(commands.catalog) check.add_command(commands.catalog)

View file

@ -2,28 +2,28 @@
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
# pylint: disable = redefined-outer-name # pylint: disable = redefined-outer-name
"""Click commands to validate configuration files.""" """
Click commands to validate configuration files
"""
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING
import click import click
from rich.pretty import pretty_repr from rich.pretty import pretty_repr
from anta.catalog import AntaCatalog
from anta.cli.console import console from anta.cli.console import console
from anta.cli.utils import catalog_options from anta.cli.utils import catalog_options
if TYPE_CHECKING:
from anta.catalog import AntaCatalog
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@click.command @click.command
@catalog_options @catalog_options
def catalog(catalog: AntaCatalog) -> None: def catalog(catalog: AntaCatalog) -> None:
"""Check that the catalog is valid.""" """
Check that the catalog is valid
"""
console.print(f"[bold][green]Catalog is valid: {catalog.filename}") console.print(f"[bold][green]Catalog is valid: {catalog.filename}")
console.print(pretty_repr(catalog.tests)) console.print(pretty_repr(catalog.tests))

View file

@ -1,9 +1,9 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""ANTA Top-level Console. """
ANTA Top-level Console
https://rich.readthedocs.io/en/stable/console.html#console-api. https://rich.readthedocs.io/en/stable/console.html#console-api
""" """
from rich.console import Console from rich.console import Console

View file

@ -1,8 +1,9 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Click commands to execute EOS commands on remote devices.""" """
Click commands to execute EOS commands on remote devices
"""
import click import click
from anta.cli.debug import commands from anta.cli.debug import commands
@ -10,7 +11,7 @@ from anta.cli.debug import commands
@click.group @click.group
def debug() -> None: def debug() -> None:
"""Commands to execute EOS commands on remote devices.""" """Commands to execute EOS commands on remote devices"""
debug.add_command(commands.run_cmd) debug.add_command(commands.run_cmd)

View file

@ -2,24 +2,23 @@
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
# pylint: disable = redefined-outer-name # pylint: disable = redefined-outer-name
"""Click commands to execute EOS commands on remote devices.""" """
Click commands to execute EOS commands on remote devices
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
from typing import TYPE_CHECKING, Literal from typing import Literal
import click import click
from anta.cli.console import console from anta.cli.console import console
from anta.cli.debug.utils import debug_options from anta.cli.debug.utils import debug_options
from anta.cli.utils import ExitCode from anta.cli.utils import ExitCode
from anta.device import AntaDevice
from anta.models import AntaCommand, AntaTemplate from anta.models import AntaCommand, AntaTemplate
if TYPE_CHECKING:
from anta.device import AntaDevice
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -27,15 +26,8 @@ logger = logging.getLogger(__name__)
@debug_options @debug_options
@click.pass_context @click.pass_context
@click.option("--command", "-c", type=str, required=True, help="Command to run") @click.option("--command", "-c", type=str, required=True, help="Command to run")
def run_cmd( def run_cmd(ctx: click.Context, device: AntaDevice, command: str, ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int) -> None:
ctx: click.Context, """Run arbitrary command to an ANTA device"""
device: AntaDevice,
command: str,
ofmt: Literal["json", "text"],
version: Literal["1", "latest"],
revision: int,
) -> None:
"""Run arbitrary command to an ANTA device."""
console.print(f"Run command [green]{command}[/green] on [red]{device.name}[/red]") 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 # I do not assume the following line, but click make me do it
v: Literal[1, "latest"] = version if version == "latest" else 1 v: Literal[1, "latest"] = version if version == "latest" else 1
@ -53,34 +45,18 @@ def run_cmd(
@click.command @click.command
@debug_options @debug_options
@click.pass_context @click.pass_context
@click.option( @click.option("--template", "-t", type=str, required=True, help="Command template to run. E.g. 'show vlan {vlan_id}'")
"--template",
"-t",
type=str,
required=True,
help="Command template to run. E.g. 'show vlan {vlan_id}'",
)
@click.argument("params", required=True, nargs=-1) @click.argument("params", required=True, nargs=-1)
def run_template( def run_template(
ctx: click.Context, ctx: click.Context, device: AntaDevice, template: str, params: list[str], ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int
device: AntaDevice,
template: str,
params: list[str],
ofmt: Literal["json", "text"],
version: Literal["1", "latest"],
revision: int,
) -> None: ) -> None:
# Using \b for click # pylint: disable=too-many-arguments
# ruff: noqa: D301
"""Run arbitrary templated command to an ANTA device. """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. 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])) template_params = dict(zip(params[::2], params[1::2]))
@ -88,7 +64,7 @@ def run_template(
# I do not assume the following line, but click make me do it # I do not assume the following line, but click make me do it
v: Literal[1, "latest"] = version if version == "latest" else 1 v: Literal[1, "latest"] = version if version == "latest" else 1
t = AntaTemplate(template=template, ofmt=ofmt, version=v, revision=revision) t = AntaTemplate(template=template, ofmt=ofmt, version=v, revision=revision)
c = t.render(**template_params) c = t.render(**template_params) # type: ignore
asyncio.run(device.collect(c)) asyncio.run(device.collect(c))
if not c.collected: if not c.collected:
console.print(f"[bold red] Command '{c.command}' failed to execute!") console.print(f"[bold red] Command '{c.command}' failed to execute!")

View file

@ -1,56 +1,40 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Utils functions to use with anta.cli.debug module.""" """
Utils functions to use with anta.cli.debug module.
"""
from __future__ import annotations from __future__ import annotations
import functools import functools
import logging import logging
from typing import TYPE_CHECKING, Any, Callable from typing import Any
import click import click
from anta.cli.utils import ExitCode, core_options from anta.cli.utils import ExitCode, inventory_options
from anta.inventory import AntaInventory
if TYPE_CHECKING:
from anta.inventory import AntaInventory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def debug_options(f: Callable[..., Any]) -> Callable[..., Any]: def debug_options(f: Any) -> Any:
"""Click common options required to execute a command on a specific device.""" """Click common options required to execute a command on a specific device"""
@core_options @inventory_options
@click.option( @click.option("--ofmt", type=click.Choice(["json", "text"]), default="json", help="EOS eAPI format to use. can be text or json")
"--ofmt", @click.option("--version", "-v", type=click.Choice(["1", "latest"]), default="latest", help="EOS eAPI version")
type=click.Choice(["json", "text"]),
default="json",
help="EOS eAPI format to use. can be text or json",
)
@click.option(
"--version",
"-v",
type=click.Choice(["1", "latest"]),
default="latest",
help="EOS eAPI version",
)
@click.option("--revision", "-r", type=int, help="eAPI command revision", required=False) @click.option("--revision", "-r", type=int, help="eAPI command revision", required=False)
@click.option("--device", "-d", type=str, required=True, help="Device from inventory to use") @click.option("--device", "-d", type=str, required=True, help="Device from inventory to use")
@click.pass_context @click.pass_context
@functools.wraps(f) @functools.wraps(f)
def wrapper( def wrapper(ctx: click.Context, *args: tuple[Any], inventory: AntaInventory, tags: list[str] | None, device: str, **kwargs: dict[str, Any]) -> Any:
ctx: click.Context, # pylint: disable=unused-argument
*args: tuple[Any], try:
inventory: AntaInventory, d = inventory[device]
device: str, except KeyError as e:
**kwargs: Any, message = f"Device {device} does not exist in Inventory"
) -> Any: logger.error(e, message)
# TODO: @gmuloc - tags come from context https://github.com/aristanetworks/anta/issues/584
# ruff: noqa: ARG001
if (d := inventory.get(device)) is None:
logger.error("Device '%s' does not exist in Inventory", device)
ctx.exit(ExitCode.USAGE_ERROR) ctx.exit(ExitCode.USAGE_ERROR)
return f(*args, device=d, **kwargs) return f(*args, device=d, **kwargs)

View file

@ -1,18 +1,19 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Click commands to execute various scripts on EOS devices.""" """
Click commands to execute various scripts on EOS devices
"""
import click import click
from anta.cli.exec import commands from anta.cli.exec import commands
@click.group("exec") @click.group
def _exec() -> None: def exec() -> None: # pylint: disable=redefined-builtin
"""Commands to execute various scripts on EOS devices.""" """Commands to execute various scripts on EOS devices"""
_exec.add_command(commands.clear_counters) exec.add_command(commands.clear_counters)
_exec.add_command(commands.snapshot) exec.add_command(commands.snapshot)
_exec.add_command(commands.collect_tech_support) exec.add_command(commands.collect_tech_support)

View file

@ -1,35 +1,32 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Click commands to execute various scripts on EOS devices.""" """
Click commands to execute various scripts on EOS devices
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
import sys import sys
from datetime import datetime, timezone from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
import click import click
from yaml import safe_load 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 from anta.cli.utils import inventory_options
from anta.inventory import AntaInventory
if TYPE_CHECKING:
from anta.inventory import AntaInventory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@click.command @click.command
@inventory_options @inventory_options
def clear_counters(inventory: AntaInventory, tags: set[str] | None) -> None: def clear_counters(inventory: AntaInventory, tags: list[str] | None) -> None:
"""Clear counter statistics on EOS devices.""" """Clear counter statistics on EOS devices"""
asyncio.run(utils.clear_counters(inventory, tags=tags)) asyncio.run(clear_counters_utils(inventory, tags=tags))
@click.command() @click.command()
@ -48,57 +45,34 @@ def clear_counters(inventory: AntaInventory, tags: set[str] | None) -> None:
show_envvar=True, show_envvar=True,
type=click.Path(file_okay=False, dir_okay=True, exists=False, writable=True, path_type=Path), type=click.Path(file_okay=False, dir_okay=True, exists=False, writable=True, path_type=Path),
help="Directory to save commands output.", help="Directory to save commands output.",
default=f"anta_snapshot_{datetime.now(tz=timezone.utc).astimezone().strftime('%Y-%m-%d_%H_%M_%S')}", default=f"anta_snapshot_{datetime.now().strftime('%Y-%m-%d_%H_%M_%S')}",
show_default=True, show_default=True,
) )
def snapshot(inventory: AntaInventory, tags: set[str] | None, commands_list: Path, output: Path) -> None: def snapshot(inventory: AntaInventory, tags: list[str] | None, commands_list: Path, output: Path) -> None:
"""Collect commands output from devices in inventory.""" """Collect commands output from devices in inventory"""
console.print(f"Collecting data for {commands_list}") print(f"Collecting data for {commands_list}")
console.print(f"Output directory is {output}") print(f"Output directory is {output}")
try: try:
with commands_list.open(encoding="UTF-8") as file: with open(commands_list, "r", encoding="UTF-8") as file:
file_content = file.read() file_content = file.read()
eos_commands = safe_load(file_content) eos_commands = safe_load(file_content)
except FileNotFoundError: except FileNotFoundError:
logger.error("Error reading %s", commands_list) logger.error(f"Error reading {commands_list}")
sys.exit(1) sys.exit(1)
asyncio.run(utils.collect_commands(inventory, eos_commands, output, tags=tags)) asyncio.run(collect_commands(inventory, eos_commands, output, tags=tags))
@click.command() @click.command()
@inventory_options @inventory_options
@click.option( @click.option("--output", "-o", default="./tech-support", show_default=True, help="Path for test catalog", type=click.Path(path_type=Path), required=False)
"--output", @click.option("--latest", help="Number of scheduled show-tech to retrieve", type=int, required=False)
"-o",
default="./tech-support",
show_default=True,
help="Path for test catalog",
type=click.Path(path_type=Path),
required=False,
)
@click.option(
"--latest",
help="Number of scheduled show-tech to retrieve",
type=int,
required=False,
)
@click.option( @click.option(
"--configure", "--configure",
help=( help="Ensure devices have 'aaa authorization exec default local' configured (required for SCP on EOS). THIS WILL CHANGE THE CONFIGURATION OF YOUR NETWORK.",
"[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, default=False,
is_flag=True, is_flag=True,
show_default=True, show_default=True,
) )
def collect_tech_support( def collect_tech_support(inventory: AntaInventory, tags: list[str] | None, output: Path, latest: int | None, configure: bool) -> None:
inventory: AntaInventory, """Collect scheduled tech-support from EOS devices"""
tags: set[str] | None, asyncio.run(collect_scheduled_show_tech(inventory, output, configure, tags=tags, latest=latest))
output: Path,
latest: int | None,
*,
configure: bool,
) -> None:
"""Collect scheduled tech-support from EOS devices."""
asyncio.run(utils.collect_show_tech(inventory, output, configure=configure, tags=tags, latest=latest))

View file

@ -2,35 +2,35 @@
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Exec CLI helpers.""" """
Exec CLI helpers
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import itertools import itertools
import json import json
import logging import logging
import re
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Literal from typing import Literal
from click.exceptions import UsageError from aioeapi import EapiCommandError
from httpx import ConnectError, HTTPError from httpx import ConnectError, HTTPError
from anta.device import AntaDevice, AsyncEOSDevice from anta.device import AntaDevice, AsyncEOSDevice
from anta.inventory import AntaInventory
from anta.models import AntaCommand from anta.models import AntaCommand
from anta.tools import safe_command
from asynceapi import EapiCommandError
if TYPE_CHECKING:
from anta.inventory import AntaInventory
EOS_SCHEDULED_TECH_SUPPORT = "/mnt/flash/schedule/tech-support" EOS_SCHEDULED_TECH_SUPPORT = "/mnt/flash/schedule/tech-support"
INVALID_CHAR = "`~!@#$/" INVALID_CHAR = "`~!@#$/"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def clear_counters(anta_inventory: AntaInventory, tags: set[str] | None = None) -> None: async def clear_counters_utils(anta_inventory: AntaInventory, tags: list[str] | None = None) -> None:
"""Clear counters.""" """
Clear counters
"""
async def clear(dev: AntaDevice) -> None: async def clear(dev: AntaDevice) -> None:
commands = [AntaCommand(command="clear counters")] commands = [AntaCommand(command="clear counters")]
@ -39,51 +39,48 @@ async def clear_counters(anta_inventory: AntaInventory, tags: set[str] | None =
await dev.collect_commands(commands=commands) await dev.collect_commands(commands=commands)
for command in commands: for command in commands:
if not command.collected: if not command.collected:
logger.error("Could not clear counters on device %s: %s", dev.name, command.errors) logger.error(f"Could not clear counters on device {dev.name}: {command.errors}")
logger.info("Cleared counters on %s (%s)", dev.name, dev.hw_model) logger.info(f"Cleared counters on {dev.name} ({dev.hw_model})")
logger.info("Connecting to devices...") logger.info("Connecting to devices...")
await anta_inventory.connect_inventory() await anta_inventory.connect_inventory()
devices = anta_inventory.get_inventory(established_only=True, tags=tags).devices devices = anta_inventory.get_inventory(established_only=True, tags=tags).values()
logger.info("Clearing counters on remote devices...") logger.info("Clearing counters on remote devices...")
await asyncio.gather(*(clear(device) for device in devices)) await asyncio.gather(*(clear(device) for device in devices))
async def collect_commands( async def collect_commands(
inv: AntaInventory, inv: AntaInventory,
commands: dict[str, list[str]], commands: dict[str, str],
root_dir: Path, root_dir: Path,
tags: set[str] | None = None, tags: list[str] | None = None,
) -> None: ) -> None:
"""Collect EOS commands.""" """
Collect EOS commands
"""
async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "text"]) -> None: async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "text"]) -> None:
outdir = Path() / root_dir / dev.name / outformat outdir = Path() / root_dir / dev.name / outformat
outdir.mkdir(parents=True, exist_ok=True) outdir.mkdir(parents=True, exist_ok=True)
safe_command = re.sub(r"(/|\|$)", "_", command)
c = AntaCommand(command=command, ofmt=outformat) c = AntaCommand(command=command, ofmt=outformat)
await dev.collect(c) await dev.collect(c)
if not c.collected: if not c.collected:
logger.error("Could not collect commands on device %s: %s", dev.name, c.errors) logger.error(f"Could not collect commands on device {dev.name}: {c.errors}")
return return
if c.ofmt == "json": if c.ofmt == "json":
outfile = outdir / f"{safe_command(command)}.json" outfile = outdir / f"{safe_command}.json"
content = json.dumps(c.json_output, indent=2) content = json.dumps(c.json_output, indent=2)
elif c.ofmt == "text": elif c.ofmt == "text":
outfile = outdir / f"{safe_command(command)}.log" outfile = outdir / f"{safe_command}.log"
content = c.text_output 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: with outfile.open(mode="w", encoding="UTF-8") as f:
f.write(content) f.write(content)
logger.info("Collected command '%s' from device %s (%s)", command, dev.name, dev.hw_model) logger.info(f"Collected command '{command}' from device {dev.name} ({dev.hw_model})")
logger.info("Connecting to devices...") logger.info("Connecting to devices...")
await inv.connect_inventory() await inv.connect_inventory()
devices = inv.get_inventory(established_only=True, tags=tags).devices devices = inv.get_inventory(established_only=True, tags=tags).values()
if not devices:
logger.info("No online device found. Exiting")
return
logger.info("Collecting commands from remote devices") logger.info("Collecting commands from remote devices")
coros = [] coros = []
if "json_format" in commands: if "json_format" in commands:
@ -93,14 +90,18 @@ async def collect_commands(
res = await asyncio.gather(*coros, return_exceptions=True) res = await asyncio.gather(*coros, return_exceptions=True)
for r in res: for r in res:
if isinstance(r, Exception): if isinstance(r, Exception):
logger.error("Error when collecting commands: %s", str(r)) logger.error(f"Error when collecting commands: {str(r)}")
async def collect_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bool, tags: set[str] | None = None, latest: int | None = None) -> None: async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, configure: bool, tags: list[str] | None = None, latest: int | None = None) -> None:
"""Collect scheduled show-tech on devices.""" """
Collect scheduled show-tech on devices
"""
async def collect(device: AntaDevice) -> None: async def collect(device: AntaDevice) -> None:
"""Collect all the tech-support files stored on Arista switches flash and copy them locally.""" """
Collect all the tech-support files stored on Arista switches flash and copy them locally
"""
try: try:
# Get the tech-support filename to retrieve # Get the tech-support filename to retrieve
cmd = f"bash timeout 10 ls -1t {EOS_SCHEDULED_TECH_SUPPORT}" cmd = f"bash timeout 10 ls -1t {EOS_SCHEDULED_TECH_SUPPORT}"
@ -108,12 +109,12 @@ async def collect_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bo
cmd += f" | head -{latest}" cmd += f" | head -{latest}"
command = AntaCommand(command=cmd, ofmt="text") command = AntaCommand(command=cmd, ofmt="text")
await device.collect(command=command) await device.collect(command=command)
if not (command.collected and command.text_output): if 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) filenames = list(map(lambda f: Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}"), command.text_output.splitlines()))
else:
logger.error(f"Unable to get tech-support filenames on {device.name}: verify that {EOS_SCHEDULED_TECH_SUPPORT} is not empty")
return return
filenames = [Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}") for f in command.text_output.splitlines()]
# Create directories # Create directories
outdir = Path() / root_dir / f"{device.name.lower()}" outdir = Path() / root_dir / f"{device.name.lower()}"
outdir.mkdir(parents=True, exist_ok=True) outdir.mkdir(parents=True, exist_ok=True)
@ -123,49 +124,38 @@ async def collect_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bo
await device.collect(command=command) await device.collect(command=command)
if command.collected and not command.text_output: if command.collected and not command.text_output:
logger.debug("'aaa authorization exec default local' is not configured on device %s", device.name) logger.debug(f"'aaa authorization exec default local' is not configured on device {device.name}")
if not configure: if configure:
logger.error("Unable to collect tech-support on %s: configuration 'aaa authorization exec default local' is not present", device.name) # Otherwise mypy complains about enable
return assert isinstance(device, AsyncEOSDevice)
# TODO - @mtache - add `config` field to `AntaCommand` object to handle this use case.
# 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)
commands = [] commands = []
# TODO: @mtache - add `config` field to `AntaCommand` object to handle this use case. if device.enable and device._enable_password is not None: # pylint: disable=protected-access
# Otherwise mypy complains about enable as it is only implemented for AsyncEOSDevice commands.append({"cmd": "enable", "input": device._enable_password}) # pylint: disable=protected-access
# 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:
commands.append({"cmd": "enable", "input": device._enable_password})
elif device.enable: elif device.enable:
commands.append({"cmd": "enable"}) commands.append({"cmd": "enable"})
commands.extend( commands.extend(
[ [
{"cmd": "configure terminal"}, {"cmd": "configure terminal"},
{"cmd": "aaa authorization exec default local"}, {"cmd": "aaa authorization exec default local"},
], ]
) )
logger.warning("Configuring 'aaa authorization exec default local' on device %s", device.name) logger.warning(f"Configuring 'aaa authorization exec default local' on device {device.name}")
command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text") command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text")
await device._session.cli(commands=commands) await device._session.cli(commands=commands) # pylint: disable=protected-access
logger.info("Configured 'aaa authorization exec default local' on device %s", device.name) logger.info(f"Configured 'aaa authorization exec default local' on device {device.name}")
else:
logger.debug("'aaa authorization exec default local' is already configured on device %s", device.name) logger.error(f"Unable to collect tech-support on {device.name}: configuration 'aaa authorization exec default local' is not present")
return
logger.debug(f"'aaa authorization exec default local' is already configured on device {device.name}")
await device.copy(sources=filenames, destination=outdir, direction="from") await device.copy(sources=filenames, destination=outdir, direction="from")
logger.info("Collected %s scheduled tech-support from %s", len(filenames), device.name) logger.info(f"Collected {len(filenames)} scheduled tech-support from {device.name}")
except (EapiCommandError, HTTPError, ConnectError) as e: except (EapiCommandError, HTTPError, ConnectError) as e:
logger.error("Unable to collect tech-support on %s: %s", device.name, str(e)) logger.error(f"Unable to collect tech-support on {device.name}: {str(e)}")
logger.info("Connecting to devices...") logger.info("Connecting to devices...")
await inv.connect_inventory() await inv.connect_inventory()
devices = inv.get_inventory(established_only=True, tags=tags).devices devices = inv.get_inventory(established_only=True, tags=tags).values()
await asyncio.gather(*(collect(device) for device in devices)) await asyncio.gather(*(collect(device) for device in devices))

View file

@ -1,8 +1,9 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Click commands to get information from or generate inventories.""" """
Click commands to get information from or generate inventories
"""
import click import click
from anta.cli.get import commands from anta.cli.get import commands
@ -10,11 +11,10 @@ from anta.cli.get import commands
@click.group @click.group
def get() -> None: def get() -> None:
"""Commands to get information from or generate inventories.""" """Commands to get information from or generate inventories"""
get.add_command(commands.from_cvp) get.add_command(commands.from_cvp)
get.add_command(commands.from_ansible) get.add_command(commands.from_ansible)
get.add_command(commands.inventory) get.add_command(commands.inventory)
get.add_command(commands.tags) get.add_command(commands.tags)
get.add_command(commands.tests)

View file

@ -2,18 +2,17 @@
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
# pylint: disable = redefined-outer-name # pylint: disable = redefined-outer-name
"""Click commands to get information from or generate inventories.""" """
Click commands to get information from or generate inventories
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any
import click import click
import requests
from cvprac.cvp_client import CvpClient from cvprac.cvp_client import CvpClient
from cvprac.cvp_client_errors import CvpApiError from cvprac.cvp_client_errors import CvpApiError
from rich.pretty import pretty_repr from rich.pretty import pretty_repr
@ -21,11 +20,9 @@ from rich.pretty import pretty_repr
from anta.cli.console import console from anta.cli.console import console
from anta.cli.get.utils import inventory_output_options from anta.cli.get.utils import inventory_output_options
from anta.cli.utils import ExitCode, inventory_options from anta.cli.utils import ExitCode, inventory_options
from anta.inventory import AntaInventory
from .utils import create_inventory_from_ansible, create_inventory_from_cvp, explore_package, get_cv_token from .utils import create_inventory_from_ansible, create_inventory_from_cvp, get_cv_token
if TYPE_CHECKING:
from anta.inventory import AntaInventory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -37,49 +34,33 @@ logger = logging.getLogger(__name__)
@click.option("--username", "-u", help="CloudVision username", type=str, required=True) @click.option("--username", "-u", help="CloudVision username", type=str, required=True)
@click.option("--password", "-p", help="CloudVision password", 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) @click.option("--container", "-c", help="CloudVision container where devices are configured", type=str)
@click.option( def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str | None) -> None:
"--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.
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. Build ANTA inventory from Cloudvision
logger.info("Getting authentication token for user '%s' from CloudVision instance '%s'", username, host)
try: TODO - handle get_inventory and get_devices_in_container failure
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.info(f"Getting authentication token for user '{username}' from CloudVision instance '{host}'")
logger.error("Authentication to CloudVison failed: %s.", error) token = get_cv_token(cvp_ip=host, cvp_username=username, cvp_password=password)
ctx.exit(ExitCode.USAGE_ERROR)
clnt = CvpClient() clnt = CvpClient()
try: try:
clnt.connect(nodes=[host], username="", password="", api_token=token) clnt.connect(nodes=[host], username="", password="", api_token=token)
except CvpApiError as error: except CvpApiError as error:
logger.error("Error connecting to CloudVision: %s", error) logger.error(f"Error connecting to CloudVision: {error}")
ctx.exit(ExitCode.USAGE_ERROR) ctx.exit(ExitCode.USAGE_ERROR)
logger.info("Connected to CloudVision instance '%s'", host) logger.info(f"Connected to CloudVision instance '{host}'")
cvp_inventory = None cvp_inventory = None
if container is None: if container is None:
# Get a list of all devices # Get a list of all devices
logger.info("Getting full inventory from CloudVision instance '%s'", host) logger.info(f"Getting full inventory from CloudVision instance '{host}'")
cvp_inventory = clnt.api.get_inventory() cvp_inventory = clnt.api.get_inventory()
else: else:
# Get devices under a container # Get devices under a container
logger.info("Getting inventory for container %s from CloudVision instance '%s'", container, host) logger.info(f"Getting inventory for container {container} from CloudVision instance '{host}'")
cvp_inventory = clnt.api.get_devices_in_container(container) cvp_inventory = clnt.api.get_devices_in_container(container)
try:
create_inventory_from_cvp(cvp_inventory, output) create_inventory_from_cvp(cvp_inventory, output)
except OSError as e:
logger.error(str(e))
ctx.exit(ExitCode.USAGE_ERROR)
@click.command @click.command
@ -93,19 +74,15 @@ def from_cvp(ctx: click.Context, output: Path, host: str, username: str, passwor
required=True, required=True,
) )
def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_inventory: Path) -> None: 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"""
logger.info(f"Building inventory from ansible file '{ansible_inventory}'")
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: try:
create_inventory_from_ansible( create_inventory_from_ansible(
inventory=ansible_inventory, inventory=ansible_inventory,
output=output, output=output,
ansible_group=ansible_group, ansible_group=ansible_group,
) )
except (ValueError, OSError) as e: except ValueError as e:
logger.error(str(e)) logger.error(str(e))
ctx.exit(ExitCode.USAGE_ERROR) ctx.exit(ExitCode.USAGE_ERROR)
@ -113,11 +90,10 @@ def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_i
@click.command @click.command
@inventory_options @inventory_options
@click.option("--connected/--not-connected", help="Display inventory after connection has been created", default=False, required=False) @click.option("--connected/--not-connected", help="Display inventory after connection has been created", default=False, required=False)
def inventory(inventory: AntaInventory, tags: set[str] | None, *, connected: bool) -> None: def inventory(inventory: AntaInventory, tags: list[str] | None, connected: bool) -> None:
"""Show inventory loaded in ANTA.""" """Show inventory loaded in ANTA."""
# TODO: @gmuloc - tags come from context - we cannot have everything..
# ruff: noqa: ARG001 logger.debug(f"Requesting devices for tags: {tags}")
logger.debug("Requesting devices for tags: %s", tags)
console.print("Current inventory content is:", style="white on blue") console.print("Current inventory content is:", style="white on blue")
if connected: if connected:
@ -129,32 +105,11 @@ def inventory(inventory: AntaInventory, tags: set[str] | None, *, connected: boo
@click.command @click.command
@inventory_options @inventory_options
def tags(inventory: AntaInventory, **kwargs: Any) -> None: def tags(inventory: AntaInventory, tags: list[str] | None) -> None: # pylint: disable=unused-argument
"""Get list of configured tags in user inventory.""" """Get list of configured tags in user inventory."""
tags: set[str] = set() tags_found = []
for device in inventory.values(): for device in inventory.values():
tags.update(device.tags) tags_found += device.tags
tags_found = sorted(set(tags_found))
console.print("Tags found:") console.print("Tags found:")
console.print_json(json.dumps(sorted(tags), indent=2)) console.print_json(json.dumps(tags_found, 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 tests_found == 0:
console.print(f"""No test {f"'{test}' " if test else ""}found in '{module}'.""")
elif count:
if tests_found == 1:
console.print(f"There is 1 test available in '{module}'.")
else:
console.print(f"There are {tests_found} tests available in '{module}'.")
except ValueError as e:
logger.error(str(e))
ctx.exit(ExitCode.USAGE_ERROR)

View file

@ -1,41 +1,34 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Utils functions to use with anta.cli.get.commands module.""" """
Utils functions to use with anta.cli.get.commands module.
"""
from __future__ import annotations from __future__ import annotations
import functools import functools
import importlib
import inspect
import json import json
import logging import logging
import pkgutil
import re
import sys
import textwrap
from pathlib import Path from pathlib import Path
from sys import stdin from sys import stdin
from typing import Any, Callable from typing import Any
import click import click
import requests import requests
import urllib3 import urllib3
import yaml import yaml
from anta.cli.console import console
from anta.cli.utils import ExitCode from anta.cli.utils import ExitCode
from anta.inventory import AntaInventory from anta.inventory import AntaInventory
from anta.inventory.models import AntaInventoryHost, AntaInventoryInput from anta.inventory.models import AntaInventoryHost, AntaInventoryInput
from anta.models import AntaTest
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def inventory_output_options(f: Callable[..., Any]) -> Callable[..., Any]: def inventory_output_options(f: Any) -> Any:
"""Click common options required when an inventory is being generated.""" """Click common options required when an inventory is being generated"""
@click.option( @click.option(
"--output", "--output",
@ -57,13 +50,7 @@ def inventory_output_options(f: Callable[..., Any]) -> Callable[..., Any]:
) )
@click.pass_context @click.pass_context
@functools.wraps(f) @functools.wraps(f)
def wrapper( def wrapper(ctx: click.Context, *args: tuple[Any], output: Path, overwrite: bool, **kwargs: dict[str, Any]) -> Any:
ctx: click.Context,
*args: tuple[Any],
output: Path,
overwrite: bool,
**kwargs: dict[str, Any],
) -> Any:
# Boolean to check if the file is empty # Boolean to check if the file is empty
output_is_not_empty = output.exists() and output.stat().st_size != 0 output_is_not_empty = output.exists() and output.stat().st_size != 0
# Check overwrite when file is not empty # Check overwrite when file is not empty
@ -71,10 +58,7 @@ def inventory_output_options(f: Callable[..., Any]) -> Callable[..., Any]:
is_tty = stdin.isatty() is_tty = stdin.isatty()
if is_tty: if is_tty:
# File has content and it is in an interactive TTY --> Prompt user # File has content and it is in an interactive TTY --> Prompt user
click.confirm( click.confirm(f"Your destination file '{output}' is not empty, continue?", abort=True)
f"Your destination file '{output}' is not empty, continue?",
abort=True,
)
else: else:
# File has content and it is not interactive TTY nor overwrite set to True --> execution stop # File has content and it is not interactive TTY nor overwrite set to True --> execution stop
logger.critical("Conversion aborted since destination file is not empty (not running in interactive TTY)") logger.critical("Conversion aborted since destination file is not empty (not running in interactive TTY)")
@ -85,102 +69,66 @@ def inventory_output_options(f: Callable[..., Any]) -> Callable[..., Any]:
return wrapper return wrapper
def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str, *, verify_cert: bool) -> str: def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str) -> str:
"""Generate the authentication token from CloudVision using username and password. """Generate AUTH token from CVP using password"""
# TODO, need to handle requests eror
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 # use CVP REST API to generate a token
url = f"https://{cvp_ip}/cvpservice/login/authenticate.do" URL = f"https://{cvp_ip}/cvpservice/login/authenticate.do"
payload = json.dumps({"userId": cvp_username, "password": cvp_password}) payload = json.dumps({"userId": cvp_username, "password": cvp_password})
headers = {"Content-Type": "application/json", "Accept": "application/json"} headers = {"Content-Type": "application/json", "Accept": "application/json"}
response = requests.request("POST", url, headers=headers, data=payload, verify=verify_cert, timeout=10) response = requests.request("POST", URL, headers=headers, data=payload, verify=False, timeout=10)
return response.json()["sessionId"] return response.json()["sessionId"]
def write_inventory_to_file(hosts: list[AntaInventoryHost], output: Path) -> None: 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) i = AntaInventoryInput(hosts=hosts)
try: with open(output, "w", encoding="UTF-8") as out_fd:
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)}))
out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: yaml.safe_load(i.yaml())})) logger.info(f"ANTA inventory file has been created: '{output}'")
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: def create_inventory_from_cvp(inv: list[dict[str, Any]], output: Path) -> None:
"""Create an inventory file from Arista CloudVision inventory.""" """
logger.debug("Received %s device(s) from CloudVision", len(inv)) Create an inventory file from Arista CloudVision inventory
"""
logger.debug(f"Received {len(inv)} device(s) from CloudVision")
hosts = [] hosts = []
for dev in inv: for dev in inv:
logger.info(" * adding entry for %s", dev["hostname"]) logger.info(f" * adding entry for {dev['hostname']}")
hosts.append( hosts.append(AntaInventoryHost(name=dev["hostname"], host=dev["ipAddress"], tags=[dev["containerName"].lower()]))
AntaInventoryHost(
name=dev["hostname"],
host=dev["ipAddress"],
tags={dev["containerName"].lower()},
)
)
write_inventory_to_file(hosts, output) write_inventory_to_file(hosts, output)
def find_ansible_group(data: dict[str, Any], group: str) -> dict[str, Any] | None: def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group: str = "all") -> None:
"""Retrieve Ansible group from an input data dict.""" """
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.
"""
def find_ansible_group(data: dict[str, Any], group: str) -> dict[str, Any] | None:
for k, v in data.items(): for k, v in data.items():
if isinstance(v, dict): if isinstance(v, dict):
if k == group and ("children" in v or "hosts" in v): if k == group and ("children" in v.keys() or "hosts" in v.keys()):
return v return v
d = find_ansible_group(v, group) d = find_ansible_group(v, group)
if d is not None: if d is not None:
return d return d
return None return None
def deep_yaml_parsing(data: dict[str, Any], hosts: list[AntaInventoryHost] | None = None) -> list[AntaInventoryHost]:
def deep_yaml_parsing(data: dict[str, Any], hosts: list[AntaInventoryHost] | None = None) -> list[AntaInventoryHost]: """Deep parsing of YAML file to extract hosts and associated IPs"""
"""Deep parsing of YAML file to extract hosts and associated IPs."""
if hosts is None: if hosts is None:
hosts = [] hosts = []
for key, value in data.items(): for key, value in data.items():
if isinstance(value, dict) and "ansible_host" in value: if isinstance(value, dict) and "ansible_host" in value.keys():
logger.info(" * adding entry for %s", key) logger.info(f" * adding entry for {key}")
hosts.append(AntaInventoryHost(name=key, host=value["ansible_host"])) hosts.append(AntaInventoryHost(name=key, host=value["ansible_host"]))
elif isinstance(value, dict): elif isinstance(value, dict):
deep_yaml_parsing(value, hosts) deep_yaml_parsing(value, hosts)
@ -188,189 +136,18 @@ def deep_yaml_parsing(data: dict[str, Any], hosts: list[AntaInventoryHost] | Non
return hosts return hosts
return hosts return hosts
def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group: str = "all") -> None:
"""Create an ANTA inventory from an Ansible inventory YAML file.
Parameters
----------
inventory
Ansible Inventory file to read.
output
ANTA inventory file to generate.
ansible_group
Ansible group from where to extract data.
"""
try: try:
with inventory.open(encoding="utf-8") as inv: with open(inventory, encoding="utf-8") as inv:
ansible_inventory = yaml.safe_load(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: except OSError as exc:
msg = f"Could not parse {inventory}." raise ValueError(f"Could not parse {inventory}.") from exc
raise ValueError(msg) from exc
if not ansible_inventory: if not ansible_inventory:
msg = f"Ansible inventory {inventory} is empty" raise ValueError(f"Ansible inventory {inventory} is empty")
raise ValueError(msg)
ansible_inventory = find_ansible_group(ansible_inventory, ansible_group) ansible_inventory = find_ansible_group(ansible_inventory, ansible_group)
if ansible_inventory is None: if ansible_inventory is None:
msg = f"Group {ansible_group} not found in Ansible inventory" raise ValueError(f"Group {ansible_group} not found in Ansible inventory")
raise ValueError(msg)
ansible_hosts = deep_yaml_parsing(ansible_inventory) ansible_hosts = deep_yaml_parsing(ansible_inventory)
write_inventory_to_file(ansible_hosts, output) write_inventory_to_file(ansible_hosts, output)
def explore_package(module_name: str, test_name: str | None = None, *, short: bool = False, count: bool = False) -> int:
"""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 <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
def find_tests_examples(qname: str, test_name: str | None, *, short: bool = False, count: bool = False) -> int:
"""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
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")
try:
test_name_line = next((i for i, input_entry in enumerate(inputs) if test.name in input_entry))
except StopIteration as e:
msg = f"Could not find the name of the test '{test.name}' in the Example section in the docstring."
raise ValueError(msg) from e
# TODO: handle not found
console.print(f" {inputs[test_name_line].strip()}")
# Injecting the description
console.print(f" # {test.description}", soft_wrap=True)
if not short and len(inputs) > test_name_line + 2: # There are params
console.print(textwrap.indent(textwrap.dedent("\n".join(inputs[test_name_line + 1 : -1])), " " * 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

View file

@ -1,38 +1,40 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Click commands that run ANTA tests using anta.runner.""" """
Click commands that run ANTA tests using anta.runner
"""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING import asyncio
import click import click
from anta.catalog import AntaCatalog
from anta.cli.nrfu import commands from anta.cli.nrfu import commands
from anta.cli.utils import AliasedGroup, catalog_options, inventory_options from anta.cli.utils import AliasedGroup, catalog_options, inventory_options
from anta.inventory import AntaInventory
from anta.models import AntaTest
from anta.result_manager import ResultManager from anta.result_manager import ResultManager
from anta.result_manager.models import AntaTestStatus from anta.runner import main
if TYPE_CHECKING: from .utils import anta_progress_bar, print_settings
from anta.catalog import AntaCatalog
from anta.inventory import AntaInventory
class IgnoreRequiredWithHelp(AliasedGroup): class IgnoreRequiredWithHelp(AliasedGroup):
"""Custom Click Group. """
https://stackoverflow.com/questions/55818737/python-click-application-required-parameters-have-precedence-over-sub-command-he https://stackoverflow.com/questions/55818737/python-click-application-required-parameters-have-precedence-over-sub-command-he
Solution to allow help without required options on subcommand Solution to allow help without required options on subcommand
This is not planned to be fixed in click as per: https://github.com/pallets/click/issues/295#issuecomment-708129734. This is not planned to be fixed in click as per: https://github.com/pallets/click/issues/295#issuecomment-708129734
""" """
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: 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.""" """
Ignore MissingParameter exception when parsing arguments if `--help`
is present for a subcommand
"""
# Adding a flag for potential callbacks # Adding a flag for potential callbacks
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["args"] = args
if "--help" in args: if "--help" in args:
ctx.obj["_anta_help"] = True ctx.obj["_anta_help"] = True
@ -49,101 +51,31 @@ class IgnoreRequiredWithHelp(AliasedGroup):
return super().parse_args(ctx, args) return super().parse_args(ctx, args)
HIDE_STATUS: list[str] = list(AntaTestStatus)
HIDE_STATUS.remove("unset")
@click.group(invoke_without_command=True, cls=IgnoreRequiredWithHelp) @click.group(invoke_without_command=True, cls=IgnoreRequiredWithHelp)
@click.pass_context @click.pass_context
@inventory_options @inventory_options
@catalog_options @catalog_options
@click.option( @click.option("--ignore-status", help="Always exit with success", show_envvar=True, is_flag=True, default=False)
"--device", @click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False)
"-d", def nrfu(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, catalog: AntaCatalog, ignore_status: bool, ignore_error: bool) -> None:
help="Run tests on a specific device. Can be provided multiple times.", """Run ANTA tests on devices"""
type=str,
multiple=True,
required=False,
)
@click.option(
"--test",
"-t",
help="Run a specific test. Can be provided multiple times.",
type=str,
multiple=True,
required=False,
)
@click.option(
"--ignore-status",
help="Exit code will always be 0.",
show_envvar=True,
is_flag=True,
default=False,
)
@click.option(
"--ignore-error",
help="Exit code will be 0 if all tests succeeded or 1 if any test failed.",
show_envvar=True,
is_flag=True,
default=False,
)
@click.option(
"--hide",
default=None,
type=click.Choice(HIDE_STATUS, case_sensitive=False),
multiple=True,
help="Hide results by type: success / failure / error / skipped'.",
required=False,
)
@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,
tags: set[str] | None,
catalog: AntaCatalog,
device: tuple[str],
test: tuple[str],
hide: tuple[str],
*,
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 help is invoke somewhere, skip the command
if ctx.obj.get("_anta_help"): if ctx.obj.get("_anta_help"):
return return
# We use ctx.obj to pass stuff to the next Click functions # We use ctx.obj to pass stuff to the next Click functions
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["result_manager"] = ResultManager() ctx.obj["result_manager"] = ResultManager()
ctx.obj["ignore_status"] = ignore_status ctx.obj["ignore_status"] = ignore_status
ctx.obj["ignore_error"] = ignore_error ctx.obj["ignore_error"] = ignore_error
ctx.obj["hide"] = set(hide) if hide else None print_settings(inventory, catalog)
ctx.obj["catalog"] = catalog with anta_progress_bar() as AntaTest.progress:
ctx.obj["catalog_format"] = catalog_format asyncio.run(main(ctx.obj["result_manager"], inventory, catalog, tags=tags))
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 # Invoke `anta nrfu table` if no command is passed
if not ctx.invoked_subcommand: if ctx.invoked_subcommand is None:
ctx.invoke(commands.table) ctx.invoke(commands.table)
nrfu.add_command(commands.table) nrfu.add_command(commands.table)
nrfu.add_command(commands.csv)
nrfu.add_command(commands.json) nrfu.add_command(commands.json)
nrfu.add_command(commands.text) nrfu.add_command(commands.text)
nrfu.add_command(commands.tpl_report) nrfu.add_command(commands.tpl_report)
nrfu.add_command(commands.md_report)

View file

@ -1,36 +1,33 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Click commands that render ANTA tests results.""" """
Click commands that render ANTA tests results
"""
from __future__ import annotations from __future__ import annotations
import logging import logging
import pathlib import pathlib
from typing import Literal
import click import click
from anta.cli.utils import exit_with_code from anta.cli.utils import exit_with_code
from .utils import print_jinja, print_json, print_table, print_text, run_tests, save_markdown_report, save_to_csv from .utils import print_jinja, print_json, print_table, print_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@click.command() @click.command()
@click.pass_context @click.pass_context
@click.option("--device", "-d", help="Show a summary for this device", type=str, required=False)
@click.option("--test", "-t", help="Show a summary for this test", type=str, required=False)
@click.option( @click.option(
"--group-by", "--group-by", default=None, type=click.Choice(["device", "test"], case_sensitive=False), help="Group result by test or host. default none", required=False
default=None,
type=click.Choice(["device", "test"], case_sensitive=False),
help="Group result by test or device.",
required=False,
) )
def table(ctx: click.Context, group_by: Literal["device", "test"] | None) -> None: def table(ctx: click.Context, device: str | None, test: str | None, group_by: str) -> None:
"""ANTA command to check network state with table results.""" """ANTA command to check network states with table result"""
run_tests(ctx) print_table(results=ctx.obj["result_manager"], device=device, group_by=group_by, test=test)
print_table(ctx, group_by=group_by)
exit_with_code(ctx) exit_with_code(ctx)
@ -42,43 +39,21 @@ def table(ctx: click.Context, group_by: Literal["device", "test"] | None) -> Non
type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path), type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path),
show_envvar=True, show_envvar=True,
required=False, required=False,
help="Path to save report as a JSON file", help="Path to save report as a file",
) )
def json(ctx: click.Context, output: pathlib.Path | None) -> None: def json(ctx: click.Context, output: pathlib.Path | None) -> None:
"""ANTA command to check network state with JSON results.""" """ANTA command to check network state with JSON result"""
run_tests(ctx) print_json(results=ctx.obj["result_manager"], output=output)
print_json(ctx, output=output)
exit_with_code(ctx) exit_with_code(ctx)
@click.command() @click.command()
@click.pass_context @click.pass_context
def text(ctx: click.Context) -> None: @click.option("--search", "-s", help="Regular expression to search in both name and test", type=str, required=False)
"""ANTA command to check network state with text results.""" @click.option("--skip-error", help="Hide tests in errors due to connectivity issue", default=False, is_flag=True, show_default=True, required=False)
run_tests(ctx) def text(ctx: click.Context, search: str | None, skip_error: bool) -> None:
print_text(ctx) """ANTA command to check network states with text result"""
exit_with_code(ctx) print_text(results=ctx.obj["result_manager"], search=search, skip_error=skip_error)
@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=False,
help="Path to save report as a CSV file",
)
def csv(ctx: click.Context, csv_output: pathlib.Path) -> None:
"""ANTA command to check network states with CSV result."""
run_tests(ctx)
save_to_csv(ctx, csv_file=csv_output)
exit_with_code(ctx) exit_with_code(ctx)
@ -101,23 +76,6 @@ def csv(ctx: click.Context, csv_output: pathlib.Path) -> None:
help="Path to save report as a file", help="Path to save report as a file",
) )
def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path | None) -> None: def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path | None) -> None:
"""ANTA command to check network state with templated report.""" """ANTA command to check network state with templated report"""
run_tests(ctx)
print_jinja(results=ctx.obj["result_manager"], template=template, output=output) print_jinja(results=ctx.obj["result_manager"], template=template, output=output)
exit_with_code(ctx) 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_tests(ctx)
save_markdown_report(ctx, md_output=md_output)
exit_with_code(ctx)

View file

@ -1,172 +1,101 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Utils functions to use with anta.cli.nrfu.commands module.""" """
Utils functions to use with anta.cli.nrfu.commands module.
"""
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
from typing import TYPE_CHECKING, Literal import pathlib
import re
import rich import rich
from rich.panel import Panel from rich.panel import Panel
from rich.pretty import pprint
from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn
from anta.catalog import AntaCatalog
from anta.cli.console import console from anta.cli.console import console
from anta.cli.utils import ExitCode from anta.inventory import AntaInventory
from anta.models import AntaTest
from anta.reporter import ReportJinja, ReportTable from anta.reporter import ReportJinja, ReportTable
from anta.reporter.csv_reporter import ReportCsv from anta.result_manager import ResultManager
from anta.reporter.md_reporter import MDReportGenerator
from anta.runner import main
if TYPE_CHECKING:
import pathlib
import click
from anta.catalog import AntaCatalog
from anta.inventory import AntaInventory
from anta.result_manager import ResultManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def run_tests(ctx: click.Context) -> None:
"""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:
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,
dry_run=dry_run,
)
)
if dry_run:
ctx.exit()
def _get_result_manager(ctx: click.Context) -> 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"]
def print_settings( def print_settings(
inventory: AntaInventory, inventory: AntaInventory,
catalog: AntaCatalog, catalog: AntaCatalog,
) -> None: ) -> None:
"""Print ANTA settings before running tests.""" """Print ANTA settings before running tests"""
message = f"- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests" message = f"Running ANTA tests:\n- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests"
console.print(Panel.fit(message, style="cyan", title="[green]Settings")) console.print(Panel.fit(message, style="cyan", title="[green]Settings"))
console.print() console.print()
def print_table(ctx: click.Context, group_by: Literal["device", "test"] | None = None) -> None: def print_table(results: ResultManager, device: str | None = None, test: str | None = None, group_by: str | None = None) -> None:
"""Print result in a table.""" """Print result in a table"""
reporter = ReportTable() reporter = ReportTable()
console.print() console.print()
results = _get_result_manager(ctx) if device:
console.print(reporter.report_all(result_manager=results, host=device))
if group_by == "device": elif test:
console.print(reporter.report_summary_devices(results)) console.print(reporter.report_all(result_manager=results, testcase=test))
elif group_by == "device":
console.print(reporter.report_summary_hosts(result_manager=results, host=None))
elif group_by == "test": elif group_by == "test":
console.print(reporter.report_summary_tests(results)) console.print(reporter.report_summary_tests(result_manager=results, testcase=None))
else: else:
console.print(reporter.report_all(results)) console.print(reporter.report_all(result_manager=results))
def print_json(ctx: click.Context, output: pathlib.Path | None = None) -> None: def print_json(results: ResultManager, output: pathlib.Path | None = None) -> None:
"""Print results as JSON. If output is provided, save to file instead.""" """Print result in a json format"""
results = _get_result_manager(ctx)
if output is None:
console.print() console.print()
console.print(Panel("JSON results", style="cyan")) console.print(Panel("JSON results of all tests", style="cyan"))
rich.print_json(results.json) rich.print_json(results.get_json_results())
else: if output is not None:
try: with open(output, "w", encoding="utf-8") as fout:
with output.open(mode="w", encoding="utf-8") as file: fout.write(results.get_json_results())
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: def print_list(results: ResultManager, output: pathlib.Path | None = None) -> None:
"""Print results as simple text.""" """Print result in a list"""
console.print() console.print()
for test in _get_result_manager(ctx).results: console.print(Panel.fit("List results of all tests", style="cyan"))
if len(test.messages) <= 1: pprint(results.get_results())
message = test.messages[0] if len(test.messages) == 1 else "" if output is not None:
console.print(f"{test.name} :: {test.test} :: [{test.result}]{test.result.upper()}[/{test.result}]({message})", highlight=False) with open(output, "w", encoding="utf-8") as fout:
else: # len(test.messages) > 1 fout.write(str(results.get_results()))
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_text(results: ResultManager, search: str | None = None, skip_error: bool = False) -> None:
"""Print results as simple text"""
console.print()
regexp = re.compile(search or ".*")
for line in results.get_results():
if any(regexp.match(entry) for entry in [line.name, line.test]) and (not skip_error or line.result != "error"):
message = f" ({str(line.messages[0])})" if len(line.messages) > 0 else ""
console.print(f"{line.name} :: {line.test} :: [{line.result}]{line.result.upper()}[/{line.result}]{message}", highlight=False)
def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib.Path | None = None) -> None: def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib.Path | None = None) -> None:
"""Print result based on template.""" """Print result based on template."""
console.print() console.print()
reporter = ReportJinja(template_path=template) reporter = ReportJinja(template_path=template)
json_data = json.loads(results.json) json_data = json.loads(results.get_json_results())
report = reporter.render(json_data) report = reporter.render(json_data)
console.print(report) console.print(report)
if output is not None: if output is not None:
with output.open(mode="w", encoding="utf-8") as file: with open(output, "w", encoding="utf-8") as file:
file.write(report) 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) -> None:
"""Save the markdown report to a file.
Parameters
----------
ctx
Click context containing the result manager.
md_output
Path to save the markdown report.
"""
try:
MDReportGenerator.generate(results=_get_result_manager(ctx), md_filename=md_output)
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 # Adding our own ANTA spinner - overriding rich SPINNERS for our own
# so ignore warning for redefinition # so ignore warning for redefinition
rich.spinner.SPINNERS = { # type: ignore[attr-defined] rich.spinner.SPINNERS = { # type: ignore[attr-defined] # noqa: F811
"anta": { "anta": {
"interval": 150, "interval": 150,
"frames": [ "frames": [
@ -183,12 +112,14 @@ rich.spinner.SPINNERS = { # type: ignore[attr-defined]
"( 🐌 )", "( 🐌 )",
"( 🐌)", "( 🐌)",
], ],
}, }
} }
def anta_progress_bar() -> Progress: def anta_progress_bar() -> Progress:
"""Return a customized Progress for progress bar.""" """
Return a customized Progress for progress bar
"""
return Progress( return Progress(
SpinnerColumn("anta"), SpinnerColumn("anta"),
TextColumn(""), TextColumn(""),

View file

@ -1,22 +1,24 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Utils functions to use with anta.cli module.""" """
Utils functions to use with anta.cli module.
"""
from __future__ import annotations from __future__ import annotations
import enum import enum
import functools import functools
import logging import logging
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable from typing import TYPE_CHECKING, Any
import click import click
from pydantic import ValidationError
from yaml import YAMLError from yaml import YAMLError
from anta.catalog import AntaCatalog from anta.catalog import AntaCatalog
from anta.inventory import AntaInventory from anta.inventory import AntaInventory
from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError
if TYPE_CHECKING: if TYPE_CHECKING:
from click import Option from click import Option
@ -25,7 +27,10 @@ logger = logging.getLogger(__name__)
class ExitCode(enum.IntEnum): class ExitCode(enum.IntEnum):
"""Encodes the valid exit codes by anta inspired from pytest.""" """
Encodes the valid exit codes by anta
inspired from pytest
"""
# Tests passed. # Tests passed.
OK = 0 OK = 0
@ -39,17 +44,19 @@ class ExitCode(enum.IntEnum):
TESTS_FAILED = 4 TESTS_FAILED = 4
def parse_tags(ctx: click.Context, param: Option, value: str | None) -> set[str] | None: def parse_tags(ctx: click.Context, param: Option, value: str) -> list[str] | None:
# ruff: noqa: ARG001 # pylint: disable=unused-argument
"""Click option callback to parse an ANTA inventory tags.""" """
Click option callback to parse an ANTA inventory tags
"""
if value is not None: if value is not None:
return set(value.split(",")) if "," in value else {value} return value.split(",") if "," in value else [value]
return None return None
def exit_with_code(ctx: click.Context) -> None: def exit_with_code(ctx: click.Context) -> None:
"""Exit the Click application with an exit code. """
Exit the Click application with an exit code.
This function determines the global test status to be either `unset`, `skipped`, `success` or `error` This function determines the global test status to be either `unset`, `skipped`, `success` or `error`
from the `ResultManger` instance. from the `ResultManger` instance.
If flag `ignore_error` is set, the `error` status will be ignored in all the tests. If flag `ignore_error` is set, the `error` status will be ignored in all the tests.
@ -57,13 +64,10 @@ def exit_with_code(ctx: click.Context) -> None:
Exit the application with the following exit code: Exit the application with the following exit code:
* 0 if `ignore_status` is `True` or global test status is `unset`, `skipped` or `success` * 0 if `ignore_status` is `True` or global test status is `unset`, `skipped` or `success`
* 1 if status is `failure` * 1 if status is `failure`
* 2 if status is `error`. * 2 if status is `error`
Parameters
----------
ctx
Click Context.
Args:
ctx: Click Context
""" """
if ctx.obj.get("ignore_status"): if ctx.obj.get("ignore_status"):
ctx.exit(ExitCode.OK) ctx.exit(ExitCode.OK)
@ -79,19 +83,18 @@ def exit_with_code(ctx: click.Context) -> None:
ctx.exit(ExitCode.TESTS_ERROR) ctx.exit(ExitCode.TESTS_ERROR)
logger.error("Please gather logs and open an issue on Github.") logger.error("Please gather logs and open an issue on Github.")
msg = f"Unknown status returned by the ResultManager: {status}. Please gather logs and open an issue on Github." raise ValueError(f"Unknown status returned by the ResultManager: {status}. Please gather logs and open an issue on Github.")
raise ValueError(msg)
class AliasedGroup(click.Group): class AliasedGroup(click.Group):
"""Implements a subclass of Group that accepts a prefix for a command. """
Implements a subclass of Group that accepts a prefix for a command.
If there were a command called push, it would accept pus as an alias (so long as it was unique) If there were a command called push, it would accept pus as an alias (so long as it was unique)
From Click documentation. From Click documentation
""" """
def get_command(self, ctx: click.Context, cmd_name: str) -> Any: def get_command(self, ctx: click.Context, cmd_name: str) -> Any:
"""Todo: document code.""" """Todo: document code"""
rv = click.Group.get_command(self, ctx, cmd_name) rv = click.Group.get_command(self, ctx, cmd_name)
if rv is not None: if rv is not None:
return rv return rv
@ -104,16 +107,15 @@ class AliasedGroup(click.Group):
return None return None
def resolve_command(self, ctx: click.Context, args: Any) -> Any: def resolve_command(self, ctx: click.Context, args: Any) -> Any:
"""Todo: document code.""" """Todo: document code"""
# always return the full command name # always return the full command name
_, cmd, args = super().resolve_command(ctx, args) _, cmd, args = super().resolve_command(ctx, args)
if not cmd: return cmd.name, cmd, args # type: ignore
return None, None, None
return cmd.name, cmd, args
def core_options(f: Callable[..., Any]) -> Callable[..., Any]: # TODO: check code of click.pass_context that raise mypy errors for types and adapt this decorator
"""Click common options when requiring an inventory to interact with devices.""" def inventory_options(f: Any) -> Any:
"""Click common options when requiring an inventory to interact with devices"""
@click.option( @click.option(
"--username", "--username",
@ -157,105 +159,35 @@ def core_options(f: Callable[..., Any]) -> Callable[..., Any]:
) )
@click.option( @click.option(
"--timeout", "--timeout",
help="Global API timeout. This value will be used for all devices.", help="Global connection timeout",
default=30.0, default=30,
show_envvar=True, show_envvar=True,
envvar="ANTA_TIMEOUT", envvar="ANTA_TIMEOUT",
show_default=True, show_default=True,
) )
@click.option( @click.option(
"--insecure", "--insecure",
help="Disable SSH Host Key validation.", help="Disable SSH Host Key validation",
default=False, default=False,
show_envvar=True, show_envvar=True,
envvar="ANTA_INSECURE", envvar="ANTA_INSECURE",
is_flag=True, is_flag=True,
show_default=True, show_default=True,
) )
@click.option( @click.option("--disable-cache", help="Disable cache globally", show_envvar=True, envvar="ANTA_DISABLE_CACHE", show_default=True, is_flag=True, default=False)
"--disable-cache",
help="Disable cache globally.",
show_envvar=True,
envvar="ANTA_DISABLE_CACHE",
show_default=True,
is_flag=True,
default=False,
)
@click.option( @click.option(
"--inventory", "--inventory",
"-i", "-i",
help="Path to the inventory YAML file.", help="Path to the inventory YAML file",
envvar="ANTA_INVENTORY", envvar="ANTA_INVENTORY",
show_envvar=True, show_envvar=True,
required=True, required=True,
type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path), type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path),
) )
@click.pass_context
@functools.wraps(f)
def wrapper(
ctx: click.Context,
*args: tuple[Any],
inventory: Path,
username: str,
password: str | None,
enable_password: str | None,
enable: bool,
prompt: bool,
timeout: float,
insecure: bool,
disable_cache: bool,
**kwargs: dict[str, Any],
) -> Any:
# If help is invoke somewhere, do not parse inventory
if ctx.obj.get("_anta_help"):
return f(*args, inventory=None, **kwargs)
if prompt:
# User asked for a password prompt
if password is None:
password = click.prompt(
"Please enter a password to connect to EOS",
type=str,
hide_input=True,
confirmation_prompt=True,
)
if enable and enable_password is None and click.confirm("Is a password required to enter EOS privileged EXEC mode?"):
enable_password = click.prompt(
"Please enter a password to enter EOS privileged EXEC mode",
type=str,
hide_input=True,
confirmation_prompt=True,
)
if password is None:
msg = "EOS password needs to be provided by using either the '--password' option or the '--prompt' option."
raise click.BadParameter(msg)
if not enable and enable_password:
msg = "Providing a password to access EOS Privileged EXEC mode requires '--enable' option."
raise click.BadParameter(msg)
try:
i = AntaInventory.parse(
filename=inventory,
username=username,
password=password,
enable=enable,
enable_password=enable_password,
timeout=timeout,
insecure=insecure,
disable_cache=disable_cache,
)
except (TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError):
ctx.exit(ExitCode.USAGE_ERROR)
return f(*args, inventory=i, **kwargs)
return wrapper
def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]:
"""Click common options when requiring an inventory to interact with devices."""
@core_options
@click.option( @click.option(
"--tags", "--tags",
help="List of tags using comma as separator: tag1,tag2,tag3.", "-t",
help="List of tags using comma as separator: tag1,tag2,tag3",
show_envvar=True, show_envvar=True,
envvar="ANTA_TAGS", envvar="ANTA_TAGS",
type=str, type=str,
@ -267,59 +199,75 @@ def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]:
def wrapper( def wrapper(
ctx: click.Context, ctx: click.Context,
*args: tuple[Any], *args: tuple[Any],
tags: set[str] | None, inventory: Path,
tags: list[str] | None,
username: str,
password: str | None,
enable_password: str | None,
enable: bool,
prompt: bool,
timeout: int,
insecure: bool,
disable_cache: bool,
**kwargs: dict[str, Any], **kwargs: dict[str, Any],
) -> Any: ) -> Any:
# pylint: disable=too-many-arguments
# If help is invoke somewhere, do not parse inventory # If help is invoke somewhere, do not parse inventory
if ctx.obj.get("_anta_help"): if ctx.obj.get("_anta_help"):
return f(*args, tags=tags, **kwargs) return f(*args, inventory=None, tags=tags, **kwargs)
return f(*args, tags=tags, **kwargs) if prompt:
# User asked for a password prompt
if password is None:
password = click.prompt("Please enter a password to connect to EOS", type=str, hide_input=True, confirmation_prompt=True)
if enable:
if enable_password is None:
if click.confirm("Is a password required to enter EOS privileged EXEC mode?"):
enable_password = click.prompt(
"Please enter a password to enter EOS privileged EXEC mode", type=str, hide_input=True, confirmation_prompt=True
)
if password is None:
raise click.BadParameter("EOS password needs to be provided by using either the '--password' option or the '--prompt' option.")
if not enable and enable_password:
raise click.BadParameter("Providing a password to access EOS Privileged EXEC mode requires '--enable' option.")
try:
i = AntaInventory.parse(
filename=inventory,
username=username,
password=password,
enable=enable,
enable_password=enable_password,
timeout=timeout,
insecure=insecure,
disable_cache=disable_cache,
)
except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchema, InventoryRootKeyError):
ctx.exit(ExitCode.USAGE_ERROR)
return f(*args, inventory=i, tags=tags, **kwargs)
return wrapper return wrapper
def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]: def catalog_options(f: Any) -> Any:
"""Click common options when requiring a test catalog to execute ANTA tests.""" """Click common options when requiring a test catalog to execute ANTA tests"""
@click.option( @click.option(
"--catalog", "--catalog",
"-c", "-c",
envvar="ANTA_CATALOG", envvar="ANTA_CATALOG",
show_envvar=True, show_envvar=True,
help="Path to the test catalog file", help="Path to the test catalog YAML file",
type=click.Path( type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path),
file_okay=True,
dir_okay=False,
exists=True,
readable=True,
path_type=Path,
),
required=True, required=True,
) )
@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 @click.pass_context
@functools.wraps(f) @functools.wraps(f)
def wrapper( def wrapper(ctx: click.Context, *args: tuple[Any], catalog: Path, **kwargs: dict[str, Any]) -> Any:
ctx: click.Context,
*args: tuple[Any],
catalog: Path,
catalog_format: str,
**kwargs: dict[str, Any],
) -> Any:
# If help is invoke somewhere, do not parse catalog # If help is invoke somewhere, do not parse catalog
if ctx.obj.get("_anta_help"): if ctx.obj.get("_anta_help"):
return f(*args, catalog=None, **kwargs) return f(*args, catalog=None, **kwargs)
try: try:
file_format = catalog_format.lower() c = AntaCatalog.parse(catalog)
c = AntaCatalog.parse(catalog, file_format=file_format) # type: ignore[arg-type] except (ValidationError, TypeError, ValueError, YAMLError, OSError):
except (TypeError, ValueError, YAMLError, OSError):
ctx.exit(ExitCode.USAGE_ERROR) ctx.exit(ExitCode.USAGE_ERROR)
return f(*args, catalog=c, **kwargs) return f(*args, catalog=c, **kwargs)

View file

@ -1,28 +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.
"""Constants used in ANTA."""
from __future__ import annotations
ACRONYM_CATEGORIES: set[str] = {"aaa", "mlag", "snmp", "bgp", "ospf", "vxlan", "stp", "igmp", "ip", "lldp", "ntp", "bfd", "ptp", "lanz", "stun", "vlan"}
"""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."""
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 .*",
]
"""List of known EOS errors that should set a test status to 'failure' with the error message."""

View file

@ -1,44 +1,19 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module that provides predefined types for AntaTest.Input instances.""" """
Module that provides predefined types for AntaTest.Input instances
"""
import re import re
from typing import Annotated, Literal from typing import Literal
from pydantic import Field from pydantic import Field
from pydantic.functional_validators import AfterValidator, BeforeValidator from pydantic.functional_validators import AfterValidator, BeforeValidator
from typing_extensions import Annotated
# Regular Expression definition
# TODO: make this configurable - with an env var maybe?
REGEXP_EOS_BLACKLIST_CMDS = [r"^reload.*", r"^conf\w*\s*(terminal|session)*", r"^wr\w*\s*\w+"]
"""List of regular expressions to blacklist from eos commands."""
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|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_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`."""
# Regexp BGP AFI/SAFI
REGEXP_BGP_L2VPN_AFI = r"\b(l2[\s\-]?vpn[\s\-]?evpn)\b"
"""Match L2VPN EVPN AFI."""
REGEXP_BGP_IPV4_MPLS_LABELS = r"\b(ipv4[\s\-]?mpls[\s\-]?label(s)?)\b"
"""Match IPv4 MPLS Labels."""
REGEX_BGP_IPV4_MPLS_VPN = r"\b(ipv4[\s\-]?mpls[\s\-]?vpn)\b"
"""Match IPv4 MPLS VPN."""
REGEX_BGP_IPV4_UNICAST = r"\b(ipv4[\s\-]?uni[\s\-]?cast)\b"
"""Match IPv4 Unicast."""
def aaa_group_prefix(v: str) -> str: def aaa_group_prefix(v: str) -> str:
"""Prefix the AAA method with 'group' if it is known.""" """Prefix the AAA method with 'group' if it is known"""
built_in_methods = ["local", "none", "logging"] built_in_methods = ["local", "none", "logging"]
return f"group {v}" if v not in built_in_methods and not v.startswith("group ") else v return f"group {v}" if v not in built_in_methods and not v.startswith("group ") else v
@ -49,51 +24,49 @@ def interface_autocomplete(v: str) -> str:
Supported alias: Supported alias:
- `et`, `eth` will be changed to `Ethernet` - `et`, `eth` will be changed to `Ethernet`
- `po` will be changed to `Port-Channel` - `po` will be changed to `Port-Channel`
- `lo` will be changed to `Loopback` - `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) m = intf_id_re.search(v)
if m is None: if m is None:
msg = f"Could not parse interface ID in interface '{v}'" raise ValueError(f"Could not parse interface ID in interface '{v}'")
raise ValueError(msg)
intf_id = m[0] 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"}
return next((f"{full_name}{intf_id}" for alias, full_name in alias_map.items() if v.lower().startswith(alias)), v) for alias, full_name in alias_map.items():
if v.lower().startswith(alias):
return f"{full_name}{intf_id}"
return v
def interface_case_sensitivity(v: str) -> str: def interface_case_sensitivity(v: str) -> str:
"""Reformat interface name to match expected case sensitivity. """Reformat interface name to match expected case sensitivity.
Examples Examples:
--------
- ethernet -> Ethernet - ethernet -> Ethernet
- vlan -> Vlan - vlan -> Vlan
- loopback -> Loopback - loopback -> Loopback
""" """
if isinstance(v, str) and v != "" and not v[0].isupper(): if isinstance(v, str) and len(v) > 0 and not v[0].isupper():
return f"{v[0].upper()}{v[1:]}" return f"{v[0].upper()}{v[1:]}"
return v return v
def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str: def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str:
"""Abbreviations for different BGP multiprotocol capabilities. """
Abbreviations for different BGP multiprotocol capabilities.
Examples Examples:
--------
- IPv4 Unicast - IPv4 Unicast
- L2vpnEVPN - L2vpnEVPN
- ipv4 MPLS Labels - ipv4 MPLS Labels
- ipv4Mplsvpn - ipv4Mplsvpn
""" """
patterns = { patterns = {
REGEXP_BGP_L2VPN_AFI: "l2VpnEvpn", r"\b(l2[\s\-]?vpn[\s\-]?evpn)\b": "l2VpnEvpn",
REGEXP_BGP_IPV4_MPLS_LABELS: "ipv4MplsLabels", r"\bipv4[\s_-]?mpls[\s_-]?label(s)?\b": "ipv4MplsLabels",
REGEX_BGP_IPV4_MPLS_VPN: "ipv4MplsVpn", r"\bipv4[\s_-]?mpls[\s_-]?vpn\b": "ipv4MplsVpn",
REGEX_BGP_IPV4_UNICAST: "ipv4Unicast", r"\bipv4[\s_-]?uni[\s_-]?cast\b": "ipv4Unicast",
} }
for pattern, replacement in patterns.items(): for pattern, replacement in patterns.items():
@ -104,15 +77,8 @@ def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str:
return value return value
def validate_regex(value: str) -> str: # ANTA framework
"""Validate that the input value is a valid regex format.""" TestStatus = Literal["unset", "success", "failure", "error", "skipped"]
try:
re.compile(value)
except re.error as e:
msg = f"Invalid regex: {e}"
raise ValueError(msg) from e
return value
# AntaTest.Input types # AntaTest.Input types
AAAAuthMethod = Annotated[str, AfterValidator(aaa_group_prefix)] AAAAuthMethod = Annotated[str, AfterValidator(aaa_group_prefix)]
@ -121,33 +87,21 @@ MlagPriority = Annotated[int, Field(ge=1, le=32767)]
Vni = Annotated[int, Field(ge=1, le=16777215)] Vni = Annotated[int, Field(ge=1, le=16777215)]
Interface = Annotated[ Interface = Annotated[
str, str,
Field(pattern=REGEXP_TYPE_EOS_INTERFACE), Field(pattern=r"^(Dps|Ethernet|Fabric|Loopback|Management|Port-Channel|Tunnel|Vlan|Vxlan)[0-9]+(\/[0-9]+)*(\.[0-9]+)?$"),
BeforeValidator(interface_autocomplete),
BeforeValidator(interface_case_sensitivity),
]
EthernetInterface = Annotated[
str,
Field(pattern=r"^Ethernet[0-9]+(\/[0-9]+)*$"),
BeforeValidator(interface_autocomplete), BeforeValidator(interface_autocomplete),
BeforeValidator(interface_case_sensitivity), BeforeValidator(interface_case_sensitivity),
] ]
VxlanSrcIntf = Annotated[ VxlanSrcIntf = Annotated[
str, str,
Field(pattern=REGEXP_TYPE_VXLAN_SRC_INTERFACE), Field(pattern=r"^(Loopback)([0-9]|[1-9][0-9]{1,2}|[1-7][0-9]{3}|8[01][0-9]{2}|819[01])$"),
BeforeValidator(interface_autocomplete), BeforeValidator(interface_autocomplete),
BeforeValidator(interface_case_sensitivity), BeforeValidator(interface_case_sensitivity),
] ]
PortChannelInterface = Annotated[ Afi = Literal["ipv4", "ipv6", "vpn-ipv4", "vpn-ipv6", "evpn", "rt-membership"]
str, Safi = Literal["unicast", "multicast", "labeled-unicast"]
Field(pattern=REGEX_TYPE_PORTCHANNEL),
BeforeValidator(interface_autocomplete),
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"] EncryptionAlgorithm = Literal["RSA", "ECDSA"]
RsaKeySize = Literal[2048, 3072, 4096] RsaKeySize = Literal[2048, 3072, 4096]
EcdsaKeySize = Literal[256, 384, 512] EcdsaKeySize = Literal[256, 384, 521]
MultiProtocolCaps = Annotated[str, BeforeValidator(bgp_multiprotocol_capabilities_abbreviations)] MultiProtocolCaps = Annotated[str, BeforeValidator(bgp_multiprotocol_capabilities_abbreviations)]
BfdInterval = Annotated[int, Field(ge=50, le=60000)] BfdInterval = Annotated[int, Field(ge=50, le=60000)]
BfdMultiplier = Annotated[int, Field(ge=3, le=50)] BfdMultiplier = Annotated[int, Field(ge=3, le=50)]
@ -166,75 +120,3 @@ ErrDisableReasons = Literal[
"uplink-failure-detection", "uplink-failure-detection",
] ]
ErrDisableInterval = Annotated[int, Field(ge=30, le=86400)] 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=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"]
SnmpPdu = Literal["inGetPdus", "inGetNextPdus", "inSetPdus", "outGetResponsePdus", "outTrapPdus"]
SnmpErrorCounter = Literal[
"inVersionErrs", "inBadCommunityNames", "inBadCommunityUses", "inParseErrs", "outTooBigErrs", "outNoSuchNameErrs", "outBadValueErrs", "outGeneralErrs"
]
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",
]

View file

@ -2,50 +2,40 @@
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""decorators for tests.""" """decorators for tests."""
from __future__ import annotations from __future__ import annotations
from functools import wraps from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, cast
from anta.models import AntaTest, logger from anta.models import AntaTest, logger
if TYPE_CHECKING: if TYPE_CHECKING:
from anta.result_manager.models import TestResult from anta.result_manager.models import TestResult
# TODO: should probably use mypy Awaitable in some places rather than this everywhere - @gmuloc # TODO - should probably use mypy Awaitable in some places rather than this everywhere - @gmuloc
F = TypeVar("F", bound=Callable[..., Any]) F = TypeVar("F", bound=Callable[..., Any])
# TODO: Remove this decorator in ANTA v2.0.0 in favor of deprecated_test_class def deprecated_test(new_tests: Optional[list[str]] = None) -> Callable[[F], F]:
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. Return a decorator to log a message of WARNING severity when a test is deprecated.
Parameters Args:
---------- new_tests (Optional[list[str]]): A list of new test classes that should replace the deprecated test.
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.
Returns:
Callable[[F], F]: A decorator that can be used to wrap test functions.
""" """
def decorator(function: F) -> F: def decorator(function: F) -> F:
"""Actual decorator that logs the message. """
Actual decorator that logs the message.
Parameters Args:
---------- function (F): The test function to be decorated.
function
The test function to be decorated.
Returns
-------
F
The decorated function.
Returns:
F: The decorated function.
""" """
@wraps(function) @wraps(function)
@ -53,9 +43,9 @@ def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]: #
anta_test = args[0] anta_test = args[0]
if new_tests: if new_tests:
new_test_names = ", ".join(new_tests) new_test_names = ", ".join(new_tests)
logger.warning("%s test is deprecated. Consider using the following new tests: %s.", anta_test.name, new_test_names) logger.warning(f"{anta_test.name} test is deprecated. Consider using the following new tests: {new_test_names}.")
else: else:
logger.warning("%s test is deprecated.", anta_test.name) logger.warning(f"{anta_test.name} test is deprecated.")
return await function(*args, **kwargs) return await function(*args, **kwargs)
return cast(F, wrapper) return cast(F, wrapper)
@ -63,93 +53,35 @@ def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]: #
return decorator 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
def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]: def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]:
"""Return a decorator to skip a test based on the device's hardware model. """
Return a decorator to skip a test based on the device's hardware model.
This decorator factory generates a decorator that will check the hardware model of the device 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. the test is run on. If the model is in the list of platforms specified, the test will be skipped.
Parameters Args:
---------- platforms (list[str]): List of hardware models on which the test should be skipped.
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.
Returns:
Callable[[F], F]: A decorator that can be used to wrap test functions.
""" """
def decorator(function: F) -> F: def decorator(function: F) -> F:
"""Actual decorator that either runs the test or skips it based on the device's hardware model. """
Actual decorator that either runs the test or skips it based on the device's hardware model.
Parameters Args:
---------- function (F): The test function to be decorated.
function
The test function to be decorated.
Returns
-------
F
The decorated function.
Returns:
F: The decorated function.
""" """
@wraps(function) @wraps(function)
async def wrapper(*args: Any, **kwargs: Any) -> TestResult: async def wrapper(*args: Any, **kwargs: Any) -> TestResult:
"""Check the device's hardware model and conditionally run or skip the test. """
Check the device's hardware model and conditionally run or skip the test.
This wrapper inspects the hardware model of the device the test is run on. This wrapper inspects the hardware model of the device the test is run on.
If the model is in the list of specified platforms, the test is either skipped. If the model is in the list of specified platforms, the test is either skipped.

View file

@ -1,86 +1,65 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""ANTA Device Abstraction Module.""" """
ANTA Device Abstraction Module
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections import defaultdict from collections import defaultdict
from typing import TYPE_CHECKING, Any, Literal from pathlib import Path
from typing import Any, Iterator, Literal, Optional, Union
import asyncssh import asyncssh
import httpcore
from aiocache import Cache from aiocache import Cache
from aiocache.plugins import HitMissRatioPlugin from aiocache.plugins import HitMissRatioPlugin
from asyncssh import SSHClientConnection, SSHClientConnectionOptions from asyncssh import SSHClientConnection, SSHClientConnectionOptions
from httpx import ConnectError, HTTPError, TimeoutException from httpx import ConnectError, HTTPError
import asynceapi from anta import __DEBUG__, aioeapi
from anta import __DEBUG__
from anta.logger import anta_log_exception, exc_to_str
from anta.models import AntaCommand from anta.models import AntaCommand
from anta.tools.misc import exc_to_str
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
logger = logging.getLogger(__name__) 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()
class AntaDevice(ABC): class AntaDevice(ABC):
"""Abstract class representing a device in ANTA. """
Abstract class representing a device in ANTA.
An implementation of this class must override the abstract coroutines `_collect()` and An implementation of this class must override the abstract coroutines `_collect()` and
`refresh()`. `refresh()`.
Attributes Attributes:
---------- name: Device name
name : str is_online: True if the device IP is reachable and a port can be open
Device name. established: True if remote command execution succeeds
is_online : bool hw_model: Hardware model of the device
True if the device IP is reachable and a port can be open. tags: List of tags for this device
established : bool cache: In-memory cache from aiocache library for this device (None if cache is disabled)
True if remote command execution succeeds. cache_locks: Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled
hw_model : str
Hardware model of the device.
tags : set[str]
Tags for this device.
cache : Cache | None
In-memory cache from aiocache library for this device (None if cache is disabled).
cache_locks : dict
Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled.
""" """
def __init__(self, name: str, tags: set[str] | None = None, *, disable_cache: bool = False) -> None: def __init__(self, name: str, tags: Optional[list[str]] = None, disable_cache: bool = False) -> None:
"""Initialize an AntaDevice. """
Constructor of AntaDevice
Parameters
----------
name
Device name.
tags
Tags for this device.
disable_cache
Disable caching for all commands for this device.
Args:
name: Device name
tags: List of tags for this device
disable_cache: Disable caching for all commands for this device. Defaults to False.
""" """
self.name: str = name self.name: str = name
self.hw_model: str | None = None self.hw_model: Optional[str] = None
self.tags: set[str] = tags if tags is not None else set() self.tags: list[str] = tags if tags is not None else []
# A device always has its own name as tag # A device always has its own name as tag
self.tags.add(self.name) self.tags.append(self.name)
self.is_online: bool = False self.is_online: bool = False
self.established: bool = False self.established: bool = False
self.cache: Cache | None = None self.cache: Optional[Cache] = None
self.cache_locks: defaultdict[str, asyncio.Lock] | None = None self.cache_locks: Optional[defaultdict[str, asyncio.Lock]] = None
# Initialize cache if not disabled # Initialize cache if not disabled
if not disable_cache: if not disable_cache:
@ -89,24 +68,34 @@ class AntaDevice(ABC):
@property @property
@abstractmethod @abstractmethod
def _keys(self) -> tuple[Any, ...]: def _keys(self) -> tuple[Any, ...]:
"""Read-only property to implement hashing and equality for AntaDevice classes.""" """
Read-only property to implement hashing and equality for AntaDevice classes.
"""
def __eq__(self, other: object) -> bool: def __eq__(self, other: object) -> bool:
"""Implement equality for AntaDevice objects.""" """
Implement equality for AntaDevice objects.
"""
return self._keys == other._keys if isinstance(other, self.__class__) else False return self._keys == other._keys if isinstance(other, self.__class__) else False
def __hash__(self) -> int: def __hash__(self) -> int:
"""Implement hashing for AntaDevice objects.""" """
Implement hashing for AntaDevice objects.
"""
return hash(self._keys) return hash(self._keys)
def _init_cache(self) -> None: def _init_cache(self) -> None:
"""Initialize cache for the device, can be overridden by subclasses to manipulate how it works.""" """
Initialize cache for the device, can be overriden by subclasses to manipulate how it works
"""
self.cache = Cache(cache_class=Cache.MEMORY, ttl=60, namespace=self.name, plugins=[HitMissRatioPlugin()]) self.cache = Cache(cache_class=Cache.MEMORY, ttl=60, namespace=self.name, plugins=[HitMissRatioPlugin()])
self.cache_locks = defaultdict(asyncio.Lock) self.cache_locks = defaultdict(asyncio.Lock)
@property @property
def cache_statistics(self) -> dict[str, Any] | None: def cache_statistics(self) -> dict[str, Any] | None:
"""Return the device cache statistics for logging purposes.""" """
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 # 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 # https://github.com/pylint-dev/pylint/issues/7258
if self.cache is not None: if self.cache is not None:
@ -115,9 +104,9 @@ class AntaDevice(ABC):
return None return None
def __rich_repr__(self) -> Iterator[tuple[str, Any]]: def __rich_repr__(self) -> Iterator[tuple[str, Any]]:
"""Implement Rich Repr Protocol. """
Implements Rich Repr Protocol
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol. https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
""" """
yield "name", self.name yield "name", self.name
yield "tags", self.tags yield "tags", self.tags
@ -126,21 +115,10 @@ class AntaDevice(ABC):
yield "established", self.established yield "established", self.established
yield "disable_cache", self.cache is None 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 @abstractmethod
async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: async def _collect(self, command: AntaCommand) -> None:
"""Collect device command output. """
Collect device command output.
This abstract coroutine can be used to implement any command collection method This abstract coroutine can be used to implement any command collection method
for a device in ANTA. for a device in ANTA.
@ -151,16 +129,13 @@ class AntaDevice(ABC):
exception and implement proper logging, the `output` attribute of the exception and implement proper logging, the `output` attribute of the
`AntaCommand` object passed as argument would be `None` in this case. `AntaCommand` object passed as argument would be `None` in this case.
Parameters Args:
---------- command: the command to collect
command
The command to collect.
collection_id
An identifier used to build the eAPI request ID.
""" """
async def collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: async def collect(self, command: AntaCommand) -> None:
"""Collect the output for a specified command. """
Collects the output for a specified command.
When caching is activated on both the device and the command, When caching is activated on both the device and the command,
this method prioritizes retrieving the output from the cache. In cases where the output isn't cached yet, this method prioritizes retrieving the output from the cache. In cases where the output isn't cached yet,
@ -170,12 +145,8 @@ class AntaDevice(ABC):
When caching is NOT enabled, either at the device or command level, the method directly collects the output 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. via the private `_collect` method without interacting with the cache.
Parameters Args:
---------- command (AntaCommand): The command to process.
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 # 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 # https://github.com/pylint-dev/pylint/issues/7258
@ -184,125 +155,100 @@ class AntaDevice(ABC):
cached_output = await self.cache.get(command.uid) # pylint: disable=no-member cached_output = await self.cache.get(command.uid) # pylint: disable=no-member
if cached_output is not None: if cached_output is not None:
logger.debug("Cache hit for %s on %s", command.command, self.name) logger.debug(f"Cache hit for {command.command} on {self.name}")
command.output = cached_output command.output = cached_output
else: else:
await self._collect(command=command, collection_id=collection_id) await self._collect(command=command)
await self.cache.set(command.uid, command.output) # pylint: disable=no-member await self.cache.set(command.uid, command.output) # pylint: disable=no-member
else: else:
await self._collect(command=command, collection_id=collection_id) await self._collect(command=command)
async def collect_commands(self, commands: list[AntaCommand], *, collection_id: str | None = None) -> None: async def collect_commands(self, commands: list[AntaCommand]) -> None:
"""Collect multiple commands.
Parameters
----------
commands
The commands to collect.
collection_id
An identifier used to build the eAPI request ID.
""" """
await asyncio.gather(*(self.collect(command=command, collection_id=collection_id) for command in commands)) Collect multiple commands.
Args:
commands: the commands to collect
"""
await asyncio.gather(*(self.collect(command=command) for command in commands))
def supports(self, command: AntaCommand) -> bool:
"""Returns True if the command is supported on the device hardware platform, False otherwise."""
unsupported = any("not supported on this hardware platform" in e for e in command.errors)
logger.debug(command)
if unsupported:
logger.debug(f"{command.command} is not supported on {self.hw_model}")
return not unsupported
@abstractmethod @abstractmethod
async def refresh(self) -> None: async def refresh(self) -> None:
"""Update attributes of an AntaDevice instance. """
Update attributes of an AntaDevice instance.
This coroutine must update the following attributes of AntaDevice: This coroutine must update the following attributes of AntaDevice:
- `is_online`: When the device IP is reachable and a port can be open
- `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
- `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: async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None:
"""Copy files to and from the device, usually through SCP. """
Copy files to and from the device, usually through SCP.
It is not mandatory to implement this for a valid AntaDevice subclass. It is not mandatory to implement this for a valid AntaDevice subclass.
Parameters Args:
---------- sources: List of files to copy to or from the device.
sources destination: Local or remote destination when copying the files. Can be a folder.
List of files to copy to or from the device. direction: Defines if this coroutine copies files 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) raise NotImplementedError(f"copy() method has not been implemented in {self.__class__.__name__} definition")
msg = f"copy() method has not been implemented in {self.__class__.__name__} definition"
raise NotImplementedError(msg)
class AsyncEOSDevice(AntaDevice): class AsyncEOSDevice(AntaDevice):
"""Implementation of AntaDevice for EOS using aio-eapi. """
Implementation of AntaDevice for EOS using aio-eapi.
Attributes
----------
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.
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: List of tags for this device
""" """
def __init__( # noqa: PLR0913 def __init__( # pylint: disable=R0913
self, self,
host: str, host: str,
username: str, username: str,
password: str, password: str,
name: str | None = None, name: Optional[str] = None,
enable_password: str | None = None,
port: int | None = None,
ssh_port: int | None = 22,
tags: set[str] | None = None,
timeout: float | None = None,
proto: Literal["http", "https"] = "https",
*,
enable: bool = False, enable: bool = False,
enable_password: Optional[str] = None,
port: Optional[int] = None,
ssh_port: Optional[int] = 22,
tags: Optional[list[str]] = None,
timeout: Optional[float] = None,
insecure: bool = False, insecure: bool = False,
proto: Literal["http", "https"] = "https",
disable_cache: bool = False, disable_cache: bool = False,
) -> None: ) -> None:
"""Instantiate an AsyncEOSDevice. """
Constructor of AsyncEOSDevice
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
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.
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: Device needs privileged access
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: List of tags for this device
timeout: Timeout value in seconds for outgoing connections. Default to 10 secs.
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. Defaults to False.
""" """
if host is None: if host is None:
message = "'host' is required to create an AsyncEOSDevice" message = "'host' is required to create an AsyncEOSDevice"
@ -310,7 +256,7 @@ class AsyncEOSDevice(AntaDevice):
raise ValueError(message) raise ValueError(message)
if name is None: if name is None:
name = f"{host}{f':{port}' if port else ''}" name = f"{host}{f':{port}' if port else ''}"
super().__init__(name, tags, disable_cache=disable_cache) super().__init__(name, tags, disable_cache)
if username is None: if username is None:
message = f"'username' is required to instantiate device '{self.name}'" message = f"'username' is required to instantiate device '{self.name}'"
logger.error(message) logger.error(message)
@ -321,18 +267,16 @@ class AsyncEOSDevice(AntaDevice):
raise ValueError(message) raise ValueError(message)
self.enable = enable self.enable = enable
self._enable_password = enable_password self._enable_password = enable_password
self._session: asynceapi.Device = asynceapi.Device(host=host, port=port, username=username, password=password, proto=proto, timeout=timeout) self._session: aioeapi.Device = aioeapi.Device(host=host, port=port, username=username, password=password, proto=proto, timeout=timeout)
ssh_params: dict[str, Any] = {} ssh_params: dict[str, Any] = {}
if insecure: if insecure:
ssh_params["known_hosts"] = None ssh_params["known_hosts"] = None
self._ssh_opts: SSHClientConnectionOptions = SSHClientConnectionOptions( self._ssh_opts: SSHClientConnectionOptions = SSHClientConnectionOptions(host=host, port=ssh_port, username=username, password=password, **ssh_params)
host=host, port=ssh_port, username=username, password=password, client_keys=CLIENT_KEYS, **ssh_params
)
def __rich_repr__(self) -> Iterator[tuple[str, Any]]: def __rich_repr__(self) -> Iterator[tuple[str, Any]]:
"""Implement Rich Repr Protocol. """
Implements Rich Repr Protocol
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol. https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
""" """
yield from super().__rich_repr__() yield from super().__rich_repr__()
yield ("host", self._session.host) yield ("host", self._session.host)
@ -342,154 +286,107 @@ class AsyncEOSDevice(AntaDevice):
yield ("insecure", self._ssh_opts.known_hosts is None) yield ("insecure", self._ssh_opts.known_hosts is None)
if __DEBUG__: if __DEBUG__:
_ssh_opts = vars(self._ssh_opts).copy() _ssh_opts = vars(self._ssh_opts).copy()
removed_pw = "<removed>" PASSWORD_VALUE = "<removed>"
_ssh_opts["password"] = removed_pw _ssh_opts["password"] = PASSWORD_VALUE
_ssh_opts["kwargs"]["password"] = removed_pw _ssh_opts["kwargs"]["password"] = PASSWORD_VALUE
yield ("_session", vars(self._session)) yield ("_session", vars(self._session))
yield ("_ssh_opts", _ssh_opts) yield ("_ssh_opts", _ssh_opts)
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 @property
def _keys(self) -> tuple[Any, ...]: def _keys(self) -> tuple[Any, ...]:
"""Two AsyncEOSDevice objects are equal if the hostname and the port are the same. """
Two AsyncEOSDevice objects are equal if the hostname and the port are the same.
This covers the use case of port forwarding when the host is localhost and the devices have different ports. This covers the use case of port forwarding when the host is localhost and the devices have different ports.
""" """
return (self._session.host, self._session.port) return (self._session.host, self._session.port)
async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: async def _collect(self, command: AntaCommand) -> None:
"""Collect device command output from EOS using aio-eapi. """
Collect device command output from EOS using aio-eapi.
Supports outformat `json` and `text` as output structure. Supports outformat `json` and `text` as output structure.
Gain privileged access using the `enable_password` attribute Gain privileged access using the `enable_password` attribute
of the `AntaDevice` instance if populated. of the `AntaDevice` instance if populated.
Parameters Args:
---------- command: the command to collect
command
The command to collect.
collection_id
An identifier used to build the eAPI request ID.
""" """
commands: list[dict[str, str | int]] = [] commands = []
if self.enable and self._enable_password is not None: if self.enable and self._enable_password is not None:
commands.append( commands.append(
{ {
"cmd": "enable", "cmd": "enable",
"input": str(self._enable_password), "input": str(self._enable_password),
}, }
) )
elif self.enable: elif self.enable:
# No password # No password
commands.append({"cmd": "enable"}) commands.append({"cmd": "enable"})
commands += [{"cmd": command.command, "revision": command.revision}] if command.revision else [{"cmd": command.command}] if command.revision:
commands.append({"cmd": command.command, "revision": command.revision})
else:
commands.append({"cmd": command.command})
try: try:
response: list[dict[str, Any] | str] = await self._session.cli( response: list[dict[str, Any]] = await self._session.cli(
commands=commands, commands=commands,
ofmt=command.ofmt, ofmt=command.ofmt,
version=command.version, version=command.version,
req_id=f"ANTA-{collection_id}-{id(command)}" if collection_id else f"ANTA-{id(command)}",
) # type: ignore[assignment] # multiple commands returns a list
# 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(
"%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: except aioeapi.EapiCommandError 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:
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)
def _log_eapi_command_error(self, command: AntaCommand, e: asynceapi.EapiCommandError) -> None:
"""Appropriately log the eapi command error."""
command.errors = e.errors command.errors = e.errors
if command.requires_privileges: if self.supports(command):
logger.error("Command '%s' requires privileged mode on %s. Verify user permissions and if the `enable` option is required.", command.command, self.name) message = f"Command '{command.command}' failed on {self.name}"
if not command.supported: logger.error(message)
logger.debug("Command '%s' is not supported on '%s' (%s)", command.command, self.name, self.hw_model) except (HTTPError, ConnectError) as e:
elif command.returned_known_eos_error: command.errors = [str(e)]
logger.debug("Command '%s' returned a known error '%s': %s", command.command, self.name, command.errors) message = f"Cannot connect to device {self.name}"
logger.error(message)
else: else:
logger.error("Command '%s' failed on %s: %s", command.command, self.name, e.errors[0] if len(e.errors) == 1 else e.errors) # selecting only our command output
command.output = response[-1]
logger.debug(f"{self.name}: {command}")
async def refresh(self) -> None: async def refresh(self) -> None:
"""Update attributes of an AsyncEOSDevice instance. """
Update attributes of an AsyncEOSDevice instance.
This coroutine must update the following attributes of AsyncEOSDevice: 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 IP is reachable and a port can be open
- established: When a command execution succeeds - established: When a command execution succeeds
- hw_model: The hardware model of the device - hw_model: The hardware model of the device
""" """
logger.debug("Refreshing device %s", self.name) logger.debug(f"Refreshing device {self.name}")
self.is_online = await self._session.check_connection() self.is_online = await self._session.check_connection()
if self.is_online: if self.is_online:
show_version = AntaCommand(command="show version") COMMAND: str = "show version"
await self._collect(show_version) HW_MODEL_KEY: str = "modelName"
if not show_version.collected: try:
logger.warning("Cannot get hardware information from device %s", self.name) response = await self._session.cli(command=COMMAND)
except aioeapi.EapiCommandError as e:
logger.warning(f"Cannot get hardware information from device {self.name}: {e.errmsg}")
except (HTTPError, ConnectError) as e:
logger.warning(f"Cannot get hardware information from device {self.name}: {exc_to_str(e)}")
else: else:
self.hw_model = show_version.json_output.get("modelName", None) if HW_MODEL_KEY in response:
if self.hw_model is None: self.hw_model = response[HW_MODEL_KEY]
logger.critical("Cannot parse 'show version' returned by device %s", self.name)
# in some cases it is possible that 'modelName' comes back empty
# and it is nice to get a meaninfule error message
elif self.hw_model == "":
logger.critical("Got an empty 'modelName' in the 'show version' returned by device %s", self.name)
else: else:
logger.warning("Could not connect to device %s: cannot open eAPI port", self.name) logger.warning(f"Cannot get hardware information from device {self.name}: cannot parse '{COMMAND}'")
else:
logger.warning(f"Could not connect to device {self.name}: cannot open eAPI port")
self.established = bool(self.is_online and self.hw_model) self.established = bool(self.is_online and self.hw_model)
async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None: 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(). """
Copy files to and from the device using asyncssh.scp().
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.
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.
""" """
async with asyncssh.connect( async with asyncssh.connect(
host=self._ssh_opts.host, host=self._ssh_opts.host,
@ -499,24 +396,22 @@ class AsyncEOSDevice(AntaDevice):
local_addr=self._ssh_opts.local_addr, local_addr=self._ssh_opts.local_addr,
options=self._ssh_opts, options=self._ssh_opts,
) as conn: ) as conn:
src: list[tuple[SSHClientConnection, Path]] | list[Path] src: Union[list[tuple[SSHClientConnection, Path]], list[Path]]
dst: tuple[SSHClientConnection, Path] | Path dst: Union[tuple[SSHClientConnection, Path], Path]
if direction == "from": if direction == "from":
src = [(conn, file) for file in sources] src = [(conn, file) for file in sources]
dst = destination dst = destination
for file in sources: for file in sources:
message = f"Copying '{file}' from device {self.name} to '{destination}' locally" logger.info(f"Copying '{file}' from device {self.name} to '{destination}' locally")
logger.info(message)
elif direction == "to": elif direction == "to":
src = sources src = sources
dst = conn, destination dst = conn, destination
for file in src: for file in src:
message = f"Copying '{file}' to device {self.name} to '{destination}' remotely" logger.info(f"Copying '{file}' to device {self.name} to '{destination}' remotely")
logger.info(message)
else: else:
logger.critical("'direction' argument to copy() function is invalid: %s", direction) logger.critical(f"'direction' argument to copy() fonction is invalid: {direction}")
return return
await asyncssh.scp(src, dst) await asyncssh.scp(src, dst)

View file

@ -1,4 +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.
"""Package related to all ANTA tests input models."""

View file

@ -1,36 +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.
"""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})"

View file

@ -1,37 +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.
"""Module containing input models for BFD tests."""
from __future__ import annotations
from ipaddress import IPv4Address
from pydantic import BaseModel, ConfigDict
from anta.custom_types import BfdInterval, BfdMultiplier, BfdProtocol
class BFDPeer(BaseModel):
"""BFD (Bidirectional Forwarding Detection) model representing the peer details.
Only IPv4 peers are supported for now.
"""
model_config = ConfigDict(extra="forbid")
peer_address: IPv4Address
"""IPv4 address of a BFD peer."""
vrf: str = "default"
"""Optional VRF for the BFD peer. Defaults to `default`."""
tx_interval: BfdInterval | None = None
"""Tx interval of BFD peer in milliseconds. Required field in the `VerifyBFDPeersIntervals` test."""
rx_interval: BfdInterval | None = None
"""Rx interval of BFD peer in milliseconds. Required field in the `VerifyBFDPeersIntervals` test."""
multiplier: BfdMultiplier | None = None
"""Multiplier of BFD peer. Required field in the `VerifyBFDPeersIntervals` test."""
protocols: list[BfdProtocol] | None = None
"""List of protocols to be verified. Required field in the `VerifyBFDPeersRegProtocols` test."""
def __str__(self) -> str:
"""Return a human-readable string representation of the BFDPeer for reporting."""
return f"Peer: {self.peer_address} VRF: {self.vrf}"

View file

@ -1,83 +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.
"""Module containing input models for connectivity tests."""
from __future__ import annotations
from ipaddress import IPv4Address
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
"""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."""
size: int = 100
"""Specify datagram size. Defaults to 100."""
df_bit: bool = False
"""Enable do not fragment bit in IP header. Defaults to False."""
def __str__(self) -> str:
"""Return a human-readable string representation of the Host for reporting.
Examples
--------
Host 10.1.1.1 (src: 10.2.2.2, vrf: mgmt, size: 100B, repeat: 2)
"""
df_status = ", df-bit: enabled" if self.df_bit else ""
return f"Host {self.destination} (src: {self.source}, vrf: {self.vrf}, size: {self.size}B, repeat: {self.repeat}{df_status})"
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)

View file

@ -1,19 +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.
"""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
registration_state: Literal["Connecting", "Connected", "Registration error", "Registration complete", "Unexpected peer state"] = "Registration complete"

View file

@ -1,48 +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.
"""Module containing input models for interface tests."""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict
from anta.custom_types import Interface, PortChannelInterface
class InterfaceState(BaseModel):
"""Model for an interface state."""
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.
"""
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

View file

@ -1,4 +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.
"""Package related to routing tests input models."""

View file

@ -1,209 +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.
"""Module containing input models for routing BGP tests."""
from __future__ import annotations
from ipaddress import IPv4Address, IPv4Network, IPv6Address
from typing import TYPE_CHECKING, Any
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, BgpDropStats, BgpUpdateError, MultiProtocolCaps, 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."""
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.
Only IPv4 peers are supported for now.
"""
model_config = ConfigDict(extra="forbid")
peer_address: IPv4Address
"""IPv4 address of the BGP peer."""
vrf: str = "default"
"""Optional VRF for the BGP peer. Defaults to `default`."""
advertised_routes: list[IPv4Network] | None = None
"""List of advertised routes in CIDR format. Required field in the `VerifyBGPExchangedRoutes` test."""
received_routes: list[IPv4Network] | 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` test."""
strict: bool = False
"""If True, requires exact match of the provided BGP multiprotocol capabilities.
Optional field in the `VerifyBGPPeerMPCaps` test. Defaults to False."""
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, defaults to None. Required field in the `VerifyBgpRouteMaps` test."""
outbound_route_map: str | None = None
"""Outbound route map applied, defaults to None. Required field in the `VerifyBgpRouteMaps` test."""
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)
"""Optional maximum routes warning limit. If not provided, it defaults to `0` meaning no warning limit."""
def __str__(self) -> str:
"""Return a human-readable string representation of the BgpPeer for reporting."""
return f"Peer: {self.peer_address} 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}"

View file

@ -1,28 +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.
"""Module containing input models for generic routing tests."""
from __future__ import annotations
from ipaddress import 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
"""The IPV4 network to validate the route type."""
vrf: str = "default"
"""VRF context. Defaults to `default` VRF."""
route_type: IPv4RouteType
"""List of IPV4 Route type to validate the valid rout type."""
def __str__(self) -> str:
"""Return a human-readable string representation of the IPv4RouteType for reporting."""
return f"Prefix: {self.prefix} VRF: {self.vrf}"

View file

@ -1,61 +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.
"""Module containing input models for security tests."""
from __future__ import annotations
from ipaddress import IPv4Address
from typing import Any
from warnings import warn
from pydantic import BaseModel, ConfigDict
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 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 IPSecPeer 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)

View file

@ -1,31 +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.
"""Module containing input models for services tests."""
from __future__ import annotations
from ipaddress import IPv4Address, IPv6Address
from pydantic import BaseModel, ConfigDict, Field
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})"

View file

@ -1,35 +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.
"""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}"

View file

@ -1,31 +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.
"""Module containing input models for system tests."""
from __future__ import annotations
from ipaddress import IPv4Address
from pydantic import BaseModel, ConfigDict, Field
from anta.custom_types import Hostname
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: int = Field(ge=0, le=16)
"""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"{self.server_address} (Preferred: {self.preferred}, Stratum: {self.stratum})"

View file

@ -1,7 +1,9 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Inventory module for ANTA.""" """
Inventory Module for ANTA.
"""
from __future__ import annotations from __future__ import annotations
@ -9,29 +11,32 @@ import asyncio
import logging import logging
from ipaddress import ip_address, ip_network from ipaddress import ip_address, ip_network
from pathlib import Path from pathlib import Path
from typing import Any, ClassVar from typing import Any, Optional
from pydantic import ValidationError from pydantic import ValidationError
from yaml import YAMLError, safe_load from yaml import YAMLError, safe_load
from anta.device import AntaDevice, AsyncEOSDevice from anta.device import AntaDevice, AsyncEOSDevice
from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError
from anta.inventory.models import AntaInventoryInput from anta.inventory.models import AntaInventoryInput
from anta.logger import anta_log_exception from anta.logger import anta_log_exception
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AntaInventory(dict[str, AntaDevice]): class AntaInventory(dict): # type: ignore
"""Inventory abstraction for ANTA framework.""" # dict[str, AntaDevice] - not working in python 3.8 hence the ignore
"""
Inventory abstraction for ANTA framework.
"""
# Root key of inventory part of the inventory file # Root key of inventory part of the inventory file
INVENTORY_ROOT_KEY = "anta_inventory" INVENTORY_ROOT_KEY = "anta_inventory"
# Supported Output format # Supported Output format
INVENTORY_OUTPUT_FORMAT: ClassVar[list[str]] = ["native", "json"] INVENTORY_OUTPUT_FORMAT = ["native", "json"]
def __str__(self) -> str: def __str__(self) -> str:
"""Human readable string representing the inventory.""" """Human readable string representing the inventory"""
devs = {} devs = {}
for dev in self.values(): for dev in self.values():
if (dev_type := dev.__class__.__name__) not in devs: if (dev_type := dev.__class__.__name__) not in devs:
@ -41,119 +46,80 @@ class AntaInventory(dict[str, AntaDevice]):
return f"ANTA Inventory contains {' '.join([f'{n} devices ({t})' for t, n in devs.items()])}" return f"ANTA Inventory contains {' '.join([f'{n} devices ({t})' for t, n in devs.items()])}"
@staticmethod @staticmethod
def _update_disable_cache(kwargs: dict[str, Any], *, inventory_disable_cache: bool) -> dict[str, Any]: def _update_disable_cache(inventory_disable_cache: bool, kwargs: dict[str, Any]) -> 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. """
Return new dictionary, replacing kwargs with added disable_cache value from inventory_value
Parameters if disable_cache has not been set by CLI.
----------
inventory_disable_cache
The value of disable_cache in the inventory.
kwargs
The kwargs to instantiate the device.
Args:
inventory_disable_cache (bool): The value of disable_cache in the inventory
kwargs: The kwargs to instantiate the device
""" """
updated_kwargs = kwargs.copy() updated_kwargs = kwargs.copy()
updated_kwargs["disable_cache"] = inventory_disable_cache or kwargs.get("disable_cache") updated_kwargs["disable_cache"] = inventory_disable_cache or kwargs.get("disable_cache")
return updated_kwargs return updated_kwargs
@staticmethod @staticmethod
def _parse_hosts( def _parse_hosts(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None:
inventory_input: AntaInventoryInput, """
inventory: AntaInventory, Parses the host section of an AntaInventoryInput and add the devices to the inventory
**kwargs: dict[str, Any],
) -> None:
"""Parse the host section of an AntaInventoryInput and add the devices to the inventory.
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.
Args:
inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices
inventory (AntaInventory): AntaInventory to add the parsed devices to
""" """
if inventory_input.hosts is None: if inventory_input.hosts is None:
return return
for host in inventory_input.hosts: for host in inventory_input.hosts:
updated_kwargs = AntaInventory._update_disable_cache(kwargs, inventory_disable_cache=host.disable_cache) updated_kwargs = AntaInventory._update_disable_cache(host.disable_cache, kwargs)
device = AsyncEOSDevice( device = AsyncEOSDevice(name=host.name, host=str(host.host), port=host.port, tags=host.tags, **updated_kwargs)
name=host.name,
host=str(host.host),
port=host.port,
tags=host.tags,
**updated_kwargs,
)
inventory.add_device(device) inventory.add_device(device)
@staticmethod @staticmethod
def _parse_networks( def _parse_networks(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None:
inventory_input: AntaInventoryInput, """
inventory: AntaInventory, Parses the network section of an AntaInventoryInput and add the devices to the inventory.
**kwargs: dict[str, Any],
) -> None:
"""Parse the network section of an AntaInventoryInput and add the devices to the inventory.
Parameters Args:
---------- inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices
inventory_input inventory (AntaInventory): AntaInventory to add the parsed devices to
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.
Raises:
InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema.
""" """
if inventory_input.networks is None: if inventory_input.networks is None:
return return
try:
for network in inventory_input.networks: for network in inventory_input.networks:
updated_kwargs = AntaInventory._update_disable_cache(kwargs, inventory_disable_cache=network.disable_cache) try:
updated_kwargs = AntaInventory._update_disable_cache(network.disable_cache, kwargs)
for host_ip in ip_network(str(network.network)): for host_ip in ip_network(str(network.network)):
device = AsyncEOSDevice(host=str(host_ip), tags=network.tags, **updated_kwargs) device = AsyncEOSDevice(host=str(host_ip), tags=network.tags, **updated_kwargs)
inventory.add_device(device) inventory.add_device(device)
except ValueError as e: except ValueError as e:
message = "Could not parse the network section in the inventory" message = "Could not parse network {network.network} in the inventory"
anta_log_exception(e, message, logger) anta_log_exception(e, message, logger)
raise InventoryIncorrectSchemaError(message) from e raise InventoryIncorrectSchema(message) from e
@staticmethod @staticmethod
def _parse_ranges( def _parse_ranges(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None:
inventory_input: AntaInventoryInput, """
inventory: AntaInventory, Parses the range section of an AntaInventoryInput and add the devices to the inventory.
**kwargs: dict[str, Any],
) -> None:
"""Parse the range section of an AntaInventoryInput and add the devices to the inventory.
Parameters Args:
---------- inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices
inventory_input inventory (AntaInventory): AntaInventory to add the parsed devices to
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.
Raises:
InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema.
""" """
if inventory_input.ranges is None: if inventory_input.ranges is None:
return return
try:
for range_def in inventory_input.ranges: for range_def in inventory_input.ranges:
updated_kwargs = AntaInventory._update_disable_cache(kwargs, inventory_disable_cache=range_def.disable_cache) try:
updated_kwargs = AntaInventory._update_disable_cache(range_def.disable_cache, kwargs)
range_increment = ip_address(str(range_def.start)) range_increment = ip_address(str(range_def.start))
range_stop = ip_address(str(range_def.end)) range_stop = ip_address(str(range_def.end))
while range_increment <= range_stop: # type: ignore[operator] while range_increment <= range_stop: # type: ignore[operator]
@ -163,57 +129,45 @@ class AntaInventory(dict[str, AntaDevice]):
inventory.add_device(device) inventory.add_device(device)
range_increment += 1 range_increment += 1
except ValueError as e: except ValueError as e:
message = "Could not parse the range section in the inventory" message = f"Could not parse the following range in the inventory: {range_def.start} - {range_def.end}"
anta_log_exception(e, message, logger) anta_log_exception(e, message, logger)
raise InventoryIncorrectSchemaError(message) from e raise InventoryIncorrectSchema(message) from e
except TypeError as e: except TypeError as e:
message = "A range in the inventory has different address families (IPv4 vs IPv6)" message = f"A range in the inventory has different address families for start and end: {range_def.start} - {range_def.end}"
anta_log_exception(e, message, logger) anta_log_exception(e, message, logger)
raise InventoryIncorrectSchemaError(message) from e raise InventoryIncorrectSchema(message) from e
@staticmethod @staticmethod
def parse( def parse(
filename: str | Path, filename: str | Path,
username: str, username: str,
password: str, password: str,
enable_password: str | None = None,
timeout: float | None = None,
*,
enable: bool = False, enable: bool = False,
enable_password: Optional[str] = None,
timeout: Optional[float] = None,
insecure: bool = False, insecure: bool = False,
disable_cache: bool = False, disable_cache: bool = False,
) -> AntaInventory: ) -> AntaInventory:
"""Create an AntaInventory instance from an inventory file. # pylint: disable=too-many-arguments
"""
Create an AntaInventory instance from an inventory file.
The inventory devices are AsyncEOSDevice instances. The inventory devices are AsyncEOSDevice instances.
Parameters Args:
---------- filename (str): Path to device inventory YAML file
filename username (str): Username to use to connect to devices
Path to device inventory YAML file. password (str): Password to use to connect to devices
username enable (bool): Whether or not the commands need to be run in enable mode towards the devices
Username to use to connect to devices. enable_password (str, optional): Enable password to use if required
password timeout (float, optional): timeout in seconds for every API call.
Password to use to connect to devices. insecure (bool): Disable SSH Host Key validation
enable_password disable_cache (bool): Disable cache globally
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.
Raises
------
InventoryRootKeyError
Root key of inventory is missing.
InventoryIncorrectSchemaError
Inventory file is not following AntaInventory Schema.
Raises:
InventoryRootKeyError: Root key of inventory is missing.
InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema.
""" """
inventory = AntaInventory() inventory = AntaInventory()
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"username": username, "username": username,
@ -234,8 +188,7 @@ class AntaInventory(dict[str, AntaDevice]):
raise ValueError(message) raise ValueError(message)
try: try:
filename = Path(filename) with open(file=filename, mode="r", encoding="UTF-8") as file:
with filename.open(encoding="UTF-8") as file:
data = safe_load(file) data = safe_load(file)
except (TypeError, YAMLError, OSError) as e: except (TypeError, YAMLError, OSError) as e:
message = f"Unable to parse ANTA Device Inventory file '{filename}'" message = f"Unable to parse ANTA Device Inventory file '{filename}'"
@ -260,11 +213,6 @@ class AntaInventory(dict[str, AntaDevice]):
return inventory return inventory
@property
def devices(self) -> list[AntaDevice]:
"""List of AntaDevice in this inventory."""
return list(self.values())
########################################################################### ###########################################################################
# Public methods # Public methods
########################################################################### ###########################################################################
@ -273,35 +221,30 @@ class AntaInventory(dict[str, AntaDevice]):
# GET methods # GET methods
########################################################################### ###########################################################################
def get_inventory(self, *, established_only: bool = False, tags: set[str] | None = None, devices: set[str] | None = None) -> AntaInventory: def get_inventory(self, established_only: bool = False, tags: Optional[list[str]] = None) -> AntaInventory:
"""Return a filtered inventory. """
Returns a filtered inventory.
Parameters Args:
---------- established_only: Whether or not to include only established devices. Default False.
established_only tags: List of tags to filter devices.
Whether or not to include only established devices.
tags
Tags to filter devices.
devices
Names to filter devices.
Returns Returns:
------- AntaInventory: An inventory with filtered AntaDevice objects.
AntaInventory
An inventory with filtered AntaDevice objects.
""" """
def _filter_devices(device: AntaDevice) -> bool: def _filter_devices(device: AntaDevice) -> bool:
"""Select the devices based on the inputs `tags`, `devices` and `established_only`.""" """
Helper function to select the devices based on the input tags
and the requirement for an established connection.
"""
if tags is not None and all(tag not in tags for tag in device.tags): if tags is not None and all(tag not in tags for tag in device.tags):
return False return False
if devices is None or device.name in devices:
return bool(not established_only or device.established) return bool(not established_only or device.established)
return False
filtered_devices: list[AntaDevice] = list(filter(_filter_devices, self.values())) devices: list[AntaDevice] = list(filter(_filter_devices, self.values()))
result = AntaInventory() result = AntaInventory()
for device in filtered_devices: for device in devices:
result.add_device(device) result.add_device(device)
return result return result
@ -310,20 +253,15 @@ class AntaInventory(dict[str, AntaDevice]):
########################################################################### ###########################################################################
def __setitem__(self, key: str, value: AntaDevice) -> None: def __setitem__(self, key: str, value: AntaDevice) -> None:
"""Set a device in the inventory."""
if key != value.name: if key != value.name:
msg = f"The key must be the device name for device '{value.name}'. Use AntaInventory.add_device()." raise RuntimeError(f"The key must be the device name for device '{value.name}'. Use AntaInventory.add_device().")
raise RuntimeError(msg)
return super().__setitem__(key, value) return super().__setitem__(key, value)
def add_device(self, device: AntaDevice) -> None: def add_device(self, device: AntaDevice) -> None:
"""Add a device to final inventory. """Add a device to final inventory.
Parameters Args:
---------- device: Device object to be added
device
Device object to be added.
""" """
self[device.name] = device self[device.name] = device

View file

@ -8,5 +8,5 @@ class InventoryRootKeyError(Exception):
"""Error raised when inventory root key is not found.""" """Error raised when inventory root key is not found."""
class InventoryIncorrectSchemaError(Exception): class InventoryIncorrectSchema(Exception):
"""Error when user data does not follow ANTA schema.""" """Error when user data does not follow ANTA schema."""

View file

@ -6,107 +6,87 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import math from typing import List, Optional, Union
import yaml # Need to keep List for pydantic in python 3.8
from pydantic import BaseModel, ConfigDict, IPvAnyAddress, IPvAnyNetwork from pydantic import BaseModel, ConfigDict, IPvAnyAddress, IPvAnyNetwork, conint, constr
from anta.custom_types import Hostname, Port
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Pydantic models for input validation
RFC_1123_REGEX = 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])$"
class AntaInventoryHost(BaseModel): class AntaInventoryHost(BaseModel):
"""Host entry of AntaInventoryInput. """
Host definition for user's inventory.
Attributes
----------
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.
Attributes:
host (IPvAnyAddress): IPv4 or IPv6 address of the device
port (int): (Optional) eAPI port to use Default is 443.
name (str): (Optional) Name to display during tests report. Default is hostname:port
tags (list[str]): List of attached tags read from inventory file.
disable_cache (bool): Disable cache per host. Defaults to False.
""" """
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
name: str | None = None name: Optional[str] = None
host: Hostname | IPvAnyAddress host: Union[constr(pattern=RFC_1123_REGEX), IPvAnyAddress] # type: ignore
port: Port | None = None port: Optional[conint(gt=1, lt=65535)] = None # type: ignore
tags: set[str] | None = None tags: Optional[List[str]] = None
disable_cache: bool = False disable_cache: bool = False
class AntaInventoryNetwork(BaseModel): class AntaInventoryNetwork(BaseModel):
"""Network entry of AntaInventoryInput. """
Network definition for user's inventory.
Attributes
----------
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.
Attributes:
network (IPvAnyNetwork): Subnet to use for testing.
tags (list[str]): List of attached tags read from inventory file.
disable_cache (bool): Disable cache per network. Defaults to False.
""" """
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
network: IPvAnyNetwork network: IPvAnyNetwork
tags: set[str] | None = None tags: Optional[List[str]] = None
disable_cache: bool = False disable_cache: bool = False
class AntaInventoryRange(BaseModel): class AntaInventoryRange(BaseModel):
"""IP Range entry of AntaInventoryInput. """
IP Range definition for user's inventory.
Attributes
----------
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.
Attributes:
start (IPvAnyAddress): IPv4 or IPv6 address for the begining of the range.
stop (IPvAnyAddress): IPv4 or IPv6 address for the end of the range.
tags (list[str]): List of attached tags read from inventory file.
disable_cache (bool): Disable cache per range of hosts. Defaults to False.
""" """
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
start: IPvAnyAddress start: IPvAnyAddress
end: IPvAnyAddress end: IPvAnyAddress
tags: set[str] | None = None tags: Optional[List[str]] = None
disable_cache: bool = False disable_cache: bool = False
class AntaInventoryInput(BaseModel): class AntaInventoryInput(BaseModel):
"""Device inventory input model.""" """
User's inventory model.
Attributes:
networks (list[AntaInventoryNetwork],Optional): List of AntaInventoryNetwork objects for networks.
hosts (list[AntaInventoryHost],Optional): List of AntaInventoryHost objects for hosts.
range (list[AntaInventoryRange],Optional): List of AntaInventoryRange objects for ranges.
"""
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
networks: list[AntaInventoryNetwork] | None = None networks: Optional[List[AntaInventoryNetwork]] = None
hosts: list[AntaInventoryHost] | None = None hosts: Optional[List[AntaInventoryHost]] = None
ranges: list[AntaInventoryRange] | None = None ranges: Optional[List[AntaInventoryRange]] = 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)), indent=2, width=math.inf)

View file

@ -1,28 +1,26 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Configure logging for ANTA.""" """
Configure logging for ANTA
"""
from __future__ import annotations from __future__ import annotations
import logging import logging
import traceback
from datetime import timedelta
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Literal from pathlib import Path
from typing import Literal, Optional
from rich.logging import RichHandler from rich.logging import RichHandler
from anta import __DEBUG__ from anta import __DEBUG__
from anta.tools.misc import exc_to_str
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Log(str, Enum): class Log(str, Enum):
"""Represent log levels from logging module as immutable strings.""" """Represent log levels from logging module as immutable strings"""
CRITICAL = logging.getLevelName(logging.CRITICAL) CRITICAL = logging.getLevelName(logging.CRITICAL)
ERROR = logging.getLevelName(logging.ERROR) ERROR = logging.getLevelName(logging.ERROR)
@ -35,8 +33,8 @@ LogLevel = Literal[Log.CRITICAL, Log.ERROR, Log.WARNING, Log.INFO, Log.DEBUG]
def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None: def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None:
"""Configure logging for ANTA. """
Configure logging for ANTA.
By default, the logging level is INFO for all loggers except for httpx and asyncssh which are too verbose: By default, the logging level is INFO for all loggers except for httpx and asyncssh which are too verbose:
their logging level is WARNING. their logging level is WARNING.
@ -49,17 +47,13 @@ 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 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. be logged to stdout while all levels will be logged in the file.
Parameters Args:
---------- level: ANTA logging level
level file: Send logs to a file
ANTA logging level
file
Send logs to a file
""" """
# Init root logger # Init root logger
root = logging.getLogger() root = logging.getLogger()
# In ANTA debug mode, level is overridden to DEBUG # In ANTA debug mode, level is overriden to DEBUG
loglevel = logging.DEBUG if __DEBUG__ else getattr(logging, level.upper()) loglevel = logging.DEBUG if __DEBUG__ else getattr(logging, level.upper())
root.setLevel(loglevel) root.setLevel(loglevel)
# Silence the logging of chatty Python modules when level is INFO # Silence the logging of chatty Python modules when level is INFO
@ -70,60 +64,44 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None:
logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpx").setLevel(logging.WARNING)
# Add RichHandler for stdout # Add RichHandler for stdout
rich_handler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False) richHandler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False)
# Show Python module in stdout at DEBUG level # In ANTA debug mode, show Python module in stdout
fmt_string = "[grey58]\\[%(name)s][/grey58] %(message)s" if loglevel == logging.DEBUG else "%(message)s" if __DEBUG__:
fmt_string = r"[grey58]\[%(name)s][/grey58] %(message)s"
else:
fmt_string = "%(message)s"
formatter = logging.Formatter(fmt=fmt_string, datefmt="[%X]") formatter = logging.Formatter(fmt=fmt_string, datefmt="[%X]")
rich_handler.setFormatter(formatter) richHandler.setFormatter(formatter)
root.addHandler(rich_handler) root.addHandler(richHandler)
# Add FileHandler if file is provided # Add FileHandler if file is provided
if file: if file:
file_handler = logging.FileHandler(file) fileHandler = logging.FileHandler(file)
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
file_handler.setFormatter(formatter) fileHandler.setFormatter(formatter)
root.addHandler(file_handler) root.addHandler(fileHandler)
# If level is DEBUG and file is provided, do not send DEBUG level to stdout # If level is DEBUG and file is provided, do not send DEBUG level to stdout
if loglevel == logging.DEBUG: if loglevel == logging.DEBUG:
rich_handler.setLevel(logging.INFO) richHandler.setLevel(logging.INFO)
if __DEBUG__: if __DEBUG__:
logger.debug("ANTA Debug Mode enabled") logger.debug("ANTA Debug Mode enabled")
def format_td(seconds: float, digits: int = 3) -> str: def anta_log_exception(exception: BaseException, message: Optional[str] = None, calling_logger: Optional[logging.Logger] = None) -> None:
"""Return a formatted string from a float number representing seconds and a number of digits.""" """
isec, fsec = divmod(round(seconds * 10**digits), 10**digits) Helper function to help log exceptions:
return f"{timedelta(seconds=isec)}.{fsec:0{digits}.0f}" * if anta.__DEBUG__ is True then the logger.exception method is called to get the traceback
* otherwise logger.error is called
Args:
def exc_to_str(exception: BaseException) -> str: exception (BAseException): The Exception being logged
"""Return a human readable string from an BaseException object.""" message (str): An optional message
return f"{type(exception).__name__}{f': {exception}' if str(exception) else ''}" calling_logger (logging.Logger): A logger to which the exception should be logged
if not present, the logger in this file is used.
def anta_log_exception(exception: BaseException, message: str | None = None, calling_logger: logging.Logger | None = None) -> None:
"""Log exception.
If `anta.__DEBUG__` is True then the `logger.exception` method is called to get the traceback, otherwise `logger.error` is called.
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: if calling_logger is None:
calling_logger = logger calling_logger = logger
calling_logger.critical(f"{message}\n{exc_to_str(exception)}" if message else exc_to_str(exception)) calling_logger.critical(f"{message}\n{exc_to_str(exception)}" if message else exc_to_str(exception))
if __DEBUG__: if __DEBUG__:
msg = f"[ANTA Debug Mode]{f' {message}' if message else ''}" calling_logger.exception(f"[ANTA Debug Mode]{f' {message}' if message else ''}", exc_info=exception)
calling_logger.exception(msg, exc_info=exception)
def tb_to_str(exception: BaseException) -> str:
"""Return a traceback string from an BaseException object."""
return "Traceback (most recent call last):\n" + "".join(traceback.format_tb(exception.__traceback__))

View file

@ -1,135 +1,102 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Models to define a TestStructure.""" """
Models to define a TestStructure
"""
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import logging import logging
import re import re
import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from copy import deepcopy
from datetime import timedelta
from functools import wraps 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 # Need to keep Dict and List for pydantic in python 3.8
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Coroutine, Dict, List, Literal, Optional, TypeVar, Union
from pydantic import BaseModel, ConfigDict, ValidationError, conint
from rich.progress import Progress, TaskID
from anta import GITHUB_SUGGESTION from anta import GITHUB_SUGGESTION
from anta.constants import KNOWN_EOS_ERRORS from anta.logger import anta_log_exception
from anta.custom_types import REGEXP_EOS_BLACKLIST_CMDS, Revision from anta.result_manager.models import TestResult
from anta.logger import anta_log_exception, exc_to_str from anta.tools.misc import exc_to_str
from anta.result_manager.models import AntaTestStatus, TestResult
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Coroutine
from rich.progress import Progress, TaskID
from anta.device import AntaDevice from anta.device import AntaDevice
F = TypeVar("F", bound=Callable[..., Any]) F = TypeVar("F", bound=Callable[..., Any])
# Proper way to type input class - revisit this later if we get any issue @gmuloc # Proper way to type input class - revisit this later if we get any issue @gmuloc
# This would imply overhead to define classes # This would imply overhead to define classes
# https://stackoverflow.com/questions/74103528/type-hinting-an-instance-of-a-nested-class # https://stackoverflow.com/questions/74103528/type-hinting-an-instance-of-a-nested-class
# N = TypeVar("N", bound="AntaTest.Input")
# 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__) logger = logging.getLogger(__name__)
class AntaParamsBaseModel(BaseModel): class AntaMissingParamException(Exception):
"""Extends BaseModel and overwrite __getattr__ to return None on missing attribute.""" """
This Exception should be used when an expected key in an AntaCommand.params dictionary
was not found.
model_config = ConfigDict(extra="forbid") This Exception should in general never be raised in normal usage of ANTA.
"""
def __init__(self, message: str) -> None:
self.message = "\n".join([message, GITHUB_SUGGESTION])
super().__init__(self.message)
class AntaTemplate: class AntaTemplate(BaseModel):
"""Class to define a command template as Python f-string. """Class to define a command template as Python f-string.
Can render a command from parameters. Can render a command from parameters.
Attributes Attributes:
---------- template: Python f-string. Example: 'show vlan {vlan_id}'
template version: eAPI version - valid values are 1 or "latest" - default is "latest"
Python f-string. Example: 'show vlan {vlan_id}'. revision: Revision of the command. Valid values are 1 to 99. Revision has precedence over version.
version ofmt: eAPI output - json or text - default is json
eAPI version - valid values are 1 or "latest". use_cache: Enable or disable caching for this AntaTemplate if the AntaDevice supports it - default is True
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.
""" """
# pylint: disable=too-few-public-methods template: str
version: Literal[1, "latest"] = "latest"
revision: Optional[conint(ge=1, le=99)] = None # type: ignore
ofmt: Literal["json", "text"] = "json"
use_cache: bool = True
def __init__( def render(self, **params: dict[str, Any]) -> AntaCommand:
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] = {key: (Any, ...) for key in field_names}
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. """Render an AntaCommand from an AntaTemplate instance.
Keep the parameters used in the AntaTemplate instance. Keep the parameters used in the AntaTemplate instance.
Parameters Args:
---------- params: dictionary of variables with string values to render the Python f-string
params
Dictionary of variables with string values to render the Python f-string.
Returns Returns:
------- command: The rendered AntaCommand.
AntaCommand
The rendered AntaCommand.
This AntaCommand instance have a template attribute that references this This AntaCommand instance have a template attribute that references this
AntaTemplate instance. AntaTemplate instance.
Raises
------
AntaTemplateRenderError
If a parameter is missing to render the AntaTemplate instance.
""" """
try: try:
command = self.template.format(**params)
except (KeyError, SyntaxError) as e:
raise AntaTemplateRenderError(self, e.args[0]) from e
return AntaCommand( return AntaCommand(
command=command, command=self.template.format(**params),
ofmt=self.ofmt, ofmt=self.ofmt,
version=self.version, version=self.version,
revision=self.revision, revision=self.revision,
template=self, template=self,
params=self.params_schema(**params), params=params,
use_cache=self.use_cache, use_cache=self.use_cache,
) )
except KeyError as e:
raise AntaTemplateRenderError(self, e.args[0]) from e
class AntaCommand(BaseModel): class AntaCommand(BaseModel):
@ -146,147 +113,70 @@ class AntaCommand(BaseModel):
__Revision has precedence over version.__ __Revision has precedence over version.__
Attributes Attributes:
---------- command: Device command
command version: eAPI version - valid values are 1 or "latest" - default is "latest"
Device command. revision: eAPI revision of the command. Valid values are 1 to 99. Revision has precedence over version.
version ofmt: eAPI output - json or text - default is json
eAPI version - valid values are 1 or "latest". output: Output of the command populated by the collect() function
revision template: AntaTemplate object used to render this command
eAPI revision of the command. Valid values are 1 to 99. Revision has precedence over version. params: Dictionary of variables with string values to render the template
ofmt errors: If the command execution fails, eAPI returns a list of strings detailing the error
eAPI output - json or text. use_cache: Enable or disable caching for this AntaCommand if the AntaDevice supports it - default is True
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 command: str
version: Literal[1, "latest"] = "latest" version: Literal[1, "latest"] = "latest"
revision: Revision | None = None revision: Optional[conint(ge=1, le=99)] = None # type: ignore
ofmt: Literal["json", "text"] = "json" ofmt: Literal["json", "text"] = "json"
output: dict[str, Any] | str | None = None output: Optional[Union[Dict[str, Any], str]] = None
template: AntaTemplate | None = None template: Optional[AntaTemplate] = None
errors: list[str] = [] errors: List[str] = []
params: AntaParamsBaseModel = AntaParamsBaseModel() params: Dict[str, Any] = {}
use_cache: bool = True use_cache: bool = True
@property @property
def uid(self) -> str: def uid(self) -> str:
"""Generate a unique identifier for this command.""" """Generate a unique identifier for this command"""
uid_str = f"{self.command}_{self.version}_{self.revision or 'NA'}_{self.ofmt}" uid_str = f"{self.command}_{self.version}_{self.revision or 'NA'}_{self.ofmt}"
# Ignoring S324 probable use of insecure hash function - sha1 is enough for our needs. return hashlib.sha1(uid_str.encode()).hexdigest()
return hashlib.sha1(uid_str.encode()).hexdigest() # noqa: S324
@property @property
def json_output(self) -> dict[str, Any]: def json_output(self) -> dict[str, Any]:
"""Get the command output as JSON.""" """Get the command output as JSON"""
if self.output is None: if self.output is None:
msg = f"There is no output for command '{self.command}'" raise RuntimeError(f"There is no output for command {self.command}")
raise RuntimeError(msg)
if self.ofmt != "json" or not isinstance(self.output, dict): if self.ofmt != "json" or not isinstance(self.output, dict):
msg = f"Output of command '{self.command}' is invalid" raise RuntimeError(f"Output of command {self.command} is invalid")
raise RuntimeError(msg)
return dict(self.output) return dict(self.output)
@property @property
def text_output(self) -> str: def text_output(self) -> str:
"""Get the command output as a string.""" """Get the command output as a string"""
if self.output is None: if self.output is None:
msg = f"There is no output for command '{self.command}'" raise RuntimeError(f"There is no output for command {self.command}")
raise RuntimeError(msg)
if self.ofmt != "text" or not isinstance(self.output, str): if self.ofmt != "text" or not isinstance(self.output, str):
msg = f"Output of command '{self.command}' is invalid" raise RuntimeError(f"Output of command {self.command} is invalid")
raise RuntimeError(msg)
return str(self.output) return str(self.output)
@property
def error(self) -> bool:
"""Return True if the command returned an error, False otherwise."""
return len(self.errors) > 0
@property @property
def collected(self) -> bool: def collected(self) -> bool:
"""Return True if the command has been collected, False otherwise. """Return True if the command has been collected"""
return self.output is not None and not self.errors
A command that has not been collected could have returned an error.
See error property.
"""
return not self.error and self.output is not None
@property
def requires_privileges(self) -> bool:
"""Return True if the command requires privileged mode, 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.
"""
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("privileged mode required" in e for e in self.errors)
@property
def supported(self) -> bool:
"""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.
"""
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 all("not supported on this hardware platform" not in e 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): class AntaTemplateRenderError(RuntimeError):
"""Raised when an AntaTemplate object could not be rendered because of missing parameters.""" """
Raised when an AntaTemplate object could not be rendered
because of missing parameters
"""
def __init__(self, template: AntaTemplate, key: str) -> None: def __init__(self, template: AntaTemplate, key: str):
"""Initialize an AntaTemplateRenderError. """Constructor for AntaTemplateRenderError
Parameters
----------
template
The AntaTemplate instance that failed to render.
key
Key that has not been provided to render the template.
Args:
template: The AntaTemplate instance that failed to render
key: Key that has not been provided to render the template
""" """
self.template = template self.template = template
self.key = key self.key = key
@ -294,17 +184,17 @@ class AntaTemplateRenderError(RuntimeError):
class AntaTest(ABC): class AntaTest(ABC):
"""Abstract class defining a test in ANTA. """Abstract class defining a test in ANTA
The goal of this class is to handle the heavy lifting and make The goal of this class is to handle the heavy lifting and make
writing a test as simple as possible. writing a test as simple as possible.
Examples Examples:
--------
The following is an example of an AntaTest subclass implementation: The following is an example of an AntaTest subclass implementation:
```python ```python
class VerifyReachability(AntaTest): class VerifyReachability(AntaTest):
'''Test the network reachability to one or many destination IP(s).''' name = "VerifyReachability"
description = "Test the network reachability to one or many destination IP(s)."
categories = ["connectivity"] categories = ["connectivity"]
commands = [AntaTemplate(template="ping vrf {vrf} {dst} source {src} repeat 2")] commands = [AntaTemplate(template="ping vrf {vrf} {dst} source {src} repeat 2")]
@ -316,13 +206,14 @@ class AntaTest(ABC):
vrf: str = "default" vrf: str = "default"
def render(self, template: AntaTemplate) -> list[AntaCommand]: 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 @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
failures = [] failures = []
for command in self.instance_commands: for command in self.instance_commands:
src, dst = command.params.src, command.params.dst if command.params and ("src" and "dst") in command.params:
src, dst = command.params["src"], command.params["dst"]
if "2 received" not in command.json_output["messages"][0]: if "2 received" not in command.json_output["messages"][0]:
failures.append((str(src), str(dst))) failures.append((str(src), str(dst)))
if not failures: if not failures:
@ -330,43 +221,28 @@ class AntaTest(ABC):
else: else:
self.result.is_failure(f"Connectivity test failed for the following source-destination pairs: {failures}") self.result.is_failure(f"Connectivity test failed for the following source-destination pairs: {failures}")
``` ```
Attributes:
Attributes device: AntaDevice instance on which this test is run
---------- inputs: AntaTest.Input instance carrying the test inputs
device instance_commands: List of AntaCommand instances of this test
AntaDevice instance on which this test is run. result: TestResult instance representing the result of this test
inputs logger: Python logger for this test instance
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.
""" """
# Optional class attributes # Mandatory class attributes
# TODO - find a way to tell mypy these are mandatory for child classes - maybe Protocol
name: ClassVar[str] name: ClassVar[str]
description: 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]] categories: ClassVar[list[str]]
commands: ClassVar[list[AntaTemplate | AntaCommand]] commands: ClassVar[list[Union[AntaTemplate, AntaCommand]]]
# Class attributes to handle the progress bar of ANTA CLI # Class attributes to handle the progress bar of ANTA CLI
progress: Progress | None = None progress: Optional[Progress] = None
nrfu_task: TaskID | None = None nrfu_task: Optional[TaskID] = None
class Input(BaseModel): class Input(BaseModel):
"""Class defining inputs for a test in ANTA. """Class defining inputs for a test in ANTA.
Examples Examples:
--------
A valid test catalog will look like the following: A valid test catalog will look like the following:
```yaml ```yaml
<Python module>: <Python module>:
@ -377,94 +253,74 @@ class AntaTest(ABC):
description: "Test with overwritten description" description: "Test with overwritten description"
custom_field: "Test run by John Doe" custom_field: "Test run by John Doe"
``` ```
Attributes:
Attributes result_overwrite: Define fields to overwrite in the TestResult object
----------
result_overwrite
Define fields to overwrite in the TestResult object.
""" """
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
result_overwrite: ResultOverwrite | None = None result_overwrite: Optional[ResultOverwrite] = None
filters: Filters | None = None filters: Optional[Filters] = None
def __hash__(self) -> int: def __hash__(self) -> int:
"""Implement generic hashing for AntaTest.Input. """
Implement generic hashing for AntaTest.Input.
This will work in most cases but this does not consider 2 lists with different ordering as equal. This will work in most cases but this does not consider 2 lists with different ordering as equal.
""" """
return hash(self.model_dump_json()) return hash(self.model_dump_json())
class ResultOverwrite(BaseModel): class ResultOverwrite(BaseModel):
"""Test inputs model to overwrite result fields. """Test inputs model to overwrite result fields
Attributes
----------
description
Overwrite `TestResult.description`.
categories
Overwrite `TestResult.categories`.
custom_field
A free string that will be included in the TestResult object.
Attributes:
description: overwrite TestResult.description
categories: overwrite TestResult.categories
custom_field: a free string that will be included in the TestResult object
""" """
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
description: str | None = None description: Optional[str] = None
categories: list[str] | None = None categories: Optional[List[str]] = None
custom_field: str | None = None custom_field: Optional[str] = None
class Filters(BaseModel): class Filters(BaseModel):
"""Runtime filters to map tests with list of tags or devices. """Runtime filters to map tests with list of tags or devices
Attributes Attributes:
---------- tags: List of device's tags for the test.
tags
Tag of devices on which to run the test.
""" """
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
tags: set[str] | None = None tags: Optional[List[str]] = None
def __init__( def __init__(
self, self,
device: AntaDevice, device: AntaDevice,
inputs: dict[str, Any] | AntaTest.Input | None = None, inputs: dict[str, Any] | AntaTest.Input | None = None,
eos_data: list[dict[Any, Any] | str] | None = None, eos_data: list[dict[Any, Any] | str] | None = None,
) -> None: ):
"""AntaTest Constructor. """AntaTest Constructor
Parameters Args:
---------- device: AntaDevice instance on which the test will be run
device inputs: dictionary of attributes used to instantiate the AntaTest.Input instance
AntaDevice instance on which the test will be run. eos_data: Populate outputs of the test commands instead of collecting from devices.
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. 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.device: AntaDevice = device
self.inputs: AntaTest.Input self.inputs: AntaTest.Input
self.instance_commands: list[AntaCommand] = [] self.instance_commands: list[AntaCommand] = []
self.result: TestResult = TestResult( self.result: TestResult = TestResult(name=device.name, test=self.name, categories=self.categories, description=self.description)
name=device.name,
test=self.name,
categories=self.categories,
description=self.description,
)
self._init_inputs(inputs) self._init_inputs(inputs)
if self.result.result == AntaTestStatus.UNSET: if self.result.result == "unset":
self._init_commands(eos_data) self._init_commands(eos_data)
def _init_inputs(self, inputs: dict[str, Any] | AntaTest.Input | None) -> None: def _init_inputs(self, inputs: dict[str, Any] | AntaTest.Input | None) -> None:
"""Instantiate the `inputs` instance attribute with an `AntaTest.Input` instance to validate test inputs using the model. """Instantiate the `inputs` instance attribute with an `AntaTest.Input` instance
to validate test inputs from defined model.
Overwrite result fields based on `ResultOverwrite` input definition. Overwrite result fields based on `ResultOverwrite` input definition.
Any input validation error will set this test result status as 'error'. Any input validation error will set this test result status as 'error'."""
"""
try: try:
if inputs is None: if inputs is None:
self.inputs = self.Input() self.inputs = self.Input()
@ -473,7 +329,7 @@ class AntaTest(ABC):
elif isinstance(inputs, dict): elif isinstance(inputs, dict):
self.inputs = self.Input(**inputs) self.inputs = self.Input(**inputs)
except ValidationError as e: except ValidationError as e:
message = f"{self.module}.{self.name}: Inputs are not valid\n{e}" message = f"{self.__module__}.{self.__class__.__name__}: Inputs are not valid\n{e}"
self.logger.error(message) self.logger.error(message)
self.result.is_error(message=message) self.result.is_error(message=message)
return return
@ -484,11 +340,10 @@ class AntaTest(ABC):
self.result.description = res_ow.description self.result.description = res_ow.description
self.result.custom_field = res_ow.custom_field self.result.custom_field = res_ow.custom_field
def _init_commands(self, eos_data: list[dict[Any, Any] | str] | None) -> None: def _init_commands(self, eos_data: Optional[list[dict[Any, Any] | str]]) -> None:
"""Instantiate the `instance_commands` instance attribute from the `commands` class attribute. """Instantiate the `instance_commands` instance attribute from the `commands` class attribute.
- Copy of the `AntaCommand` instances - Copy of the `AntaCommand` instances
- Render all `AntaTemplate` instances using the `render()` method. - Render all `AntaTemplate` instances using the `render()` method
Any template rendering error will set this test result status as 'error'. Any template rendering error will set this test result status as 'error'.
Any exception in user code in `render()` will set this test result status as 'error'. Any exception in user code in `render()` will set this test result status as 'error'.
@ -496,7 +351,7 @@ class AntaTest(ABC):
if self.__class__.commands: if self.__class__.commands:
for cmd in self.__class__.commands: for cmd in self.__class__.commands:
if isinstance(cmd, AntaCommand): if isinstance(cmd, AntaCommand):
self.instance_commands.append(cmd.model_copy()) self.instance_commands.append(deepcopy(cmd))
elif isinstance(cmd, AntaTemplate): elif isinstance(cmd, AntaTemplate):
try: try:
self.instance_commands.extend(self.render(cmd)) self.instance_commands.extend(self.render(cmd))
@ -506,21 +361,21 @@ class AntaTest(ABC):
except NotImplementedError as e: except NotImplementedError as e:
self.result.is_error(message=e.args[0]) self.result.is_error(message=e.args[0])
return return
except Exception as e: # noqa: BLE001 except Exception as e: # pylint: disable=broad-exception-caught
# render() is user-defined code. # render() is user-defined code.
# We need to catch everything if we want the AntaTest object # We need to catch everything if we want the AntaTest object
# to live until the reporting # 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) anta_log_exception(e, message, self.logger)
self.result.is_error(message=f"{message}: {exc_to_str(e)}") self.result.is_error(message=f"{message}: {exc_to_str(e)}")
return return
if eos_data is not None: if eos_data is not None:
self.logger.debug("Test %s initialized with input data", self.name) self.logger.debug(f"Test {self.name} initialized with input data")
self.save_commands_data(eos_data) self.save_commands_data(eos_data)
def save_commands_data(self, eos_data: list[dict[str, Any] | str]) -> None: def save_commands_data(self, eos_data: list[dict[str, Any] | str]) -> None:
"""Populate output of all AntaCommand instances in `instance_commands`.""" """Populate output of all AntaCommand instances in `instance_commands`"""
if len(eos_data) > len(self.instance_commands): if len(eos_data) > len(self.instance_commands):
self.result.is_error(message="Test initialization error: Trying to save more data than there are commands for the test") self.result.is_error(message="Test initialization error: Trying to save more data than there are commands for the test")
return return
@ -531,67 +386,50 @@ class AntaTest(ABC):
self.instance_commands[index].output = data self.instance_commands[index].output = data
def __init_subclass__(cls) -> None: def __init_subclass__(cls) -> None:
"""Verify that the mandatory class attributes are defined and set name and description if not set.""" """Verify that the mandatory class attributes are defined"""
mandatory_attributes = ["categories", "commands"] mandatory_attributes = ["name", "description", "categories", "commands"]
if missing_attrs := [attr for attr in mandatory_attributes if not hasattr(cls, attr)]: for attr in mandatory_attributes:
msg = f"Class {cls.__module__}.{cls.__name__} is missing required class attribute(s): {', '.join(missing_attrs)}" if not hasattr(cls, attr):
raise AttributeError(msg) raise NotImplementedError(f"Class {cls.__module__}.{cls.__name__} is missing required class attribute {attr}")
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 @property
def collected(self) -> bool: def collected(self) -> bool:
"""Return True if all commands for this test have been collected.""" """Returns True if all commands for this test have been collected."""
return all(command.collected for command in self.instance_commands) return all(command.collected for command in self.instance_commands)
@property @property
def failed_commands(self) -> list[AntaCommand]: def failed_commands(self) -> list[AntaCommand]:
"""Return a list of all the commands that have failed.""" """Returns a list of all the commands that have failed."""
return [command for command in self.instance_commands if command.error] return [command for command in self.instance_commands if command.errors]
def render(self, template: AntaTemplate) -> list[AntaCommand]: def render(self, template: AntaTemplate) -> list[AntaCommand]:
"""Render an AntaTemplate instance of this AntaTest using the provided AntaTest.Input instance at self.inputs. """Render an AntaTemplate instance of this AntaTest using the provided
AntaTest.Input instance at self.inputs.
This is not an abstract method because it does not need to be implemented if there is This is not an abstract method because it does not need to be implemented if there is
no AntaTemplate for this test. no AntaTemplate for this test."""
""" raise NotImplementedError(f"AntaTemplate are provided but render() method has not been implemented for {self.__module__}.{self.name}")
_ = template
msg = f"AntaTemplate are provided but render() method has not been implemented for {self.module}.{self.__class__.__name__}"
raise NotImplementedError(msg)
@property @property
def blocked(self) -> bool: def blocked(self) -> bool:
"""Check if CLI commands contain a blocked keyword.""" """Check if CLI commands contain a blocked keyword."""
state = False state = False
for command in self.instance_commands: for command in self.instance_commands:
for pattern in REGEXP_EOS_BLACKLIST_CMDS: for pattern in BLACKLIST_REGEX:
if re.match(pattern, command.command): if re.match(pattern, command.command):
self.logger.error( self.logger.error(f"Command <{command.command}> is blocked for security reason matching {BLACKLIST_REGEX}")
"Command <%s> is blocked for security reason matching %s",
command.command,
REGEXP_EOS_BLACKLIST_CMDS,
)
self.result.is_error(f"<{command.command}> is blocked for security reason") self.result.is_error(f"<{command.command}> is blocked for security reason")
state = True state = True
return state return state
async def collect(self) -> None: async def collect(self) -> None:
"""Collect outputs of all commands of this test class from the device of this test instance.""" """
Method used to collect outputs of all commands of this test class from the device of this test instance.
"""
try: try:
if self.blocked is False: if self.blocked is False:
await self.device.collect_commands(self.instance_commands, collection_id=self.name) await self.device.collect_commands(self.instance_commands)
except Exception as e: # noqa: BLE001 except Exception as e: # pylint: disable=broad-exception-caught
# device._collect() is user-defined code. # device._collect() is user-defined code.
# We need to catch everything if we want the AntaTest object # We need to catch everything if we want the AntaTest object
# to live until the reporting # to live until the reporting
@ -601,7 +439,8 @@ class AntaTest(ABC):
@staticmethod @staticmethod
def anta_test(function: F) -> Callable[..., Coroutine[Any, Any, TestResult]]: def anta_test(function: F) -> Callable[..., Coroutine[Any, Any, TestResult]]:
"""Decorate the `test()` method in child classes. """
Decorator for the `test()` method.
This decorator implements (in this order): This decorator implements (in this order):
@ -615,50 +454,50 @@ class AntaTest(ABC):
async def wrapper( async def wrapper(
self: AntaTest, self: AntaTest,
eos_data: list[dict[Any, Any] | str] | None = None, eos_data: list[dict[Any, Any] | str] | None = None,
**kwargs: dict[str, Any], **kwargs: Any,
) -> TestResult: ) -> TestResult:
"""Inner function for the anta_test decorator.
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
-------
TestResult
The TestResult instance attribute populated with error status if any.
""" """
Args:
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.
Returns:
result: 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": if self.result.result != "unset":
return self.result return self.result
# Data # Data
if eos_data is not None: if eos_data is not None:
self.save_commands_data(eos_data) self.save_commands_data(eos_data)
self.logger.debug("Test %s initialized with input data %s", self.name, eos_data) self.logger.debug(f"Test {self.name} initialized with input data {eos_data}")
# If some data is missing, try to collect # If some data is missing, try to collect
if not self.collected: if not self.collected:
await self.collect() await self.collect()
if self.result.result != "unset": if self.result.result != "unset":
AntaTest.update_progress()
return self.result return self.result
if self.failed_commands: if cmds := self.failed_commands:
self._handle_failed_commands() self.logger.debug(self.device.supports)
unsupported_commands = [f"Skipped because {c.command} is not supported on {self.device.hw_model}" for c in cmds if not self.device.supports(c)]
AntaTest.update_progress() self.logger.debug(unsupported_commands)
if unsupported_commands:
self.logger.warning(f"Test {self.name} has been skipped because it is not supported on {self.device.hw_model}: {GITHUB_SUGGESTION}")
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]))
return self.result return self.result
try: try:
function(self, **kwargs) function(self, **kwargs)
except Exception as e: # noqa: BLE001 except Exception as e: # pylint: disable=broad-exception-caught
# test() is user-defined code. # test() is user-defined code.
# We need to catch everything if we want the AntaTest object # We need to catch everything if we want the AntaTest object
# to live until the reporting # to live until the reporting
@ -666,49 +505,30 @@ class AntaTest(ABC):
anta_log_exception(e, message, self.logger) anta_log_exception(e, message, self.logger)
self.result.is_error(message=exc_to_str(e)) self.result.is_error(message=exc_to_str(e))
# TODO: find a correct way to time test execution test_duration = time.time() - start_time
self.logger.debug(f"Executing test {self.name} on device {self.device.name} took {format_td(test_duration)}")
AntaTest.update_progress() AntaTest.update_progress()
return self.result return self.result
return wrapper 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:
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
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 @classmethod
def update_progress(cls: type[AntaTest]) -> None: def update_progress(cls) -> None:
"""Update progress bar for all AntaTest objects if it exists.""" """
Update progress bar for all AntaTest objects if it exists
"""
if cls.progress and (cls.nrfu_task is not None): if cls.progress and (cls.nrfu_task is not None):
cls.progress.update(cls.nrfu_task, advance=1) cls.progress.update(cls.nrfu_task, advance=1)
@abstractmethod @abstractmethod
def test(self) -> Coroutine[Any, Any, TestResult]: def test(self) -> Coroutine[Any, Any, TestResult]:
"""Core of the test logic. """
This abstract method is the core of the test logic.
It must set the correct status of the `result` instance attribute
with the appropriate outcome of the test.
This is an abstractmethod that must be implemented by child classes. Examples:
It must set the correct status of the `result` instance attribute with the appropriate outcome of the test.
Examples
--------
It must be implemented using the `AntaTest.anta_test` decorator: It must be implemented using the `AntaTest.anta_test` decorator:
```python ```python
@AntaTest.anta_test @AntaTest.anta_test
@ -716,7 +536,6 @@ class AntaTest(ABC):
self.result.is_success() self.result.is_success()
for command in self.instance_commands: for command in self.instance_commands:
if not self._test_command(command): # _test_command() is an arbitrary test logic if not self._test_command(command): # _test_command() is an arbitrary test logic
self.result.is_failure("Failure reason") self.result.is_failure("Failure reson")
``` ```
""" """

View file

View file

@ -1,26 +1,23 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Report management for ANTA.""" """
Report management for ANTA.
"""
# pylint: disable = too-few-public-methods # pylint: disable = too-few-public-methods
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass import os.path
from typing import TYPE_CHECKING, Any import pathlib
from typing import Any, Optional
from jinja2 import Template from jinja2 import Template
from rich.table import Table from rich.table import Table
from anta import RICH_COLOR_PALETTE, RICH_COLOR_THEME from anta import RICH_COLOR_PALETTE, RICH_COLOR_THEME
from anta.tools import convert_categories from anta.custom_types import TestStatus
from anta.result_manager import ResultManager
if TYPE_CHECKING:
import pathlib
from anta.result_manager import ResultManager
from anta.result_manager.models import AntaTestStatus, TestResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -28,201 +25,185 @@ logger = logging.getLogger(__name__)
class ReportTable: class ReportTable:
"""TableReport Generate a Table based on TestResult.""" """TableReport Generate a Table based on TestResult."""
@dataclass() def _split_list_to_txt_list(self, usr_list: list[str], delimiter: Optional[str] = None) -> str:
class Headers: # pylint: disable=too-many-instance-attributes """
"""Headers for the table report.""" Split list to multi-lines string
device: str = "Device" Args:
test_case: str = "Test Name" usr_list (list[str]): List of string to concatenate
number_of_success: str = "# of success" delimiter (str, optional): A delimiter to use to start string. Defaults to None.
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.
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.
Returns:
str: Multi-lines string
""" """
if delimiter is not None: if delimiter is not None:
return "\n".join(f"{delimiter} {line}" for line in usr_list) return "\n".join(f"{delimiter} {line}" for line in usr_list)
return "\n".join(f"{line}" for line in usr_list) return "\n".join(f"{line}" for line in usr_list)
def _build_headers(self, headers: list[str], table: Table) -> Table: def _build_headers(self, headers: list[str], table: Table) -> Table:
"""Create headers for a table. """
Create headers for a table.
First key is considered as header and is colored using RICH_COLOR_PALETTE.HEADER First key is considered as header and is colored using RICH_COLOR_PALETTE.HEADER
Parameters Args:
---------- headers (list[str]): List of headers
headers table (Table): A rich Table instance
List of headers.
table
A rich Table instance.
Returns
-------
Table
A rich `Table` instance with headers.
Returns:
Table: A rich Table instance with headers
""" """
for idx, header in enumerate(headers): for idx, header in enumerate(headers):
if idx == 0: if idx == 0:
table.add_column(header, justify="left", style=RICH_COLOR_PALETTE.HEADER, no_wrap=True) 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: else:
table.add_column(header, justify="left") table.add_column(header, justify="left")
return table return table
def _color_result(self, status: AntaTestStatus) -> str: def _color_result(self, status: TestStatus) -> str:
"""Return a colored string based on an AntaTestStatus.
Parameters
----------
status
AntaTestStatus enum to color.
Returns
-------
str
The colored string.
""" """
color = RICH_COLOR_THEME.get(str(status), "") Return a colored string based on the status value.
Args:
status (TestStatus): status value to color
Returns:
str: the colored string
"""
color = RICH_COLOR_THEME.get(status, "")
return f"[{color}]{status}" if color != "" else str(status) return f"[{color}]{status}" if color != "" else str(status)
def report_all(self, manager: ResultManager, title: str = "All tests results") -> Table: def report_all(
"""Create a table report with all tests for one or all devices. self,
result_manager: ResultManager,
host: Optional[str] = None,
testcase: Optional[str] = None,
title: str = "All tests results",
) -> Table:
"""
Create a table report with all tests for one or all devices.
Create table with full output: Device | Test Name | Test Status | Message(s) | Test description | Test category Create table with full output: Host / Test / Status / Message
Parameters Args:
---------- result_manager (ResultManager): A manager with a list of tests.
manager host (str, optional): IP Address of a host to search for. Defaults to None.
A ResultManager instance. testcase (str, optional): A test name to search for. Defaults to None.
title title (str, optional): Title for the report. Defaults to 'All tests results'.
Title for the report. Defaults to 'All tests results'.
Returns Returns:
------- Table: A fully populated rich Table
Table
A fully populated rich `Table`.
""" """
table = Table(title=title, show_lines=True) table = Table(title=title, show_lines=True)
headers = ["Device", "Test Name", "Test Status", "Message(s)", "Test description", "Test category"] headers = ["Device", "Test Name", "Test Status", "Message(s)", "Test description", "Test category"]
table = self._build_headers(headers=headers, table=table) table = self._build_headers(headers=headers, table=table)
def add_line(result: TestResult) -> None: for result in result_manager.get_results():
# pylint: disable=R0916
if (host is None and testcase is None) or (host is not None and str(result.name) == host) or (testcase is not None and testcase == str(result.test)):
state = self._color_result(result.result) state = self._color_result(result.result)
message = self._split_list_to_txt_list(result.messages) if len(result.messages) > 0 else "" message = self._split_list_to_txt_list(result.messages) if len(result.messages) > 0 else ""
categories = ", ".join(convert_categories(result.categories)) categories = ", ".join(result.categories)
table.add_row(str(result.name), result.test, state, message, result.description, categories) table.add_row(str(result.name), result.test, state, message, result.description, categories)
for result in manager.results:
add_line(result)
return table return table
def report_summary_tests( def report_summary_tests(
self, self,
manager: ResultManager, result_manager: ResultManager,
tests: list[str] | None = None, testcase: Optional[str] = None,
title: str = "Summary per test", title: str = "Summary per test case",
) -> Table: ) -> Table:
"""Create a table report with result aggregated per test.
Create table with full output:
Test Name | # of success | # of skipped | # of failure | # of errors | List of failed or error nodes
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`.
""" """
Create a table report with result agregated per test.
Create table with full output: Test / Number of success / Number of failure / Number of error / List of nodes in error or failure
Args:
result_manager (ResultManager): A manager with a list of tests.
testcase (str, optional): A test name to search for. Defaults to None.
title (str, optional): Title for the report. Defaults to 'All tests results'.
Returns:
Table: A fully populated rich Table
"""
# sourcery skip: class-extract-method
table = Table(title=title, show_lines=True) table = Table(title=title, show_lines=True)
headers = [ headers = [
self.Headers.test_case, "Test Case",
self.Headers.number_of_success, "# of success",
self.Headers.number_of_skipped, "# of skipped",
self.Headers.number_of_failure, "# of failure",
self.Headers.number_of_errors, "# of errors",
self.Headers.list_of_error_nodes, "List of failed or error nodes",
] ]
table = self._build_headers(headers=headers, table=table) table = self._build_headers(headers=headers, table=table)
for test, stats in sorted(manager.test_stats.items()): for testcase_read in result_manager.get_testcases():
if tests is None or test in tests: if testcase is None or str(testcase_read) == testcase:
results = result_manager.get_result_by_test(testcase_read)
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 = [str(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( table.add_row(
test, testcase_read,
str(stats.devices_success_count), str(nb_success),
str(stats.devices_skipped_count), str(nb_skipped),
str(stats.devices_failure_count), str(nb_failure),
str(stats.devices_error_count), str(nb_error),
", ".join(stats.devices_failure), str(list_failure),
) )
return table return table
def report_summary_devices( def report_summary_hosts(
self, self,
manager: ResultManager, result_manager: ResultManager,
devices: list[str] | None = None, host: Optional[str] = None,
title: str = "Summary per device", title: str = "Summary per host",
) -> Table: ) -> Table:
"""Create a table report with result aggregated per device. """
Create a table report with result agregated per host.
Create table with full output: Device | # of success | # of skipped | # of failure | # of errors | List of failed or error test cases Create table with full output: Host / Number of success / Number of failure / Number of error / List of nodes in error or failure
Parameters Args:
---------- result_manager (ResultManager): A manager with a list of tests.
manager host (str, optional): IP Address of a host to search for. Defaults to None.
A ResultManager instance. title (str, optional): Title for the report. Defaults to 'All tests results'.
devices
List of device names to include. None to select all devices.
title
Title of the report.
Returns Returns:
------- Table: A fully populated rich Table
Table
A fully populated rich `Table`.
""" """
table = Table(title=title, show_lines=True) table = Table(title=title, show_lines=True)
headers = [ headers = [
self.Headers.device, "Device",
self.Headers.number_of_success, "# of success",
self.Headers.number_of_skipped, "# of skipped",
self.Headers.number_of_failure, "# of failure",
self.Headers.number_of_errors, "# of errors",
self.Headers.list_of_error_tests, "List of failed or error test cases",
] ]
table = self._build_headers(headers=headers, table=table) table = self._build_headers(headers=headers, table=table)
for device, stats in sorted(manager.device_stats.items()): for host_read in result_manager.get_hosts():
if devices is None or device in devices: if host is None or str(host_read) == host:
results = result_manager.get_result_by_host(host_read)
logger.debug("data to use for computation")
logger.debug(f"{host}: {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 = [str(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( table.add_row(
device, str(host_read),
str(stats.tests_success_count), str(nb_success),
str(stats.tests_skipped_count), str(nb_skipped),
str(stats.tests_failure_count), str(nb_failure),
str(stats.tests_error_count), str(nb_error),
", ".join(stats.tests_failure), str(list_failure),
) )
return table return table
@ -231,23 +212,20 @@ class ReportJinja:
"""Report builder based on a Jinja2 template.""" """Report builder based on a Jinja2 template."""
def __init__(self, template_path: pathlib.Path) -> None: def __init__(self, template_path: pathlib.Path) -> None:
"""Create a ReportJinja instance.""" if os.path.isfile(template_path):
if not template_path.is_file(): self.tempalte_path = template_path
msg = f"template file is not found: {template_path}" else:
raise FileNotFoundError(msg) raise FileNotFoundError(f"template file is not found: {template_path}")
self.template_path = template_path def render(self, data: list[dict[str, Any]], trim_blocks: bool = True, lstrip_blocks: bool = True) -> str:
"""
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
"""Build a report based on a Jinja2 template.
Report is built based on a J2 template provided by user. Report is built based on a J2 template provided by user.
Data structure sent to template is: Data structure sent to template is:
Example >>> data = ResultManager.get_json_results()
------- >>> print(data)
```
>>> print(ResultManager.json)
[ [
{ {
name: ..., name: ...,
@ -258,24 +236,16 @@ class ReportJinja:
description: ..., description: ...,
} }
] ]
```
Parameters Args:
---------- data (list[dict[str, Any]]): List of results from ResultManager.get_results
data trim_blocks (bool, optional): enable trim_blocks for J2 rendering. Defaults to True.
List of results from `ResultManager.results`. lstrip_blocks (bool, optional): enable lstrip_blocks for J2 rendering. Defaults to True.
trim_blocks
enable trim_blocks for J2 rendering.
lstrip_blocks
enable lstrip_blocks for J2 rendering.
Returns
-------
str
Rendered template
Returns:
str: rendered template
""" """
with self.template_path.open(encoding="utf-8") as file_: with open(self.tempalte_path, encoding="utf-8") as file_:
template = Template(file_.read(), trim_blocks=trim_blocks, lstrip_blocks=lstrip_blocks) template = Template(file_.read(), trim_blocks=trim_blocks, lstrip_blocks=lstrip_blocks)
return template.render({"data": data}) return template.render({"data": data})

View file

@ -1,121 +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.
"""CSV Report management for ANTA."""
# pylint: disable = too-few-public-methods
from __future__ import annotations
import csv
import logging
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=",",
)
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

View file

@ -1,298 +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.
"""Markdown report generator for ANTA test results."""
from __future__ import annotations
import logging
import re
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, ClassVar, TextIO
from anta.constants import MD_REPORT_TOC
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__)
# 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.
The `generate` class method will loop over all the section subclasses and call their `generate_section` method.
The final report will be generated in the same order as the `sections` list of the method.
"""
@classmethod
def generate(cls, results: ResultManager, md_filename: Path) -> None:
"""Generate and write the various sections of the markdown report.
Parameters
----------
results
The ResultsManager instance containing all test results.
md_filename
The path to the markdown file to write the report into.
"""
try:
with md_filename.open("w", encoding="utf-8") as mdfile:
sections: list[MDReportBase] = [
ANTAReport(mdfile, results),
TestResultsSummary(mdfile, results),
SummaryTotals(mdfile, results),
SummaryTotalsDeviceUnderTest(mdfile, results),
SummaryTotalsPerCategory(mdfile, results),
TestResults(mdfile, results),
]
for section in sections:
section.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
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) -> 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.
"""
self.mdfile = mdfile
self.results = results
@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 spaces to keep content on one line
text = text.replace("\n", " ")
# Replace backticks with single quotes
return text.replace("`", "'")
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
self.mdfile.write(toc + "\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
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.sorted_category_stats.items():
total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count
yield (
f"| {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.get_results(sort_by=["name", "test"]):
messages = self.safe_markdown(", ".join(result.messages))
categories = ", ".join(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)

View file

@ -1,36 +1,35 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Result Manager module for ANTA.""" """
Result Manager Module for ANTA.
"""
from __future__ import annotations from __future__ import annotations
import json import json
import logging import logging
from collections import defaultdict
from functools import cached_property
from itertools import chain
from typing import Any
from anta.result_manager.models import AntaTestStatus, TestResult from pydantic import TypeAdapter
from .models import CategoryStats, DeviceStats, TestStats from anta.custom_types import TestStatus
from anta.result_manager.models import TestResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# pylint: disable=too-many-instance-attributes
class ResultManager: class ResultManager:
"""Helper to manage Test Results and generate reports. """
Helper to manage Test Results and generate reports.
Examples:
Examples
--------
Create Inventory: Create Inventory:
inventory_anta = AntaInventory.parse( inventory_anta = AntaInventory.parse(
filename='examples/inventory.yml', filename='examples/inventory.yml',
username='ansible', username='ansible',
password='ansible', password='ansible',
timeout=0.5
) )
Create Result Manager: Create Result Manager:
@ -39,51 +38,36 @@ class ResultManager:
Run tests for all connected devices: Run tests for all connected devices:
for device in inventory_anta.get_inventory().devices: for device in inventory_anta.get_inventory():
manager.add( manager.add_test_result(
VerifyNTP(device=device).test() VerifyNTP(device=device).test()
) )
manager.add( manager.add_test_result(
VerifyEOSVersion(device=device).test(version='4.28.3M') VerifyEOSVersion(device=device).test(version='4.28.3M')
) )
Print result in native format: Print result in native format:
manager.results manager.get_results()
[ [
TestResult( TestResult(
name="pf1", host=IPv4Address('192.168.0.10'),
test="VerifyZeroTouch", test='VerifyNTP',
categories=["configuration"], result='failure',
description="Verifies ZeroTouch is disabled", message="device is not running NTP correctly"
result="success",
messages=[],
custom_field=None,
), ),
TestResult( TestResult(
name="pf1", host=IPv4Address('192.168.0.10'),
test='VerifyNTP', test='VerifyEOSVersion',
categories=["software"], result='success',
categories=['system'], message=None
description='Verifies if NTP is synchronised.',
result='failure',
messages=["The device is not synchronized with the configured NTP server(s): 'NTP is disabled.'"],
custom_field=None,
), ),
] ]
""" """
_result_entries: list[TestResult]
status: AntaTestStatus
error_status: bool
_device_stats: defaultdict[str, DeviceStats]
_category_stats: defaultdict[str, CategoryStats]
_test_stats: defaultdict[str, TestStats]
_stats_in_sync: bool
def __init__(self) -> None: def __init__(self) -> None:
"""Class constructor. """
Class constructor.
The status of the class is initialized to "unset" The status of the class is initialized to "unset"
@ -103,287 +87,125 @@ class ResultManager:
If the status of the added test is error, the status is untouched and the If the status of the added test is error, the status is untouched and the
error_status is set to True. error_status is set to True.
""" """
self.reset()
def reset(self) -> None:
"""Create or reset the attributes of the ResultManager instance."""
self._result_entries: list[TestResult] = [] self._result_entries: list[TestResult] = []
self.status: AntaTestStatus = AntaTestStatus.UNSET # Initialize status
self.status: TestStatus = "unset"
self.error_status = False self.error_status = False
# Initialize the statistics attributes
self._reset_stats()
def __len__(self) -> int: def __len__(self) -> int:
"""Implement __len__ method to count number of results.""" """
Implement __len__ method to count number of results.
"""
return len(self._result_entries) return len(self._result_entries)
@property def _update_status(self, test_status: TestStatus) -> None:
def results(self) -> list[TestResult]:
"""Get the list of TestResult."""
return self._result_entries
@results.setter
def results(self, value: list[TestResult]) -> None:
"""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(self.dump, indent=4)
@property
def device_stats(self) -> defaultdict[str, DeviceStats]:
"""Get the device statistics."""
self._ensure_stats_in_sync()
return self._device_stats
@property
def category_stats(self) -> defaultdict[str, CategoryStats]:
"""Get the category statistics."""
self._ensure_stats_in_sync()
return self._category_stats
@property
def test_stats(self) -> defaultdict[str, TestStats]:
"""Get the test statistics."""
self._ensure_stats_in_sync()
return self._test_stats
@property
def sorted_category_stats(self) -> dict[str, CategoryStats]:
"""A property that returns the category_stats dictionary sorted by key name."""
self._ensure_stats_in_sync()
return dict(sorted(self.category_stats.items()))
@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.
""" """
Update ResultManager status based on the table above.
"""
ResultValidator = TypeAdapter(TestStatus)
ResultValidator.validate_python(test_status)
if test_status == "error": if test_status == "error":
self.error_status = True self.error_status = True
return return
if self.status == "unset" or (self.status == "skipped" and test_status in {"success", "failure"}): if self.status == "unset":
self.status = test_status
elif self.status == "skipped" and test_status in {"success", "failure"}:
self.status = test_status self.status = test_status
elif self.status == "success" and test_status == "failure": elif self.status == "success" and test_status == "failure":
self.status = AntaTestStatus.FAILURE self.status = "failure"
def _reset_stats(self) -> None: def add_test_result(self, entry: TestResult) -> None:
"""Create or reset the statistics attributes.""" """Add a result to the list
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: Args:
"""Update the statistics based on the test result. entry (TestResult): TestResult data to add to the report
Parameters
----------
result
TestResult to update the statistics.
""" """
count_attr = f"tests_{result.result}_count" logger.debug(entry)
self._result_entries.append(entry)
self._update_status(entry.result)
# Update device stats def add_test_results(self, entries: list[TestResult]) -> None:
device_stats: DeviceStats = self._device_stats[result.name] """Add a list of results to the list
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 Args:
for category in result.categories: entries (list[TestResult]): List of TestResult data to add to the report
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.
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) for e in entries:
self._update_status(result.result) self.add_test_result(e)
self._stats_in_sync = False
# Every time a new result is added, we need to clear the cached property def get_status(self, ignore_error: bool = False) -> str:
self.__dict__.pop("results_by_status", None)
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 Returns the current status including error_status if ignore_error is False
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) 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 return "error" if self.error_status and not ignore_error else self.status
def filter(self, hide: set[AntaTestStatus]) -> ResultManager: def get_results(self) -> list[TestResult]:
"""Get a filtered ResultManager based on test 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) Expose list of all test results in different format
manager = ResultManager()
manager.results = self.get_results(possible_statuses - hide)
return manager
def filter_by_tests(self, tests: set[str]) -> ResultManager: Returns:
"""Get a filtered ResultManager that only contains specific tests. any: List of results.
Parameters
----------
tests
Set of test names to filter the results.
Returns
-------
ResultManager
A filtered `ResultManager`.
""" """
manager = ResultManager() return self._result_entries
manager.results = [result for result in self._result_entries if result.test in tests]
return manager
def filter_by_devices(self, devices: set[str]) -> ResultManager: def get_json_results(self) -> str:
"""Get a filtered ResultManager that only contains specific devices.
Parameters
----------
devices
Set of device names to filter the results.
Returns
-------
ResultManager
A filtered `ResultManager`.
""" """
manager = ResultManager() Expose list of all test results in JSON
manager.results = [result for result in self._result_entries if result.name in devices]
return manager
def get_tests(self) -> set[str]: Returns:
"""Get the set of all the test names. str: JSON dumps of the list of results
Returns
-------
set[str]
Set of test names.
""" """
return {str(result.test) for result in self._result_entries} result = [result.model_dump() for result in self._result_entries]
return json.dumps(result, indent=4)
def get_devices(self) -> set[str]: def get_result_by_test(self, test_name: str) -> list[TestResult]:
"""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} Get list of test result for a given test.
Args:
test_name (str): Test name to use to filter results
output_format (str, optional): format selector. Can be either native/list. Defaults to 'native'.
Returns:
list[TestResult]: List of results related to the test.
"""
return [result for result in self._result_entries if str(result.test) == test_name]
def get_result_by_host(self, host_ip: str) -> list[TestResult]:
"""
Get list of test result for a given host.
Args:
host_ip (str): IP Address of the host to use to filter results.
output_format (str, optional): format selector. Can be either native/list. Defaults to 'native'.
Returns:
list[TestResult]: List of results related to the host.
"""
return [result for result in self._result_entries if str(result.name) == host_ip]
def get_testcases(self) -> list[str]:
"""
Get list of name of all test cases in current manager.
Returns:
list[str]: List of names for all tests.
"""
result_list = []
for testcase in self._result_entries:
if str(testcase.test) not in result_list:
result_list.append(str(testcase.test))
return result_list
def get_hosts(self) -> list[str]:
"""
Get list of IP addresses in current manager.
Returns:
list[str]: List of IP addresses.
"""
result_list = []
for testcase in self._result_entries:
if str(testcase.name) not in result_list:
result_list.append(str(testcase.name))
return result_list

View file

@ -2,160 +2,85 @@
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Models related to anta.result_manager module.""" """Models related to anta.result_manager module."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field # Need to keep List for pydantic in 3.8
from enum import Enum from typing import List, Optional
from pydantic import BaseModel 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): class TestResult(BaseModel):
"""Describe the result of a test from a single device. """
Describe the result of a test from a single device.
Attributes
----------
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.
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: str
test: str test: str
categories: list[str] categories: List[str]
description: str description: str
result: AntaTestStatus = AntaTestStatus.UNSET result: TestStatus = "unset"
messages: list[str] = [] messages: List[str] = []
custom_field: str | None = None custom_field: Optional[str] = None
def is_success(self, message: str | None = None) -> None: def is_success(self, message: str | None = None) -> None:
"""Set status to success.
Parameters
----------
message
Optional message related to the test.
""" """
self._set_status(AntaTestStatus.SUCCESS, message) Helper to set status to success
Args:
message: Optional message related to the test
"""
self._set_status("success", message)
def is_failure(self, message: str | None = None) -> None: def is_failure(self, message: str | None = None) -> None:
"""Set status to failure.
Parameters
----------
message
Optional message related to the test.
""" """
self._set_status(AntaTestStatus.FAILURE, message) Helper to set status to failure
Args:
message: Optional message related to the test
"""
self._set_status("failure", message)
def is_skipped(self, message: str | None = None) -> None: def is_skipped(self, message: str | None = None) -> None:
"""Set status to skipped.
Parameters
----------
message
Optional message related to the test.
""" """
self._set_status(AntaTestStatus.SKIPPED, message) Helper to set status to skipped
Args:
message: Optional message related to the test
"""
self._set_status("skipped", message)
def is_error(self, message: str | None = None) -> None: def is_error(self, message: str | None = None) -> None:
"""Set status to error.
Parameters
----------
message
Optional message related to the test.
""" """
self._set_status(AntaTestStatus.ERROR, message) Helper to set status to error
"""
self._set_status("error", message)
def _set_status(self, status: AntaTestStatus, message: str | None = None) -> None: def _set_status(self, status: TestStatus, message: str | None = None) -> None:
"""Set status and insert optional message. """
Set status and insert optional message
Parameters
----------
status
Status of the test.
message
Optional message.
Args:
status: status of the test
message: optional message
""" """
self.result = status self.result = status
if message is not None: if message is not None:
self.messages.append(message) self.messages.append(message)
def __str__(self) -> str: def __str__(self) -> str:
"""Return a human readable string of this TestResult.""" """
Returns a human readable string of this TestResult
"""
return f"Test '{self.test}' (on '{self.name}'): Result '{self.result}'\nMessages: {self.messages}" return f"Test '{self.test}' (on '{self.name}'): Result '{self.result}'\nMessages: {self.messages}"
# Pylint does not treat dataclasses differently: https://github.com/pylint-dev/pylint/issues/9058
# pylint: disable=too-many-instance-attributes
@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)

View file

@ -1,312 +1,109 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""ANTA runner function.""" # pylint: disable=too-many-branches
"""
ANTA runner function
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os from typing import Tuple
import sys
from collections import defaultdict
from typing import TYPE_CHECKING, Any
from anta import GITHUB_SUGGESTION from anta import GITHUB_SUGGESTION
from anta.logger import anta_log_exception, exc_to_str from anta.catalog import AntaCatalog, AntaTestDefinition
from anta.device import AntaDevice
from anta.inventory import AntaInventory
from anta.logger import anta_log_exception
from anta.models import AntaTest from anta.models import AntaTest
from anta.tools import Catchtime, cprofile from anta.result_manager import ResultManager
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
if os.name == "posix":
import resource
DEFAULT_NOFILE = 16384
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)
resource.setrlimit(resource.RLIMIT_NOFILE, (nofile, limits[1]))
return resource.getrlimit(resource.RLIMIT_NOFILE)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
AntaTestRunner = Tuple[AntaTestDefinition, AntaDevice]
def log_cache_statistics(devices: list[AntaDevice]) -> None:
"""Log cache statistics for each device in the inventory.
Parameters async def main(manager: ResultManager, inventory: AntaInventory, catalog: AntaCatalog, tags: list[str] | None = None, established_only: bool = True) -> None:
----------
devices
List of devices in the inventory.
""" """
for device in devices: Main coroutine to run ANTA.
if device.cache_statistics is not None: Use this as an entrypoint to the test framwork in your script.
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.info(msg)
else:
logger.info("Caching is not enabled on %s", device.name)
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.
tags: List of tags to filter devices from the inventory. Defaults to None.
established_only: Include only established device(s). Defaults to True.
async def setup_inventory(inventory: AntaInventory, tags: set[str] | None, devices: set[str] | None, *, established_only: bool) -> AntaInventory | None: Returns:
"""Set up the inventory for the ANTA run. any: 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.
Returns
-------
AntaInventory | None
The filtered inventory or None if there are no devices to run tests on.
"""
if len(inventory) == 0:
logger.info("The inventory is empty, exiting")
return None
# 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
selected_inventory = selected_inventory.get_inventory(established_only=established_only)
# 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 None
return selected_inventory
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.
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
device_to_tests: defaultdict[AntaDevice, set[AntaTestDefinition]] = defaultdict(set)
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, 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 tags
device_to_tests[device].update(catalog.tag_to_tests[None])
# Then add the tests with matching tags from device tags
device_to_tests[device].update(catalog.get_tests_by_tags(device.tags))
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
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
@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.
Use this as an entrypoint to the test framework in your script.
ResultManager object gets updated with the test results.
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.
""" """
if not catalog.tests: if not catalog.tests:
logger.info("The list of tests is empty, exiting") logger.info("The list of tests is empty, exiting")
return return
if len(inventory) == 0:
with Catchtime(logger=logger, message="Preparing ANTA NRFU Run"): logger.info("The inventory is empty, exiting")
# Setup the inventory
selected_inventory = inventory if dry_run else await setup_inventory(inventory, tags, devices, established_only=established_only)
if selected_inventory is None:
return return
await inventory.connect_inventory()
devices: list[AntaDevice] = list(inventory.get_inventory(established_only=established_only, tags=tags).values())
with Catchtime(logger=logger, message="Preparing the tests"): if not devices:
selected_tests = prepare_tests(selected_inventory, catalog, tests, tags) logger.info(
if selected_tests is None: f"No device in the established state '{established_only}' "
return f"{f'matching the tags {tags} ' if tags else ''}was found. There is no device to run tests against, exiting"
final_tests_count = sum(len(tests) for tests in selected_tests.values())
run_info = (
"--- ANTA NRFU Run Information ---\n"
f"Number of devices: {len(inventory)} ({len(selected_inventory)} established)\n"
f"Total number of selected tests: {final_tests_count}\n"
) )
if os.name == "posix": return
# Adjust the maximum number of open file descriptors for the ANTA process coros = []
limits = adjust_rlimit_nofile() # Using a set to avoid inserting duplicate tests
run_info += f"Maximum number of open file descriptors for the current ANTA process: {limits[0]}\n" tests_set: set[AntaTestRunner] = set()
for device in devices:
if tags:
# If there are CLI tags, only execute tests with matching tags
tests_set.update((test, device) for test in catalog.get_tests_by_tags(tags))
else: else:
# Running on non-Posix system, cannot manage the resource. # If there is no CLI tags, execute all tests without filters
limits = (sys.maxsize, sys.maxsize) tests_set.update((t, device) for t in catalog.tests if t.inputs.filters is None or t.inputs.filters.tags is None)
run_info += "Running on a non-POSIX system, cannot adjust the maximum number of file descriptors.\n"
run_info += "---------------------------------" # Then add the tests with matching tags from device tags
tests_set.update((t, device) for t in catalog.get_tests_by_tags(device.tags))
logger.info(run_info) tests: list[AntaTestRunner] = list(tests_set)
if final_tests_count > limits[0]: if not tests:
logger.warning( logger.info(f"There is no tests{f' matching the tags {tags} ' if tags else ' '}to run on current inventory. " "Exiting...")
"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."
)
coroutines = get_coroutines(selected_tests, manager if dry_run else None)
if dry_run:
logger.info("Dry-run mode, exiting before running the tests.")
for coro in coroutines:
coro.close()
return return
for test_definition, device in 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: if AntaTest.progress is not None:
AntaTest.nrfu_task = AntaTest.progress.add_task("Running NRFU Tests...", total=len(coroutines)) AntaTest.nrfu_task = AntaTest.progress.add_task("Running NRFU Tests...", total=len(coros))
with Catchtime(logger=logger, message="Running ANTA tests"): logger.info("Running ANTA tests...")
results = await asyncio.gather(*coroutines) test_results = await asyncio.gather(*coros)
for result in results: for r in test_results:
manager.add(result) manager.add_test_result(r)
for device in devices:
log_cache_statistics(selected_inventory.devices) if device.cache_statistics is not None:
logger.info(
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']})"
)
else:
logger.info(f"Caching is not enabled on {device.name}")

View file

@ -1,4 +1,3 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to all ANTA tests."""

View file

@ -1,54 +1,44 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to the EOS various AAA tests.""" """
Test functions related to the EOS various AAA settings
"""
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from ipaddress import IPv4Address from ipaddress import IPv4Address
from typing import TYPE_CHECKING, ClassVar, Literal
# Need to keep List and Set for pydantic in python 3.8
from typing import List, Literal, Set
from anta.custom_types import AAAAuthMethod from anta.custom_types import AAAAuthMethod
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyTacacsSourceIntf(AntaTest): class VerifyTacacsSourceIntf(AntaTest):
"""Verifies TACACS source-interface for a specified VRF. """
Verifies TACACS source-interface for a specified VRF.
Expected Results Expected Results:
---------------- * success: The test will pass if the provided TACACS source-interface is configured in the specified VRF.
* Success: The test will pass if the provided TACACS source-interface is configured in the specified VRF. * failure: The test will fail if the provided TACACS source-interface is NOT configured in the specified VRF.
* Failure: The test will fail if the provided TACACS source-interface is NOT configured in the specified VRF.
Examples
--------
```yaml
anta.tests.aaa:
- VerifyTacacsSourceIntf:
intf: Management0
vrf: MGMT
```
""" """
categories: ClassVar[list[str]] = ["aaa"] name = "VerifyTacacsSourceIntf"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)] description = "Verifies TACACS source-interface for a specified VRF."
categories = ["aaa"]
class Input(AntaTest.Input): commands = [AntaCommand(command="show tacacs")]
"""Input model for the VerifyTacacsSourceIntf test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
intf: str intf: str
"""Source-interface to use as source IP of TACACS messages.""" """Source-interface to use as source IP of TACACS messages"""
vrf: str = "default" vrf: str = "default"
"""The name of the VRF to transport TACACS messages. Defaults to `default`.""" """The name of the VRF to transport TACACS messages"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyTacacsSourceIntf."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
try: try:
if command_output["srcIntf"][self.inputs.vrf] == self.inputs.intf: if command_output["srcIntf"][self.inputs.vrf] == self.inputs.intf:
@ -60,39 +50,27 @@ class VerifyTacacsSourceIntf(AntaTest):
class VerifyTacacsServers(AntaTest): class VerifyTacacsServers(AntaTest):
"""Verifies TACACS servers are configured for a specified VRF. """
Verifies TACACS servers are configured for a specified VRF.
Expected Results Expected Results:
---------------- * success: The test will pass if the provided TACACS servers are configured in the specified VRF.
* Success: The test will pass if the provided TACACS servers are configured in the specified VRF. * failure: The test will fail if the provided TACACS servers are NOT configured in the specified VRF.
* Failure: The test will fail if the provided TACACS servers are NOT configured in the specified VRF.
Examples
--------
```yaml
anta.tests.aaa:
- VerifyTacacsServers:
servers:
- 10.10.10.21
- 10.10.10.22
vrf: MGMT
```
""" """
categories: ClassVar[list[str]] = ["aaa"] name = "VerifyTacacsServers"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)] description = "Verifies TACACS servers are configured for a specified VRF."
categories = ["aaa"]
commands = [AntaCommand(command="show tacacs")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyTacacsServers test.""" servers: List[IPv4Address]
"""List of TACACS servers"""
servers: list[IPv4Address]
"""List of TACACS servers."""
vrf: str = "default" vrf: str = "default"
"""The name of the VRF to transport TACACS messages. Defaults to `default`.""" """The name of the VRF to transport TACACS messages"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyTacacsServers."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
tacacs_servers = command_output["tacacsServers"] tacacs_servers = command_output["tacacsServers"]
if not tacacs_servers: if not tacacs_servers:
@ -112,36 +90,25 @@ class VerifyTacacsServers(AntaTest):
class VerifyTacacsServerGroups(AntaTest): class VerifyTacacsServerGroups(AntaTest):
"""Verifies if the provided TACACS server group(s) are configured. """
Verifies if the provided TACACS server group(s) are configured.
Expected Results Expected Results:
---------------- * success: The test will pass if the provided TACACS server group(s) are configured.
* Success: The test will pass if the provided TACACS server group(s) are configured. * failure: The test will fail if one or all the provided TACACS server group(s) are NOT configured.
* Failure: The test will fail if one or all the provided TACACS server group(s) are NOT configured.
Examples
--------
```yaml
anta.tests.aaa:
- VerifyTacacsServerGroups:
groups:
- TACACS-GROUP1
- TACACS-GROUP2
```
""" """
categories: ClassVar[list[str]] = ["aaa"] name = "VerifyTacacsServerGroups"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)] description = "Verifies if the provided TACACS server group(s) are configured."
categories = ["aaa"]
commands = [AntaCommand(command="show tacacs")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyTacacsServerGroups test.""" groups: List[str]
"""List of TACACS server group"""
groups: list[str]
"""List of TACACS server groups."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyTacacsServerGroups."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
tacacs_groups = command_output["groups"] tacacs_groups = command_output["groups"]
if not tacacs_groups: if not tacacs_groups:
@ -155,45 +122,29 @@ class VerifyTacacsServerGroups(AntaTest):
class VerifyAuthenMethods(AntaTest): class VerifyAuthenMethods(AntaTest):
"""Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x). """
Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x).
Expected Results Expected Results:
---------------- * success: The test will pass if the provided AAA authentication method list is matching in the configured authentication types.
* Success: The test will pass if the provided AAA authentication method list is matching in the configured authentication types. * failure: The test will fail if the provided AAA authentication method list is NOT matching in the configured authentication types.
* Failure: The test will fail if the provided AAA authentication method list is NOT matching in the configured authentication types.
Examples
--------
```yaml
anta.tests.aaa:
- VerifyAuthenMethods:
methods:
- local
- none
- logging
types:
- login
- enable
- dot1x
```
""" """
categories: ClassVar[list[str]] = ["aaa"] name = "VerifyAuthenMethods"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authentication", revision=1)] description = "Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x)."
categories = ["aaa"]
commands = [AntaCommand(command="show aaa methods authentication")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyAuthenMethods test.""" methods: List[AAAAuthMethod]
"""List of AAA authentication methods. Methods should be in the right order"""
methods: list[AAAAuthMethod] types: Set[Literal["login", "enable", "dot1x"]]
"""List of AAA authentication methods. Methods should be in the right order.""" """List of authentication types to verify"""
types: set[Literal["login", "enable", "dot1x"]]
"""List of authentication types to verify."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyAuthenMethods."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
not_matching: list[str] = [] not_matching = []
for k, v in command_output.items(): for k, v in command_output.items():
auth_type = k.replace("AuthenMethods", "") auth_type = k.replace("AuthenMethods", "")
if auth_type not in self.inputs.types: if auth_type not in self.inputs.types:
@ -206,8 +157,9 @@ class VerifyAuthenMethods(AntaTest):
if v["login"]["methods"] != self.inputs.methods: 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 {self.inputs.methods} are not matching for login console")
return return
not_matching.extend(auth_type for methods in v.values() if methods["methods"] != self.inputs.methods) for methods in v.values():
if methods["methods"] != self.inputs.methods:
not_matching.append(auth_type)
if not not_matching: if not not_matching:
self.result.is_success() self.result.is_success()
else: else:
@ -215,51 +167,37 @@ class VerifyAuthenMethods(AntaTest):
class VerifyAuthzMethods(AntaTest): class VerifyAuthzMethods(AntaTest):
"""Verifies the AAA authorization method lists for different authorization types (commands, exec). """
Verifies the AAA authorization method lists for different authorization types (commands, exec).
Expected Results Expected Results:
---------------- * success: The test will pass if the provided AAA authorization method list is matching in the configured authorization types.
* Success: The test will pass if the provided AAA authorization method list is matching in the configured authorization types. * failure: The test will fail if the provided AAA authorization method list is NOT matching in the configured authorization types.
* Failure: The test will fail if the provided AAA authorization method list is NOT matching in the configured authorization types.
Examples
--------
```yaml
anta.tests.aaa:
- VerifyAuthzMethods:
methods:
- local
- none
- logging
types:
- commands
- exec
```
""" """
categories: ClassVar[list[str]] = ["aaa"] name = "VerifyAuthzMethods"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authorization", revision=1)] description = "Verifies the AAA authorization method lists for different authorization types (commands, exec)."
categories = ["aaa"]
commands = [AntaCommand(command="show aaa methods authorization")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyAuthzMethods test.""" methods: List[AAAAuthMethod]
"""List of AAA authorization methods. Methods should be in the right order"""
methods: list[AAAAuthMethod] types: Set[Literal["commands", "exec"]]
"""List of AAA authorization methods. Methods should be in the right order.""" """List of authorization types to verify"""
types: set[Literal["commands", "exec"]]
"""List of authorization types to verify."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyAuthzMethods."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
not_matching: list[str] = [] not_matching = []
for k, v in command_output.items(): for k, v in command_output.items():
authz_type = k.replace("AuthzMethods", "") authz_type = k.replace("AuthzMethods", "")
if authz_type not in self.inputs.types: if authz_type not in self.inputs.types:
# We do not need to verify this accounting type # We do not need to verify this accounting type
continue continue
not_matching.extend(authz_type for methods in v.values() if methods["methods"] != self.inputs.methods) for methods in v.values():
if methods["methods"] != self.inputs.methods:
not_matching.append(authz_type)
if not not_matching: if not not_matching:
self.result.is_success() self.result.is_success()
else: else:
@ -267,44 +205,27 @@ class VerifyAuthzMethods(AntaTest):
class VerifyAcctDefaultMethods(AntaTest): class VerifyAcctDefaultMethods(AntaTest):
"""Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x). """
Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x).
Expected Results Expected Results:
---------------- * success: The test will pass if the provided AAA accounting default method list is matching in the configured accounting types.
* Success: The test will pass if the provided AAA accounting default method list is matching in the configured accounting types. * failure: The test will fail if the provided AAA accounting default method list is NOT matching in the configured accounting types.
* Failure: The test will fail if the provided AAA accounting default method list is NOT matching in the configured accounting types.
Examples
--------
```yaml
anta.tests.aaa:
- VerifyAcctDefaultMethods:
methods:
- local
- none
- logging
types:
- system
- exec
- commands
- dot1x
```
""" """
categories: ClassVar[list[str]] = ["aaa"] name = "VerifyAcctDefaultMethods"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)] description = "Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x)."
categories = ["aaa"]
commands = [AntaCommand(command="show aaa methods accounting")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyAcctDefaultMethods test.""" methods: List[AAAAuthMethod]
"""List of AAA accounting methods. Methods should be in the right order"""
methods: list[AAAAuthMethod] types: Set[Literal["commands", "exec", "system", "dot1x"]]
"""List of AAA accounting methods. Methods should be in the right order.""" """List of accounting types to verify"""
types: set[Literal["commands", "exec", "system", "dot1x"]]
"""List of accounting types to verify."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyAcctDefaultMethods."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
not_matching = [] not_matching = []
not_configured = [] not_configured = []
@ -328,44 +249,27 @@ class VerifyAcctDefaultMethods(AntaTest):
class VerifyAcctConsoleMethods(AntaTest): class VerifyAcctConsoleMethods(AntaTest):
"""Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x). """
Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x).
Expected Results Expected Results:
---------------- * success: The test will pass if the provided AAA accounting console method list is matching in the configured accounting types.
* Success: The test will pass if the provided AAA accounting console method list is matching in the configured accounting types. * failure: The test will fail if the provided AAA accounting console method list is NOT matching in the configured accounting types.
* Failure: The test will fail if the provided AAA accounting console method list is NOT matching in the configured accounting types.
Examples
--------
```yaml
anta.tests.aaa:
- VerifyAcctConsoleMethods:
methods:
- local
- none
- logging
types:
- system
- exec
- commands
- dot1x
```
""" """
categories: ClassVar[list[str]] = ["aaa"] name = "VerifyAcctConsoleMethods"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)] description = "Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x)."
categories = ["aaa"]
commands = [AntaCommand(command="show aaa methods accounting")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyAcctConsoleMethods test.""" methods: List[AAAAuthMethod]
"""List of AAA accounting console methods. Methods should be in the right order"""
methods: list[AAAAuthMethod] types: Set[Literal["commands", "exec", "system", "dot1x"]]
"""List of AAA accounting console methods. Methods should be in the right order.""" """List of accounting console types to verify"""
types: set[Literal["commands", "exec", "system", "dot1x"]]
"""List of accounting console types to verify."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyAcctConsoleMethods."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
not_matching = [] not_matching = []
not_configured = [] not_configured = []

View file

@ -1,195 +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.
"""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"AVT path {path} for profile {profile} in VRF {vrf} is invalid and not active.")
elif not valid:
self.result.is_failure(f"AVT path {path} for profile {profile} in VRF {vrf} is invalid.")
elif not active:
self.result.is_failure(f"AVT path {path} for profile {profile} in VRF {vrf} is 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"Expected AVT role as `{self.inputs.role}`, but found `{command_output.get('role')}` instead.")

View file

@ -1,233 +1,203 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to BFD tests.""" """
BFD test functions
"""
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime
from typing import TYPE_CHECKING, ClassVar from ipaddress import IPv4Address
from typing import Any, List, Optional
from pydantic import Field from pydantic import BaseModel, Field
from anta.input_models.bfd import BFDPeer from anta.custom_types import BfdInterval, BfdMultiplier
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
from anta.tools import get_value from anta.tools.get_value import get_value
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyBFDSpecificPeers(AntaTest): class VerifyBFDSpecificPeers(AntaTest):
"""Verifies the state of IPv4 BFD peer sessions. """
This class verifies if the IPv4 BFD peer's sessions are UP and remote disc is non-zero in the specified VRF.
This test performs the following checks for each specified peer: Expected results:
* success: The test will pass if IPv4 BFD peers are up and remote disc is non-zero in the specified VRF.
1. Confirms that the specified VRF is configured. * 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.
2. Verifies that the peer exists in the BFD configuration.
3. For each specified BFD peer:
- Validates that the state is `up`
- Confirms that the remote discriminator identifier (disc) is non-zero.
Expected Results
----------------
* Success: If all of the following conditions are met:
- All specified peers are found in the BFD configuration within the specified VRF.
- All BFD peers are `up` and remote disc is non-zero.
* Failure: If any of the following occur:
- A specified peer is not found in the BFD configuration within the specified VRF.
- Any BFD peer session is not `up` or the remote discriminator identifier is zero.
Examples
--------
```yaml
anta.tests.bfd:
- VerifyBFDSpecificPeers:
bfd_peers:
- peer_address: 192.0.255.8
vrf: default
- peer_address: 192.0.255.7
vrf: default
```
""" """
categories: ClassVar[list[str]] = ["bfd"] name = "VerifyBFDSpecificPeers"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers", revision=1)] description = "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF."
categories = ["bfd"]
commands = [AntaCommand(command="show bfd peers")]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyBFDSpecificPeers test.""" """
This class defines the input parameters of the test case.
"""
bfd_peers: list[BFDPeer] bfd_peers: List[BFDPeers]
"""List of IPv4 BFD""" """List of IPv4 BFD peers"""
BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer
"""To maintain backward compatibility.""" class BFDPeers(BaseModel):
"""
This class defines the details of an IPv4 BFD peer.
"""
peer_address: IPv4Address
"""IPv4 address of a BFD peer"""
vrf: str = "default"
"""Optional VRF for BGP peer. If not provided, it defaults to `default`."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyBFDSpecificPeers.""" failures: dict[Any, Any] = {}
self.result.is_success()
# Iterating over BFD peers # Iterating over BFD peers
for bfd_peer in self.inputs.bfd_peers: for bfd_peer in self.inputs.bfd_peers:
peer = str(bfd_peer.peer_address) peer = str(bfd_peer.peer_address)
vrf = bfd_peer.vrf vrf = bfd_peer.vrf
bfd_output = get_value( bfd_output = get_value(self.instance_commands[0].json_output, f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..", separator="..")
self.instance_commands[0].json_output,
f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..",
separator="..",
)
# Check if BFD peer configured # Check if BFD peer configured
if not bfd_output: if not bfd_output:
self.result.is_failure(f"{bfd_peer} - Not found") failures[peer] = {vrf: "Not Configured"}
continue continue
# Check BFD peer status and remote disc # Check BFD peer status and remote disc
state = bfd_output.get("status") if not (bfd_output.get("status") == "up" and bfd_output.get("remoteDisc") != 0):
remote_disc = bfd_output.get("remoteDisc") failures[peer] = {vrf: {"status": bfd_output.get("status"), "remote_disc": bfd_output.get("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}") 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}")
class VerifyBFDPeersIntervals(AntaTest): class VerifyBFDPeersIntervals(AntaTest):
"""Verifies the timers of IPv4 BFD peer sessions. """
This class verifies the timers of the IPv4 BFD peers in the specified VRF.
This test performs the following checks for each specified peer: Expected results:
* success: The test will pass if the timers of the IPv4 BFD peers are correct in the specified VRF.
1. Confirms that the specified VRF is configured. * failure: The test will fail if the IPv4 BFD peers are not found or their timers are incorrect in the specified VRF.
2. Verifies that the peer exists in the BFD configuration.
3. Confirms that BFD peer is correctly configured with the `Transmit interval, Receive interval and Multiplier`.
Expected Results
----------------
* Success: If all of the following conditions are met:
- All specified peers are found in the BFD configuration within the specified VRF.
- All BFD peers are correctly configured with the `Transmit interval, Receive interval and Multiplier`.
* Failure: If any of the following occur:
- A specified peer is not found in the BFD configuration within the specified VRF.
- Any BFD peer not correctly configured with the `Transmit interval, Receive interval and Multiplier`.
Examples
--------
```yaml
anta.tests.bfd:
- VerifyBFDPeersIntervals:
bfd_peers:
- peer_address: 192.0.255.8
vrf: default
tx_interval: 1200
rx_interval: 1200
multiplier: 3
- peer_address: 192.0.255.7
vrf: default
tx_interval: 1200
rx_interval: 1200
multiplier: 3
```
""" """
categories: ClassVar[list[str]] = ["bfd"] name = "VerifyBFDPeersIntervals"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=1)] description = "Verifies the timers of the IPv4 BFD peers in the specified VRF."
categories = ["bfd"]
commands = [AntaCommand(command="show bfd peers detail")]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyBFDPeersIntervals test.""" """
This class defines the input parameters of the test case.
"""
bfd_peers: list[BFDPeer] bfd_peers: List[BFDPeers]
"""List of IPv4 BFD""" """List of BFD peers"""
BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer
"""To maintain backward compatibility""" class BFDPeers(BaseModel):
"""
This class defines the details of an IPv4 BFD peer.
"""
peer_address: IPv4Address
"""IPv4 address of a BFD peer"""
vrf: str = "default"
"""Optional VRF for BGP 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"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyBFDPeersIntervals.""" failures: dict[Any, Any] = {}
self.result.is_success()
# Iterating over BFD peers # Iterating over BFD peers
for bfd_peer in self.inputs.bfd_peers: for bfd_peers in self.inputs.bfd_peers:
peer = str(bfd_peer.peer_address) peer = str(bfd_peers.peer_address)
vrf = bfd_peer.vrf vrf = bfd_peers.vrf
tx_interval = bfd_peer.tx_interval
rx_interval = bfd_peer.rx_interval # Converting milliseconds intervals into actual value
multiplier = bfd_peer.multiplier 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 # Check if BFD peer configured
bfd_output = get_value(
self.instance_commands[0].json_output,
f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..",
separator="..",
)
if not bfd_output: if not bfd_output:
self.result.is_failure(f"{bfd_peer} - Not found") failures[peer] = {vrf: "Not Configured"}
continue continue
# Convert interval timer(s) into milliseconds to be consistent with the inputs.
bfd_details = bfd_output.get("peerStatsDetail", {}) bfd_details = bfd_output.get("peerStatsDetail", {})
op_tx_interval = bfd_details.get("operTxInterval") // 1000 intervals_ok = (
op_rx_interval = bfd_details.get("operRxInterval") // 1000 bfd_details.get("operTxInterval") == tx_interval and bfd_details.get("operRxInterval") == rx_interval and bfd_details.get("detectMult") == multiplier
detect_multiplier = bfd_details.get("detectMult") )
if op_tx_interval != tx_interval: # Check timers of BFD peer
self.result.is_failure(f"{bfd_peer} - Incorrect Transmit interval - Expected: {tx_interval} Actual: {op_tx_interval}") 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 op_rx_interval != rx_interval: # Check if any failures
self.result.is_failure(f"{bfd_peer} - Incorrect Receive interval - Expected: {rx_interval} Actual: {op_rx_interval}") if not failures:
self.result.is_success()
if detect_multiplier != multiplier: else:
self.result.is_failure(f"{bfd_peer} - Incorrect Multiplier - Expected: {multiplier} Actual: {detect_multiplier}") self.result.is_failure(f"Following BFD peers are not configured or timers are not correct:\n{failures}")
class VerifyBFDPeersHealth(AntaTest): class VerifyBFDPeersHealth(AntaTest):
"""Verifies the health of IPv4 BFD peers across all VRFs. """
This class verifies the health of IPv4 BFD peers across all VRFs.
This test performs the following checks for BFD peers across all VRFs: 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.
1. Validates that the state is `up`. Expected results:
2. Confirms that the remote discriminator identifier (disc) is non-zero. * Success: The test will pass if all IPv4 BFD peers are up, the discriminator value of each remote system is non-zero,
3. Optionally verifies that the peer have not been down before a specified threshold of hours. 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,
Expected Results or the last downtime of any peer is below the defined threshold.
----------------
* Success: If all of the following conditions are met:
- All BFD peers across the VRFs are up and remote disc is non-zero.
- Last downtime of each peer is above the defined threshold, if specified.
* Failure: If any of the following occur:
- Any BFD peer session is not up or the remote discriminator identifier is zero.
- Last downtime of any peer is below the defined threshold, if specified.
Examples
--------
```yaml
anta.tests.bfd:
- VerifyBFDPeersHealth:
down_threshold: 2
```
""" """
categories: ClassVar[list[str]] = ["bfd"] name = "VerifyBFDPeersHealth"
description = "Verifies the health of all IPv4 BFD peers."
categories = ["bfd"]
# revision 1 as later revision introduces additional nesting for type # revision 1 as later revision introduces additional nesting for type
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ commands = [AntaCommand(command="show bfd peers", revision=1), AntaCommand(command="show clock")]
AntaCommand(command="show bfd peers", revision=1),
AntaCommand(command="show clock", revision=1),
]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyBFDPeersHealth test.""" """
This class defines the input parameters of the test case.
"""
down_threshold: int | None = Field(default=None, gt=0) down_threshold: Optional[int] = Field(default=None, gt=0)
"""Optional down threshold in hours to check if a BFD peer was down before those hours or not.""" """Optional down threshold in hours to check if a BFD peer was down before those hours or not."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyBFDPeersHealth.""" # Initialize failure strings
self.result.is_success() down_failures = []
up_failures = []
# Extract the current timestamp and command output # Extract the current timestamp and command output
clock_output = self.instance_commands[1].json_output clock_output = self.instance_commands[1].json_output
current_timestamp = clock_output["utcTime"] current_timestamp = clock_output["utcTime"]
bfd_output = self.instance_commands[0].json_output bfd_output = self.instance_commands[0].json_output
# set the initial result
self.result.is_success()
# Check if any IPv4 BFD peer is configured # Check if any IPv4 BFD peer is configured
ipv4_neighbors_exist = any(vrf_data["ipv4Neighbors"] for vrf_data in bfd_output["vrfs"].values()) ipv4_neighbors_exist = any(vrf_data["ipv4Neighbors"] for vrf_data in bfd_output["vrfs"].values())
if not ipv4_neighbors_exist: if not ipv4_neighbors_exist:
@ -240,88 +210,26 @@ class VerifyBFDPeersHealth(AntaTest):
for peer_data in neighbor_data["peerStats"].values(): for peer_data in neighbor_data["peerStats"].values():
peer_status = peer_data["status"] peer_status = peer_data["status"]
remote_disc = peer_data["remoteDisc"] remote_disc = peer_data["remoteDisc"]
remote_disc_info = f" with remote disc {remote_disc}" if remote_disc == 0 else ""
last_down = peer_data["lastDown"] last_down = peer_data["lastDown"]
hours_difference = ( hours_difference = (datetime.fromtimestamp(current_timestamp) - datetime.fromtimestamp(last_down)).total_seconds() / 3600
datetime.fromtimestamp(current_timestamp, tz=timezone.utc) - datetime.fromtimestamp(last_down, tz=timezone.utc)
).total_seconds() / 3600
if not (peer_status == "up" and remote_disc != 0): # Check if peer status is not up
self.result.is_failure( if peer_status != "up":
f"Peer: {peer} VRF: {vrf} - Session not properly established - State: {peer_status} Remote Discriminator: {remote_disc}" down_failures.append(f"{peer} is {peer_status} in {vrf} VRF{remote_disc_info}.")
)
# Check if the last down is within the threshold # Check if the last down is within the threshold
if self.inputs.down_threshold and hours_difference < self.inputs.down_threshold: elif self.inputs.down_threshold and hours_difference < self.inputs.down_threshold:
self.result.is_failure( up_failures.append(f"{peer} in {vrf} VRF was down {round(hours_difference)} hours ago{remote_disc_info}.")
f"Peer: {peer} VRF: {vrf} - Session failure detected within the expected uptime threshold ({round(hours_difference)} hours ago)"
)
# Check if remote disc is 0
elif remote_disc == 0:
up_failures.append(f"{peer} in {vrf} VRF has remote disc {remote_disc}.")
class VerifyBFDPeersRegProtocols(AntaTest): # Check if there are any failures
"""Verifies the registered routing protocol of IPv4 BFD peer sessions. if down_failures:
down_failures_str = "\n".join(down_failures)
This test performs the following checks for each specified peer: self.result.is_failure(f"Following BFD peers are not up:\n{down_failures_str}")
if up_failures:
1. Confirms that the specified VRF is configured. up_failures_str = "\n".join(up_failures)
2. Verifies that the peer exists in the BFD configuration. self.result.is_failure(f"\nFollowing BFD peers were down:\n{up_failures_str}")
3. Confirms that BFD peer is correctly configured with the `routing protocol`.
Expected Results
----------------
* Success: If all of the following conditions are met:
- All specified peers are found in the BFD configuration within the specified VRF.
- All BFD peers are correctly configured with the `routing protocol`.
* Failure: If any of the following occur:
- A specified peer is not found in the BFD configuration within the specified VRF.
- Any BFD peer not correctly configured with the `routing protocol`.
Examples
--------
```yaml
anta.tests.bfd:
- VerifyBFDPeersRegProtocols:
bfd_peers:
- peer_address: 192.0.255.7
vrf: default
protocols:
- bgp
```
"""
categories: ClassVar[list[str]] = ["bfd"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifyBFDPeersRegProtocols test."""
bfd_peers: list[BFDPeer]
"""List of IPv4 BFD"""
BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer
"""To maintain backward compatibility"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyBFDPeersRegProtocols."""
self.result.is_success()
# Iterating over BFD peers, extract the parameters and command output
for bfd_peer in self.inputs.bfd_peers:
peer = str(bfd_peer.peer_address)
vrf = bfd_peer.vrf
protocols = bfd_peer.protocols
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:
self.result.is_failure(f"{bfd_peer} - Not found")
continue
# Check registered protocols
difference = sorted(set(protocols) - set(get_value(bfd_output, "peerStatsDetail.apps")))
if difference:
failures = " ".join(f"`{item}`" for item in difference)
self.result.is_failure(f"{bfd_peer} - {failures} routing protocol(s) not configured")

View file

@ -1,45 +1,30 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to the device configuration tests.""" """
Test functions related to the device configuration
"""
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
import re
from typing import TYPE_CHECKING, ClassVar
from anta.custom_types import RegexString
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyZeroTouch(AntaTest): class VerifyZeroTouch(AntaTest):
"""Verifies ZeroTouch is disabled. """
Verifies ZeroTouch is disabled
Expected Results
----------------
* Success: The test will pass if ZeroTouch is disabled.
* Failure: The test will fail if ZeroTouch is enabled.
Examples
--------
```yaml
anta.tests.configuration:
- VerifyZeroTouch:
```
""" """
categories: ClassVar[list[str]] = ["configuration"] name = "VerifyZeroTouch"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show zerotouch", revision=1)] description = "Verifies ZeroTouch is disabled"
categories = ["configuration"]
commands = [AntaCommand(command="show zerotouch")]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyZeroTouch.""" command_output = self.instance_commands[0].output
command_output = self.instance_commands[0].json_output assert isinstance(command_output, dict)
if command_output["mode"] == "disabled": if command_output["mode"] == "disabled":
self.result.is_success() self.result.is_success()
else: else:
@ -47,82 +32,20 @@ class VerifyZeroTouch(AntaTest):
class VerifyRunningConfigDiffs(AntaTest): class VerifyRunningConfigDiffs(AntaTest):
"""Verifies there is no difference between the running-config and the startup-config. """
Verifies there is no difference between the running-config and the startup-config
Expected Results
----------------
* Success: The test will pass if there is no difference between the running-config and the startup-config.
* Failure: The test will fail if there is a difference between the running-config and the startup-config.
Examples
--------
```yaml
anta.tests.configuration:
- VerifyRunningConfigDiffs:
```
""" """
categories: ClassVar[list[str]] = ["configuration"] name = "VerifyRunningConfigDiffs"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config diffs", ofmt="text")] description = "Verifies there is no difference between the running-config and the startup-config"
categories = ["configuration"]
commands = [AntaCommand(command="show running-config diffs", ofmt="text")]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyRunningConfigDiffs.""" command_output = self.instance_commands[0].output
command_output = self.instance_commands[0].text_output if command_output is None or command_output == "":
if command_output == "":
self.result.is_success() self.result.is_success()
else: else:
self.result.is_failure(command_output) self.result.is_failure()
self.result.is_failure(str(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))

View file

@ -1,140 +1,125 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to various connectivity tests.""" """
Test functions related to various connectivity checks
"""
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from typing import ClassVar from ipaddress import IPv4Address
from anta.input_models.connectivity import Host, LLDPNeighbor, Neighbor # Need to keep List for pydantic in python 3.8
from anta.models import AntaCommand, AntaTemplate, AntaTest from typing import List, Union
from pydantic import BaseModel
from anta.custom_types import Interface
from anta.models import AntaCommand, AntaMissingParamException, AntaTemplate, AntaTest
class VerifyReachability(AntaTest): class VerifyReachability(AntaTest):
"""Test network reachability to one or many destination IP(s). """
Test network reachability to one or many destination IP(s).
Expected Results Expected Results:
---------------- * success: The test will pass if all destination IP(s) are reachable.
* Success: The test will pass if all destination IP(s) are reachable. * failure: The test will fail if one or many destination IP(s) are unreachable.
* Failure: The test will fail if one or many destination IP(s) are unreachable.
Examples
--------
```yaml
anta.tests.connectivity:
- VerifyReachability:
hosts:
- source: Management0
destination: 1.1.1.1
vrf: MGMT
df_bit: True
size: 100
- source: Management0
destination: 8.8.8.8
vrf: MGMT
df_bit: True
size: 100
```
""" """
categories: ClassVar[list[str]] = ["connectivity"] name = "VerifyReachability"
# Template uses '{size}{df_bit}' without space since df_bit includes leading space when enabled description = "Test the network reachability to one or many destination IP(s)."
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ categories = ["connectivity"]
AntaTemplate(template="ping vrf {vrf} {destination} source {source} size {size}{df_bit} repeat {repeat}", revision=1) commands = [AntaTemplate(template="ping vrf {vrf} {destination} source {source} repeat {repeat}")]
]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyReachability test.""" hosts: List[Host]
"""List of hosts to ping"""
hosts: list[Host] class Host(BaseModel):
"""List of host to ping.""" """Remote host to ping"""
Host: ClassVar[type[Host]] = Host
"""To maintain backward compatibility.""" destination: IPv4Address
"""IPv4 address to ping"""
source: Union[IPv4Address, Interface]
"""IPv4 address source IP or Egress interface to use"""
vrf: str = "default"
"""VRF context"""
repeat: int = 2
"""Number of ping repetition (default=2)"""
def render(self, template: AntaTemplate) -> list[AntaCommand]: 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=host.source, 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 @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyReachability.""" failures = []
self.result.is_success() for command in self.instance_commands:
src = command.params.get("source")
dst = command.params.get("destination")
repeat = command.params.get("repeat")
for command, host in zip(self.instance_commands, self.inputs.hosts): if any(elem is None for elem in (src, dst, repeat)):
if f"{host.repeat} received" not in command.json_output["messages"][0]: raise AntaMissingParamException(f"A parameter is missing to execute the test for command {command}")
self.result.is_failure(f"{host} - Unreachable")
if f"{repeat} received" not in command.json_output["messages"][0]:
failures.append((str(src), str(dst)))
if not failures:
self.result.is_success()
else:
self.result.is_failure(f"Connectivity test failed for the following source-destination pairs: {failures}")
class VerifyLLDPNeighbors(AntaTest): class VerifyLLDPNeighbors(AntaTest):
"""Verifies the connection status of the specified LLDP (Link Layer Discovery Protocol) neighbors. """
This test verifies that the provided LLDP neighbors are present and connected with the correct configuration.
This test performs the following checks for each specified LLDP neighbor: Expected Results:
* success: The test will pass if each of the provided LLDP neighbors is present and connected to the specified port and device.
1. Confirming matching ports on both local and neighboring devices. * failure: The test will fail if any of the following conditions are met:
2. Ensuring compatibility of device names and interface identifiers. - The provided LLDP neighbor is not found.
3. Verifying neighbor configurations match expected values per interface; extra neighbors are ignored. - The system name or port of the LLDP neighbor does not match the provided information.
Expected Results
----------------
* 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 in the LLDP table.
- The system name or port of the LLDP neighbor does not match the expected information.
Examples
--------
```yaml
anta.tests.connectivity:
- VerifyLLDPNeighbors:
neighbors:
- port: Ethernet1
neighbor_device: DC1-SPINE1
neighbor_port: Ethernet1
- port: Ethernet2
neighbor_device: DC1-SPINE2
neighbor_port: Ethernet1
```
""" """
categories: ClassVar[list[str]] = ["connectivity"] name = "VerifyLLDPNeighbors"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lldp neighbors detail", revision=1)] description = "Verifies that the provided LLDP neighbors are connected properly."
categories = ["connectivity"]
commands = [AntaCommand(command="show lldp neighbors detail")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyLLDPNeighbors test.""" neighbors: List[Neighbor]
"""List of LLDP neighbors"""
neighbors: list[LLDPNeighbor] class Neighbor(BaseModel):
"""List of LLDP neighbors.""" """LLDP neighbor"""
Neighbor: ClassVar[type[Neighbor]] = Neighbor
"""To maintain backward compatibility.""" port: Interface
"""LLDP port"""
neighbor_device: str
"""LLDP neighbor device"""
neighbor_port: Interface
"""LLDP neighbor port"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyLLDPNeighbors.""" command_output = self.instance_commands[0].json_output
self.result.is_success()
failures: dict[str, list[str]] = {}
output = self.instance_commands[0].json_output["lldpNeighbors"]
for neighbor in self.inputs.neighbors: for neighbor in self.inputs.neighbors:
if neighbor.port not in output: if neighbor.port not in command_output["lldpNeighbors"]:
self.result.is_failure(f"{neighbor} - Port not found") failures.setdefault("port_not_configured", []).append(neighbor.port)
continue elif len(lldp_neighbor_info := command_output["lldpNeighbors"][neighbor.port]["lldpNeighborInfo"]) == 0:
failures.setdefault("no_lldp_neighbor", []).append(neighbor.port)
elif (
lldp_neighbor_info[0]["systemName"] != neighbor.neighbor_device
or lldp_neighbor_info[0]["neighborInterfaceInfo"]["interfaceId_v2"] != neighbor.neighbor_port
):
failures.setdefault("wrong_lldp_neighbor", []).append(neighbor.port)
if len(lldp_neighbor_info := output[neighbor.port]["lldpNeighborInfo"]) == 0: if not failures:
self.result.is_failure(f"{neighbor} - No LLDP neighbors") self.result.is_success()
continue else:
self.result.is_failure(f"The following port(s) have issues: {failures}")
# 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
)
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)}")

View file

@ -1,283 +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.
"""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: {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:
self.result.is_failure(f"Management CVX status is not valid: {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"Incorrect number of mount path states for {hostname} - 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"Unexpected MCS path type for {hostname}: '{path_type}'.")
if (path_state := path.get("state")) != "mountStateMountComplete":
self.result.is_failure(f"MCS server mount state for path '{path_type}' is not valid is for {hostname}: '{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"No mount status for {hostname}")
continue
if not mcs_mounts:
self.result.is_failure(f"MCS mount state not detected for {hostname}")
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"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: {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 {num_of_peers} vs {expected_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} is 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} registration state is not complete: {peer_reg_state}")

View file

@ -1,47 +1,33 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to field notices tests.""" """
Test functions to flag field notices
from __future__ import annotations """
from typing import TYPE_CHECKING, ClassVar
from anta.decorators import skip_on_platforms from anta.decorators import skip_on_platforms
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyFieldNotice44Resolution(AntaTest): class VerifyFieldNotice44Resolution(AntaTest):
"""Verifies if the device is using an Aboot version that fixes the bug discussed in the Field Notice 44. """
Verifies the device is using an Aboot version that fix the bug discussed
in the field notice 44 (Aboot manages system settings prior to EOS initialization).
Aboot manages system settings prior to EOS initialization. https://www.arista.com/en/support/advisories-notices/field-notice/8756-field-notice-44
Reference: https://www.arista.com/en/support/advisories-notices/field-notice/8756-field-notice-44
Expected Results
----------------
* Success: The test will pass if the device is using an Aboot version that fixes the bug discussed in the Field Notice 44.
* Failure: The test will fail if the device is not using an Aboot version that fixes the bug discussed in the Field Notice 44.
Examples
--------
```yaml
anta.tests.field_notices:
- VerifyFieldNotice44Resolution:
```
""" """
description = "Verifies that the device is using the correct Aboot version per FN0044." name = "VerifyFieldNotice44Resolution"
categories: ClassVar[list[str]] = ["field notices"] description = (
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)] "Verifies the device is using an Aboot version that fix the bug discussed in the field notice 44 (Aboot manages system settings prior to EOS initialization)"
)
categories = ["field notices", "software"]
commands = [AntaCommand(command="show version detail")]
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) # TODO maybe implement ONLY ON PLATFORMS instead
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyFieldNotice44Resolution."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
devices = [ devices = [
@ -93,6 +79,7 @@ class VerifyFieldNotice44Resolution(AntaTest):
variants = ["-SSD-F", "-SSD-R", "-M-F", "-M-R", "-F", "-R"] variants = ["-SSD-F", "-SSD-R", "-M-F", "-M-R", "-F", "-R"]
model = command_output["modelName"] model = command_output["modelName"]
# TODO this list could be a regex
for variant in variants: for variant in variants:
model = model.replace(variant, "") model = model.replace(variant, "")
if model not in devices: if model not in devices:
@ -102,50 +89,33 @@ class VerifyFieldNotice44Resolution(AntaTest):
for component in command_output["details"]["components"]: for component in command_output["details"]["components"]:
if component["name"] == "Aboot": if component["name"] == "Aboot":
aboot_version = component["version"].split("-")[2] aboot_version = component["version"].split("-")[2]
break
else:
self.result.is_failure("Aboot component not found")
return
self.result.is_success() self.result.is_success()
incorrect_aboot_version = ( if aboot_version.startswith("4.0.") and int(aboot_version.split(".")[2]) < 7:
(aboot_version.startswith("4.0.") and int(aboot_version.split(".")[2]) < 7) self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
or (aboot_version.startswith("4.1.") and int(aboot_version.split(".")[2]) < 1) elif aboot_version.startswith("4.1.") and int(aboot_version.split(".")[2]) < 1:
or ( self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
(aboot_version.startswith("6.0.") and int(aboot_version.split(".")[2]) < 9) elif 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) self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
) elif 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): class VerifyFieldNotice72Resolution(AntaTest):
"""Verifies if the device is potentially exposed to Field Notice 72, and if the issue has been mitigated. """
Checks if the device is potentially exposed to Field Notice 72, and if the issue has been mitigated.
Reference: https://www.arista.com/en/support/advisories-notices/field-notice/17410-field-notice-0072 https://www.arista.com/en/support/advisories-notices/field-notice/17410-field-notice-0072
Expected Results
----------------
* Success: The test will pass if the device is not exposed to FN72 and the issue has been mitigated.
* Failure: The test will fail if the device is exposed to FN72 and the issue has not been mitigated.
Examples
--------
```yaml
anta.tests.field_notices:
- VerifyFieldNotice72Resolution:
```
""" """
description = "Verifies if the device is exposed to FN0072, and if the issue has been mitigated." name = "VerifyFieldNotice72Resolution"
categories: ClassVar[list[str]] = ["field notices"] description = "Verifies if the device has exposeure to FN72, and if the issue has been mitigated"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)] categories = ["field notices", "software"]
commands = [AntaCommand(command="show version detail")]
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) # TODO maybe implement ONLY ON PLATFORMS instead
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyFieldNotice72Resolution."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
devices = ["DCS-7280SR3-48YC8", "DCS-7280SR3K-48YC8"] devices = ["DCS-7280SR3-48YC8", "DCS-7280SR3K-48YC8"]
@ -181,7 +151,8 @@ class VerifyFieldNotice72Resolution(AntaTest):
self.result.is_skipped("Device not exposed") self.result.is_skipped("Device not exposed")
return return
# Because each of the if checks above will return if taken, we only run the long check if we get this far # Because each of the if checks above will return if taken, we only run the long
# check if we get this far
for entry in command_output["details"]["components"]: for entry in command_output["details"]["components"]:
if entry["name"] == "FixedSystemvrm1": if entry["name"] == "FixedSystemvrm1":
if int(entry["version"]) < 7: if int(entry["version"]) < 7:
@ -190,4 +161,5 @@ class VerifyFieldNotice72Resolution(AntaTest):
self.result.is_success("FN72 is mitigated") self.result.is_success("FN72 is mitigated")
return return
# We should never hit this point # We should never hit this point
self.result.is_failure("Error in running test - Component FixedSystemvrm1 not found in 'show version'") self.result.is_error(message="Error in running test - FixedSystemvrm1 not found")
return

View file

@ -1,192 +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.
"""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 pydantic import BaseModel
from anta.decorators import skip_on_platforms
from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_failed_logs
def validate_record_export(record_export: dict[str, str], tracker_info: dict[str, str]) -> str:
"""Validate the record export configuration against the tracker info.
Parameters
----------
record_export
The expected record export configuration.
tracker_info
The actual tracker info from the command output.
Returns
-------
str
A failure message if the record export configuration does not match, otherwise blank string.
"""
failed_log = ""
actual_export = {"inactive timeout": tracker_info.get("inactiveTimeout"), "interval": tracker_info.get("activeInterval")}
expected_export = {"inactive timeout": record_export.get("on_inactive_timeout"), "interval": record_export.get("on_interval")}
if actual_export != expected_export:
failed_log = get_failed_logs(expected_export, actual_export)
return failed_log
def validate_exporters(exporters: list[dict[str, str]], tracker_info: dict[str, str]) -> 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
-------
str
Failure message if any exporter configuration does not match.
"""
failed_log = ""
for exporter in exporters:
exporter_name = exporter["name"]
actual_exporter_info = tracker_info["exporters"].get(exporter_name)
if not actual_exporter_info:
failed_log += f"\nExporter `{exporter_name}` is not configured."
continue
expected_exporter_data = {"local interface": exporter["local_interface"], "template interval": exporter["template_interval"]}
actual_exporter_data = {"local interface": actual_exporter_info["localIntf"], "template interval": actual_exporter_info["templateInterval"]}
if expected_exporter_data != actual_exporter_data:
failed_msg = get_failed_logs(expected_exporter_data, actual_exporter_data)
failed_log += f"\nExporter `{exporter_name}`: {failed_msg}"
return failed_log
class VerifyHardwareFlowTrackerStatus(AntaTest):
"""Verifies if hardware flow tracking is running and an input tracker is active.
This test optionally verifies the tracker interval/timeout and exporter configuration.
Expected Results
----------------
* Success: The test will pass if hardware flow tracking is running and an input tracker is active.
* Failure: The test will fail if hardware flow tracking is not running, an input tracker is not active,
or the tracker interval/timeout and exporter configuration does not match the expected values.
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
```
"""
description = (
"Verifies if hardware flow tracking is running and an input tracker is active. Optionally verifies the tracker interval/timeout and exporter configuration."
)
categories: ClassVar[list[str]] = ["flow tracking"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show flow tracking hardware tracker {name}", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifyHardwareFlowTrackerStatus test."""
trackers: list[FlowTracker]
"""List of flow trackers to verify."""
class FlowTracker(BaseModel):
"""Detail of a flow tracker."""
name: str
"""Name of the flow tracker."""
record_export: RecordExport | None = None
"""Record export configuration for the flow tracker."""
exporters: list[Exporter] | None = None
"""List of exporters for the flow tracker."""
class RecordExport(BaseModel):
"""Record export configuration."""
on_inactive_timeout: int
"""Timeout in milliseconds for exporting records when inactive."""
on_interval: int
"""Interval in milliseconds for exporting records."""
class Exporter(BaseModel):
"""Detail of an exporter."""
name: str
"""Name of the exporter."""
local_interface: str
"""Local interface used by the exporter."""
template_interval: int
"""Template interval in milliseconds for the exporter."""
def render(self, template: AntaTemplate) -> list[AntaCommand]:
"""Render the template for each hardware tracker."""
return [template.render(name=tracker.name) for tracker in self.inputs.trackers]
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyHardwareFlowTrackerStatus."""
self.result.is_success()
for command, tracker_input in zip(self.instance_commands, self.inputs.trackers):
hardware_tracker_name = command.params.name
record_export = tracker_input.record_export.model_dump() if tracker_input.record_export else None
exporters = [exporter.model_dump() for exporter in tracker_input.exporters] if tracker_input.exporters else None
command_output = command.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
# Check if the input hardware tracker is configured
tracker_info = command_output["trackers"].get(hardware_tracker_name)
if not tracker_info:
self.result.is_failure(f"Hardware flow tracker `{hardware_tracker_name}` is not configured.")
continue
# Check if the input hardware tracker is active
if not tracker_info.get("active"):
self.result.is_failure(f"Hardware flow tracker `{hardware_tracker_name}` is not active.")
continue
# Check the input hardware tracker timeouts
failure_msg = ""
if record_export:
record_export_failure = validate_record_export(record_export, tracker_info)
if record_export_failure:
failure_msg += record_export_failure
# Check the input hardware tracker exporters' configuration
if exporters:
exporters_failure = validate_exporters(exporters, tracker_info)
if exporters_failure:
failure_msg += exporters_failure
if failure_msg:
self.result.is_failure(f"{hardware_tracker_name}: {failure_msg}\n")

View file

@ -1,79 +1,60 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to GreenT (Postcard Telemetry) tests.""" """
Test functions related to GreenT (Postcard Telemetry) in EOS
"""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyGreenTCounters(AntaTest): class VerifyGreenTCounters(AntaTest):
"""Verifies if the GreenT (GRE Encapsulated Telemetry) counters are incremented. """
Verifies whether GRE packets are sent.
Expected Results
----------------
* Success: The test will pass if the GreenT counters are incremented.
* Failure: The test will fail if the GreenT counters are not incremented.
Examples
--------
```yaml
anta.tests.greent:
- VerifyGreenTCounters:
```
Expected Results:
* success: if >0 gre packets are sent
* failure: if no gre packets are sent
""" """
description = "Verifies if the GreenT counters are incremented." name = "VerifyGreenTCounters"
categories: ClassVar[list[str]] = ["greent"] description = "Verifies if the greent counters are incremented."
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard counters", revision=1)] categories = ["greent"]
commands = [AntaCommand(command="show monitor telemetry postcard counters")]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyGreenTCounters."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if command_output["grePktSent"] > 0: if command_output["grePktSent"] > 0:
self.result.is_success() self.result.is_success()
else: else:
self.result.is_failure("GreenT counters are not incremented") self.result.is_failure("GRE packets are not sent")
class VerifyGreenT(AntaTest): class VerifyGreenT(AntaTest):
"""Verifies if a GreenT (GRE Encapsulated Telemetry) policy other than the default is created. """
Verifies whether GreenT policy is created.
Expected Results
----------------
* Success: The test will pass if a GreenT policy is created other than the default one.
* Failure: The test will fail if no other GreenT policy is created.
Examples
--------
```yaml
anta.tests.greent:
- VerifyGreenT:
```
Expected Results:
* success: if there exists any policy other than "default" policy.
* failure: if no policy is created.
""" """
description = "Verifies if a GreenT policy other than the default is created." name = "VerifyGreenT"
categories: ClassVar[list[str]] = ["greent"] description = "Verifies whether greent policy is created."
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard policy profile", revision=1)] categories = ["greent"]
commands = [AntaCommand(command="show monitor telemetry postcard policy profile")]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyGreenT."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
profiles = [profile for profile in command_output["profiles"] if profile != "default"] out = [f"{i} policy is created" for i in command_output["profiles"].keys() if "default" not in i]
if profiles: if len(out) > 0:
self.result.is_success() for i in out:
self.result.is_success(f"{i} policy is created")
else: else:
self.result.is_failure("No GreenT policy is created") self.result.is_failure("policy is not created")

View file

@ -1,54 +1,41 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to the hardware or environment tests.""" """
Test functions related to the hardware or environment
"""
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar # Need to keep List for pydantic in python 3.8
from typing import List
from anta.decorators import skip_on_platforms from anta.decorators import skip_on_platforms
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyTransceiversManufacturers(AntaTest): class VerifyTransceiversManufacturers(AntaTest):
"""Verifies if all the transceivers come from approved manufacturers. """
This test verifies if all the transceivers come from approved manufacturers.
Expected Results Expected Results:
---------------- * success: The test will pass if all transceivers are from approved manufacturers.
* Success: The test will pass if all transceivers are from approved manufacturers. * failure: The test will fail if some transceivers are from unapproved manufacturers.
* Failure: The test will fail if some transceivers are from unapproved manufacturers.
Examples
--------
```yaml
anta.tests.hardware:
- VerifyTransceiversManufacturers:
manufacturers:
- Not Present
- Arista Networks
- Arastra, Inc.
```
""" """
categories: ClassVar[list[str]] = ["hardware"] name = "VerifyTransceiversManufacturers"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show inventory", revision=2)] description = "Verifies if all transceivers come from approved manufacturers."
categories = ["hardware"]
commands = [AntaCommand(command="show inventory", ofmt="json")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyTransceiversManufacturers test.""" manufacturers: List[str]
"""List of approved transceivers manufacturers"""
manufacturers: list[str] @skip_on_platforms(["cEOSLab", "vEOS-lab"])
"""List of approved transceivers manufacturers."""
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyTransceiversManufacturers."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
wrong_manufacturers = { wrong_manufacturers = {
interface: value["mfgName"] for interface, value in command_output["xcvrSlots"].items() if value["mfgName"] not in self.inputs.manufacturers interface: value["mfgName"] for interface, value in command_output["xcvrSlots"].items() if value["mfgName"] not in self.inputs.manufacturers
@ -60,30 +47,24 @@ class VerifyTransceiversManufacturers(AntaTest):
class VerifyTemperature(AntaTest): class VerifyTemperature(AntaTest):
"""Verifies if the device temperature is within acceptable limits. """
This test verifies if the device temperature is within acceptable limits.
Expected Results Expected Results:
---------------- * success: The test will pass if the device temperature is currently OK: 'temperatureOk'.
* Success: The test will pass if the device temperature is currently OK: 'temperatureOk'. * failure: The test will fail if the device temperature is NOT OK.
* Failure: The test will fail if the device temperature is NOT OK.
Examples
--------
```yaml
anta.tests.hardware:
- VerifyTemperature:
```
""" """
categories: ClassVar[list[str]] = ["hardware"] name = "VerifyTemperature"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature", revision=1)] description = "Verifies the device temperature."
categories = ["hardware"]
commands = [AntaCommand(command="show system environment temperature", ofmt="json")]
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) @skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyTemperature."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
temperature_status = command_output.get("systemStatus", "") temperature_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else ""
if temperature_status == "temperatureOk": if temperature_status == "temperatureOk":
self.result.is_success() self.result.is_success()
else: else:
@ -91,30 +72,24 @@ class VerifyTemperature(AntaTest):
class VerifyTransceiversTemperature(AntaTest): class VerifyTransceiversTemperature(AntaTest):
"""Verifies if all the transceivers are operating at an acceptable temperature. """
This test verifies if all the transceivers are operating at an acceptable temperature.
Expected Results Expected Results:
---------------- * success: The test will pass if all transceivers status are OK: 'ok'.
* Success: The test will pass if all transceivers status are OK: 'ok'. * failure: The test will fail if some transceivers are NOT OK.
* Failure: The test will fail if some transceivers are NOT OK.
Examples
--------
```yaml
anta.tests.hardware:
- VerifyTransceiversTemperature:
```
""" """
categories: ClassVar[list[str]] = ["hardware"] name = "VerifyTransceiversTemperature"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature transceiver", revision=1)] description = "Verifies the transceivers temperature."
categories = ["hardware"]
commands = [AntaCommand(command="show system environment temperature transceiver", ofmt="json")]
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) @skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyTransceiversTemperature."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
sensors = command_output.get("tempSensors", "") sensors = command_output["tempSensors"] if "tempSensors" in command_output.keys() else ""
wrong_sensors = { wrong_sensors = {
sensor["name"]: { sensor["name"]: {
"hwStatus": sensor["hwStatus"], "hwStatus": sensor["hwStatus"],
@ -130,68 +105,50 @@ class VerifyTransceiversTemperature(AntaTest):
class VerifyEnvironmentSystemCooling(AntaTest): class VerifyEnvironmentSystemCooling(AntaTest):
"""Verifies the device's system cooling status. """
This test verifies the device's system cooling.
Expected Results Expected Results:
---------------- * success: The test will pass if the system cooling status is OK: 'coolingOk'.
* Success: The test will pass if the system cooling status is OK: 'coolingOk'. * failure: The test will fail if the system cooling status is NOT OK.
* Failure: The test will fail if the system cooling status is NOT OK.
Examples
--------
```yaml
anta.tests.hardware:
- VerifyEnvironmentSystemCooling:
```
""" """
categories: ClassVar[list[str]] = ["hardware"] name = "VerifyEnvironmentSystemCooling"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", revision=1)] description = "Verifies the system cooling status."
categories = ["hardware"]
commands = [AntaCommand(command="show system environment cooling", ofmt="json")]
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) @skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyEnvironmentSystemCooling."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
sys_status = command_output.get("systemStatus", "") sys_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else ""
self.result.is_success() self.result.is_success()
if sys_status != "coolingOk": 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 is not OK: '{sys_status}'")
class VerifyEnvironmentCooling(AntaTest): class VerifyEnvironmentCooling(AntaTest):
"""Verifies the status of power supply fans and all fan trays. """
This test verifies the fans status.
Expected Results Expected Results:
---------------- * success: The test will pass if the fans status are within the accepted states list.
* Success: The test will pass if the fans status are within the accepted states list. * failure: The test will fail if some fans status is not within the accepted states list.
* Failure: The test will fail if some fans status is not within the accepted states list.
Examples
--------
```yaml
anta.tests.hardware:
- VerifyEnvironmentCooling:
states:
- ok
```
""" """
name = "VerifyEnvironmentCooling" name = "VerifyEnvironmentCooling"
description = "Verifies the status of power supply fans and all fan trays." description = "Verifies the status of power supply fans and all fan trays."
categories: ClassVar[list[str]] = ["hardware"] categories = ["hardware"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", revision=1)] commands = [AntaCommand(command="show system environment cooling", ofmt="json")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyEnvironmentCooling test.""" states: List[str]
"""Accepted states list for fan status"""
states: list[str] @skip_on_platforms(["cEOSLab", "vEOS-lab"])
"""List of accepted states of fan status."""
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyEnvironmentCooling."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
self.result.is_success() self.result.is_success()
# First go through power supplies fans # First go through power supplies fans
@ -207,38 +164,28 @@ class VerifyEnvironmentCooling(AntaTest):
class VerifyEnvironmentPower(AntaTest): class VerifyEnvironmentPower(AntaTest):
"""Verifies the power supplies status. """
This test verifies the power supplies status.
Expected Results Expected Results:
---------------- * success: The test will pass if the power supplies status are within the accepted states list.
* 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.
* Failure: The test will fail if some power supplies status is not within the accepted states list.
Examples
--------
```yaml
anta.tests.hardware:
- VerifyEnvironmentPower:
states:
- ok
```
""" """
categories: ClassVar[list[str]] = ["hardware"] name = "VerifyEnvironmentPower"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment power", revision=1)] description = "Verifies the power supplies status."
categories = ["hardware"]
commands = [AntaCommand(command="show system environment power", ofmt="json")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyEnvironmentPower test.""" states: List[str]
"""Accepted states list for power supplies status"""
states: list[str] @skip_on_platforms(["cEOSLab", "vEOS-lab"])
"""List of accepted states list of power supplies status."""
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyEnvironmentPower."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
power_supplies = command_output.get("powerSupplies", "{}") power_supplies = command_output["powerSupplies"] if "powerSupplies" in command_output.keys() else "{}"
wrong_power_supplies = { wrong_power_supplies = {
powersupply: {"state": value["state"]} for powersupply, value in dict(power_supplies).items() if value["state"] not in self.inputs.states powersupply: {"state": value["state"]} for powersupply, value in dict(power_supplies).items() if value["state"] not in self.inputs.states
} }
@ -249,31 +196,24 @@ class VerifyEnvironmentPower(AntaTest):
class VerifyAdverseDrops(AntaTest): class VerifyAdverseDrops(AntaTest):
"""Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches (Arad/Jericho chips). """
This test verifies if there are no adverse drops on DCS7280E and DCS7500E.
Expected Results Expected Results:
---------------- * success: The test will pass if there are no adverse drops.
* Success: The test will pass if there are no adverse drops. * failure: The test will fail if there are adverse drops.
* Failure: The test will fail if there are adverse drops.
Examples
--------
```yaml
anta.tests.hardware:
- VerifyAdverseDrops:
```
""" """
description = "Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches." name = "VerifyAdverseDrops"
categories: ClassVar[list[str]] = ["hardware"] description = "Verifies there are no adverse drops on DCS7280E and DCS7500E"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware counter drop", revision=1)] categories = ["hardware"]
commands = [AntaCommand(command="show hardware counter drop", ofmt="json")]
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) @skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyAdverseDrops."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
total_adverse_drop = command_output.get("totalAdverseDrops", "") total_adverse_drop = command_output["totalAdverseDrops"] if "totalAdverseDrops" in command_output.keys() else ""
if total_adverse_drop == 0: if total_adverse_drop == 0:
self.result.is_success() self.result.is_success()
else: else:

File diff suppressed because it is too large Load diff

View file

@ -1,46 +1,34 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to LANZ tests.""" """
Test functions related to LANZ
"""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from anta.decorators import skip_on_platforms
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyLANZ(AntaTest): class VerifyLANZ(AntaTest):
"""Verifies if LANZ (Latency Analyzer) is enabled. """
Verifies if LANZ is enabled
Expected Results Expected results:
---------------- * success: the test will pass if lanz is enabled
* Success: The test will pass if LANZ is enabled. * failure: the test will fail if lanz is disabled
* Failure: The test will fail if LANZ is disabled.
Examples
--------
```yaml
anta.tests.lanz:
- VerifyLANZ:
```
""" """
name = "VerifyLANZ"
description = "Verifies if LANZ is enabled." description = "Verifies if LANZ is enabled."
categories: ClassVar[list[str]] = ["lanz"] categories = ["lanz"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show queue-monitor length status", revision=1)] commands = [AntaCommand(command="show queue-monitor length status")]
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyLANZ."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if command_output["lanzEnabled"] is not True: if command_output["lanzEnabled"] is not True:
self.result.is_failure("LANZ is not enabled") self.result.is_failure("LANZ is not enabled")
else: else:
self.result.is_success() self.result.is_success("LANZ is enabled")

View file

@ -1,73 +1,57 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to the EOS various logging tests.
NOTE: The EOS command `show logging` does not support JSON output format.
""" """
Test functions related to the EOS various logging settings
NOTE: 'show logging' does not support json output yet
"""
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
import logging
import re import re
from ipaddress import IPv4Address from ipaddress import IPv4Address
from typing import TYPE_CHECKING, ClassVar
# Need to keep List for pydantic in python 3.8
from typing import List
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
import logging
from anta.models import AntaTemplate
def _get_logging_states(logger: logging.Logger, command_output: str) -> str: 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. """
Parse "show logging" output and gets operational logging states used
Parameters in the tests in this module.
----------
logger
The logger object.
command_output
The `show logging` output.
Returns
-------
str
The operational logging states.
Args:
command_output: The 'show logging' output
""" """
log_states = command_output.partition("\n\nExternal configuration:")[0] log_states = command_output.partition("\n\nExternal configuration:")[0]
logger.debug("Device logging states:\n%s", log_states) logger.debug(f"Device logging states:\n{log_states}")
return log_states return log_states
class VerifyLoggingPersistent(AntaTest): class VerifyLoggingPersistent(AntaTest):
"""Verifies if logging persistent is enabled and logs are saved in flash. """
Verifies if logging persistent is enabled and logs are saved in flash.
Expected Results Expected Results:
---------------- * success: The test will pass if logging persistent is enabled and logs are in flash.
* Success: The test will pass if logging persistent is enabled and logs are in flash. * failure: The test will fail if logging persistent is disabled or no logs are saved in flash.
* Failure: The test will fail if logging persistent is disabled or no logs are saved in flash.
Examples
--------
```yaml
anta.tests.logging:
- VerifyLoggingPersistent:
```
""" """
categories: ClassVar[list[str]] = ["logging"] name = "VerifyLoggingPersistent"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ description = "Verifies if logging persistent is enabled and logs are saved in flash."
categories = ["logging"]
commands = [
AntaCommand(command="show logging", ofmt="text"), AntaCommand(command="show logging", ofmt="text"),
AntaCommand(command="dir flash:/persist/messages", ofmt="text"), AntaCommand(command="dir flash:/persist/messages", ofmt="text"),
] ]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyLoggingPersistent."""
self.result.is_success() self.result.is_success()
log_output = self.instance_commands[0].text_output log_output = self.instance_commands[0].text_output
dir_flash_output = self.instance_commands[1].text_output dir_flash_output = self.instance_commands[1].text_output
@ -81,37 +65,27 @@ class VerifyLoggingPersistent(AntaTest):
class VerifyLoggingSourceIntf(AntaTest): class VerifyLoggingSourceIntf(AntaTest):
"""Verifies logging source-interface for a specified VRF. """
Verifies logging source-interface for a specified VRF.
Expected Results Expected Results:
---------------- * success: The test will pass if the provided logging source-interface is configured in the specified VRF.
* Success: The test will pass if the provided logging source-interface is configured in the specified VRF. * failure: The test will fail if the provided logging source-interface is NOT configured in the specified VRF.
* Failure: The test will fail if the provided logging source-interface is NOT configured in the specified VRF.
Examples
--------
```yaml
anta.tests.logging:
- VerifyLoggingSourceIntf:
interface: Management0
vrf: default
```
""" """
categories: ClassVar[list[str]] = ["logging"] name = "VerifyLoggingSourceInt"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")] description = "Verifies logging source-interface for a specified VRF."
categories = ["logging"]
class Input(AntaTest.Input): commands = [AntaCommand(command="show logging", ofmt="text")]
"""Input model for the VerifyLoggingSourceIntf test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
interface: str interface: str
"""Source-interface to use as source IP of log messages.""" """Source-interface to use as source IP of log messages"""
vrf: str = "default" vrf: str = "default"
"""The name of the VRF to transport log messages. Defaults to `default`.""" """The name of the VRF to transport log messages"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyLoggingSourceIntf."""
output = self.instance_commands[0].text_output output = self.instance_commands[0].text_output
pattern = rf"Logging source-interface '{self.inputs.interface}'.*VRF {self.inputs.vrf}" pattern = rf"Logging source-interface '{self.inputs.interface}'.*VRF {self.inputs.vrf}"
if re.search(pattern, _get_logging_states(self.logger, output)): if re.search(pattern, _get_logging_states(self.logger, output)):
@ -121,43 +95,31 @@ class VerifyLoggingSourceIntf(AntaTest):
class VerifyLoggingHosts(AntaTest): class VerifyLoggingHosts(AntaTest):
"""Verifies logging hosts (syslog servers) for a specified VRF. """
Verifies logging hosts (syslog servers) for a specified VRF.
Expected Results Expected Results:
---------------- * success: The test will pass if the provided syslog servers are configured in the specified VRF.
* Success: The test will pass if the provided syslog servers are configured in the specified VRF. * failure: The test will fail if the provided syslog servers are NOT configured in the specified VRF.
* Failure: The test will fail if the provided syslog servers are NOT configured in the specified VRF.
Examples
--------
```yaml
anta.tests.logging:
- VerifyLoggingHosts:
hosts:
- 1.1.1.1
- 2.2.2.2
vrf: default
```
""" """
categories: ClassVar[list[str]] = ["logging"] name = "VerifyLoggingHosts"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")] description = "Verifies logging hosts (syslog servers) for a specified VRF."
categories = ["logging"]
commands = [AntaCommand(command="show logging", ofmt="text")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyLoggingHosts test.""" hosts: List[IPv4Address]
"""List of hosts (syslog servers) IP addresses"""
hosts: list[IPv4Address]
"""List of hosts (syslog servers) IP addresses."""
vrf: str = "default" vrf: str = "default"
"""The name of the VRF to transport log messages. Defaults to `default`.""" """The name of the VRF to transport log messages"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyLoggingHosts."""
output = self.instance_commands[0].text_output output = self.instance_commands[0].text_output
not_configured = [] not_configured = []
for host in self.inputs.hosts: for host in self.inputs.hosts:
pattern = rf"Logging to '{host!s}'.*VRF {self.inputs.vrf}" pattern = rf"Logging to '{str(host)}'.*VRF {self.inputs.vrf}"
if not re.search(pattern, _get_logging_states(self.logger, output)): if not re.search(pattern, _get_logging_states(self.logger, output)):
not_configured.append(str(host)) not_configured.append(str(host))
@ -168,42 +130,24 @@ class VerifyLoggingHosts(AntaTest):
class VerifyLoggingLogsGeneration(AntaTest): class VerifyLoggingLogsGeneration(AntaTest):
"""Verifies if logs are generated. """
Verifies if logs are generated.
This test performs the following checks: Expected Results:
* success: The test will pass if logs are generated.
1. Sends a test log message at the **informational** level * failure: The test will fail if logs are NOT generated.
2. Retrieves the most recent logs (last 30 seconds)
3. Verifies that the test message was successfully logged
!!! warning
EOS logging buffer should be set to severity level `informational` or higher for this test to work.
Expected Results
----------------
* 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:
```
""" """
categories: ClassVar[list[str]] = ["logging"] name = "VerifyLoggingLogsGeneration"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ description = "Verifies if logs are generated."
AntaCommand(command="send log level informational message ANTA VerifyLoggingLogsGeneration validation", ofmt="text"), categories = ["logging"]
commands = [
AntaCommand(command="send log level informational message ANTA VerifyLoggingLogsGeneration validation"),
AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False), AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
] ]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyLoggingLogsGeneration."""
log_pattern = r"ANTA VerifyLoggingLogsGeneration validation" log_pattern = r"ANTA VerifyLoggingLogsGeneration validation"
output = self.instance_commands[1].text_output output = self.instance_commands[1].text_output
lines = output.strip().split("\n")[::-1] lines = output.strip().split("\n")[::-1]
@ -215,44 +159,25 @@ class VerifyLoggingLogsGeneration(AntaTest):
class VerifyLoggingHostname(AntaTest): class VerifyLoggingHostname(AntaTest):
"""Verifies if logs are generated with the device FQDN. """
Verifies if logs are generated with the device FQDN.
This test performs the following checks: Expected Results:
* success: The test will pass if logs are generated with the device FQDN.
1. Retrieves the device's configured FQDN * failure: The test will fail if logs are NOT generated with the device FQDN.
2. Sends a test log message at the **informational** level
3. Retrieves the most recent logs (last 30 seconds)
4. Verifies that the test message includes the complete FQDN of the device
!!! warning
EOS logging buffer should be set to severity level `informational` or higher for this test to work.
Expected Results
----------------
* 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:
```
""" """
categories: ClassVar[list[str]] = ["logging"] name = "VerifyLoggingHostname"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ description = "Verifies if logs are generated with the device FQDN."
AntaCommand(command="show hostname", revision=1), categories = ["logging"]
AntaCommand(command="send log level informational message ANTA VerifyLoggingHostname validation", ofmt="text"), commands = [
AntaCommand(command="show hostname"),
AntaCommand(command="send log level informational message ANTA VerifyLoggingHostname validation"),
AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False), AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
] ]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyLoggingHostname."""
output_hostname = self.instance_commands[0].json_output output_hostname = self.instance_commands[0].json_output
output_logging = self.instance_commands[2].text_output output_logging = self.instance_commands[2].text_output
fqdn = output_hostname["fqdn"] fqdn = output_hostname["fqdn"]
@ -270,46 +195,26 @@ class VerifyLoggingHostname(AntaTest):
class VerifyLoggingTimestamp(AntaTest): class VerifyLoggingTimestamp(AntaTest):
"""Verifies if logs are generated with the appropriate timestamp. """
Verifies if logs are generated with the approprate timestamp.
This test performs the following checks: Expected Results:
* success: The test will pass if logs are generated with the appropriated timestamp.
1. Sends a test log message at the **informational** level * failure: The test will fail if logs are NOT generated with the appropriated timestamp.
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
!!! warning
EOS logging buffer should be set to severity level `informational` or higher for this test to work.
Expected Results
----------------
* 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:
```
""" """
categories: ClassVar[list[str]] = ["logging"] name = "VerifyLoggingTimestamp"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ description = "Verifies if logs are generated with the appropriate timestamp."
AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation", ofmt="text"), categories = ["logging"]
commands = [
AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation"),
AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False), AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
] ]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyLoggingTimestamp."""
log_pattern = r"ANTA VerifyLoggingTimestamp validation" 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 output = self.instance_commands[1].text_output
lines = output.strip().split("\n")[::-1] lines = output.strip().split("\n")[::-1]
last_line_with_pattern = "" last_line_with_pattern = ""
@ -324,27 +229,21 @@ class VerifyLoggingTimestamp(AntaTest):
class VerifyLoggingAccounting(AntaTest): class VerifyLoggingAccounting(AntaTest):
"""Verifies if AAA accounting logs are generated. """
Verifies if AAA accounting logs are generated.
Expected Results Expected Results:
---------------- * success: The test will pass if AAA accounting logs are generated.
* Success: The test will pass if AAA accounting logs are generated. * failure: The test will fail if AAA accounting logs are NOT generated.
* Failure: The test will fail if AAA accounting logs are NOT generated.
Examples
--------
```yaml
anta.tests.logging:
- VerifyLoggingAccounting:
```
""" """
categories: ClassVar[list[str]] = ["logging"] name = "VerifyLoggingAccounting"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")] description = "Verifies if AAA accounting logs are generated."
categories = ["logging"]
commands = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyLoggingAccounting."""
pattern = r"cmd=show aaa accounting logs" pattern = r"cmd=show aaa accounting logs"
output = self.instance_commands[0].text_output output = self.instance_commands[0].text_output
if re.search(pattern, output): if re.search(pattern, output):
@ -354,27 +253,24 @@ class VerifyLoggingAccounting(AntaTest):
class VerifyLoggingErrors(AntaTest): class VerifyLoggingErrors(AntaTest):
"""Verifies there are no syslog messages with a severity of ERRORS or higher. """
This test verifies there are no syslog messages with a severity of ERRORS or higher.
Expected Results Expected Results:
---------------- * success: The test will pass if there are NO syslog messages with a severity of ERRORS or higher.
* Success: The test will pass if there are NO syslog messages with a severity of ERRORS or higher. * failure: The test will fail if ERRORS or higher syslog messages are present.
* Failure: The test will fail if ERRORS or higher syslog messages are present.
Examples
--------
```yaml
anta.tests.logging:
- VerifyLoggingErrors:
```
""" """
categories: ClassVar[list[str]] = ["logging"] name = "VerifyLoggingWarning"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging threshold errors", ofmt="text")] description = "This test verifies there are no syslog messages with a severity of ERRORS or higher."
categories = ["logging"]
commands = [AntaCommand(command="show logging threshold errors", ofmt="text")]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyLoggingErrors.""" """
Run VerifyLoggingWarning validation
"""
command_output = self.instance_commands[0].text_output command_output = self.instance_commands[0].text_output
if len(command_output) == 0: if len(command_output) == 0:

View file

@ -1,47 +1,39 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to Multi-chassis Link Aggregation (MLAG) tests.""" """
Test functions related to Multi-chassis Link Aggregation (MLAG)
"""
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar from pydantic import conint
from anta.custom_types import MlagPriority, PositiveInteger from anta.custom_types import MlagPriority
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
from anta.tools import get_value from anta.tools.get_value import get_value
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyMlagStatus(AntaTest): class VerifyMlagStatus(AntaTest):
"""Verifies the health status of the MLAG configuration. """
This test verifies the health status of the MLAG configuration.
Expected Results Expected Results:
---------------- * success: The test will pass if the MLAG state is 'active', negotiation status is 'connected',
* Success: The test will pass if the MLAG state is 'active', negotiation status is 'connected',
peer-link status and local interface status are 'up'. 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', * 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'. peer-link status or local interface status are not 'up'.
* Skipped: The test will be skipped if MLAG is 'disabled'. * skipped: The test will be skipped if MLAG is 'disabled'.
Examples
--------
```yaml
anta.tests.mlag:
- VerifyMlagStatus:
```
""" """
categories: ClassVar[list[str]] = ["mlag"] name = "VerifyMlagStatus"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)] description = "Verifies the health status of the MLAG configuration."
categories = ["mlag"]
commands = [AntaCommand(command="show mlag", ofmt="json")]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyMlagStatus."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if command_output["state"] == "disabled": if command_output["state"] == "disabled":
self.result.is_skipped("MLAG is disabled") self.result.is_skipped("MLAG is disabled")
@ -60,28 +52,22 @@ class VerifyMlagStatus(AntaTest):
class VerifyMlagInterfaces(AntaTest): class VerifyMlagInterfaces(AntaTest):
"""Verifies there are no inactive or active-partial MLAG ports. """
This test verifies there are no inactive or active-partial MLAG ports.
Expected Results Expected Results:
---------------- * success: The test will pass if there are NO inactive or active-partial MLAG ports.
* Success: The test will pass if there are NO inactive or active-partial MLAG ports. * failure: The test will fail if there are inactive or active-partial MLAG ports.
* Failure: The test will fail if there are inactive or active-partial MLAG ports. * skipped: The test will be skipped if MLAG is 'disabled'.
* Skipped: The test will be skipped if MLAG is 'disabled'.
Examples
--------
```yaml
anta.tests.mlag:
- VerifyMlagInterfaces:
```
""" """
categories: ClassVar[list[str]] = ["mlag"] name = "VerifyMlagInterfaces"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)] description = "Verifies there are no inactive or active-partial MLAG ports."
categories = ["mlag"]
commands = [AntaCommand(command="show mlag", ofmt="json")]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyMlagInterfaces."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if command_output["state"] == "disabled": if command_output["state"] == "disabled":
self.result.is_skipped("MLAG is disabled") self.result.is_skipped("MLAG is disabled")
@ -93,31 +79,28 @@ class VerifyMlagInterfaces(AntaTest):
class VerifyMlagConfigSanity(AntaTest): class VerifyMlagConfigSanity(AntaTest):
"""Verifies there are no MLAG config-sanity inconsistencies. """
This test verifies there are no MLAG config-sanity inconsistencies.
Expected Results Expected Results:
---------------- * success: The test will pass if there are NO MLAG config-sanity inconsistencies.
* Success: The test will pass if there are NO MLAG config-sanity inconsistencies. * failure: The test will fail if there are MLAG config-sanity inconsistencies.
* Failure: The test will fail if there are MLAG config-sanity inconsistencies. * skipped: The test will be skipped if MLAG is 'disabled'.
* Skipped: The test will be skipped if MLAG is 'disabled'. * error: The test will give an error if 'mlagActive' is not found in the JSON response.
* Error: The test will give an error if 'mlagActive' is not found in the JSON response.
Examples
--------
```yaml
anta.tests.mlag:
- VerifyMlagConfigSanity:
```
""" """
categories: ClassVar[list[str]] = ["mlag"] name = "VerifyMlagConfigSanity"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag config-sanity", revision=1)] description = "Verifies there are no MLAG config-sanity inconsistencies."
categories = ["mlag"]
commands = [AntaCommand(command="show mlag config-sanity", ofmt="json")]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyMlagConfigSanity."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if command_output["mlagActive"] is False: 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:
self.result.is_skipped("MLAG is disabled") self.result.is_skipped("MLAG is disabled")
return return
keys_to_verify = ["globalConfiguration", "interfaceConfiguration"] keys_to_verify = ["globalConfiguration", "interfaceConfiguration"]
@ -129,38 +112,28 @@ class VerifyMlagConfigSanity(AntaTest):
class VerifyMlagReloadDelay(AntaTest): class VerifyMlagReloadDelay(AntaTest):
"""Verifies the reload-delay parameters of the MLAG configuration. """
This test verifies the reload-delay parameters of the MLAG configuration.
Expected Results Expected Results:
---------------- * success: The test will pass if the reload-delay parameters are configured properly.
* Success: The test will pass if the reload-delay parameters are configured properly. * failure: The test will fail if the reload-delay parameters are NOT configured properly.
* Failure: The test will fail if the reload-delay parameters are NOT configured properly. * skipped: The test will be skipped if MLAG is 'disabled'.
* Skipped: The test will be skipped if MLAG is 'disabled'.
Examples
--------
```yaml
anta.tests.mlag:
- VerifyMlagReloadDelay:
reload_delay: 300
reload_delay_non_mlag: 330
```
""" """
categories: ClassVar[list[str]] = ["mlag"] name = "VerifyMlagReloadDelay"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)] description = "Verifies the MLAG reload-delay parameters."
categories = ["mlag"]
commands = [AntaCommand(command="show mlag", ofmt="json")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyMlagReloadDelay test.""" reload_delay: conint(ge=0) # type: ignore
"""Delay (seconds) after reboot until non peer-link ports that are part of an MLAG are enabled"""
reload_delay: PositiveInteger reload_delay_non_mlag: conint(ge=0) # type: ignore
"""Delay (seconds) after reboot until non peer-link ports that are part of an MLAG are enabled.""" """Delay (seconds) after reboot until ports that are not part of an MLAG are enabled"""
reload_delay_non_mlag: PositiveInteger
"""Delay (seconds) after reboot until ports that are not part of an MLAG are enabled."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyMlagReloadDelay."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if command_output["state"] == "disabled": if command_output["state"] == "disabled":
self.result.is_skipped("MLAG is disabled") self.result.is_skipped("MLAG is disabled")
@ -175,45 +148,32 @@ class VerifyMlagReloadDelay(AntaTest):
class VerifyMlagDualPrimary(AntaTest): class VerifyMlagDualPrimary(AntaTest):
"""Verifies the dual-primary detection and its parameters of the MLAG configuration. """
This test verifies the dual-primary detection and its parameters of the MLAG configuration.
Expected Results Expected Results:
---------------- * success: The test will pass if the dual-primary detection is enabled and its parameters are configured properly.
* Success: The test will pass if the dual-primary detection is enabled and its parameters are configured properly. * failure: The test will fail if the dual-primary detection is NOT enabled or its parameters are NOT configured properly.
* Failure: The test will fail if the dual-primary detection is NOT enabled or its parameters are NOT configured properly. * skipped: The test will be skipped if MLAG is 'disabled'.
* Skipped: The test will be skipped if MLAG is 'disabled'.
Examples
--------
```yaml
anta.tests.mlag:
- VerifyMlagDualPrimary:
detection_delay: 200
errdisabled: True
recovery_delay: 60
recovery_delay_non_mlag: 0
```
""" """
name = "VerifyMlagDualPrimary"
description = "Verifies the MLAG dual-primary detection parameters." description = "Verifies the MLAG dual-primary detection parameters."
categories: ClassVar[list[str]] = ["mlag"] categories = ["mlag"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)] commands = [AntaCommand(command="show mlag detail", ofmt="json")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyMlagDualPrimary test.""" detection_delay: conint(ge=0) # type: ignore
"""Delay detection (seconds)"""
detection_delay: PositiveInteger
"""Delay detection (seconds)."""
errdisabled: bool = False errdisabled: bool = False
"""Errdisabled all interfaces when dual-primary is detected.""" """Errdisabled all interfaces when dual-primary is detected"""
recovery_delay: PositiveInteger recovery_delay: conint(ge=0) # type: ignore
"""Delay (seconds) after dual-primary detection resolves until non peer-link ports that are part of an MLAG are enabled.""" """Delay (seconds) after dual-primary detection resolves until non peer-link ports that are part of an MLAG are enabled"""
recovery_delay_non_mlag: PositiveInteger recovery_delay_non_mlag: conint(ge=0) # type: ignore
"""Delay (seconds) after dual-primary detection resolves until ports that are not part of an MLAG are enabled.""" """Delay (seconds) after dual-primary detection resolves until ports that are not part of an MLAG are enabled"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyMlagDualPrimary."""
errdisabled_action = "errdisableAllInterfaces" if self.inputs.errdisabled else "none" errdisabled_action = "errdisableAllInterfaces" if self.inputs.errdisabled else "none"
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if command_output["state"] == "disabled": if command_output["state"] == "disabled":
@ -236,36 +196,28 @@ class VerifyMlagDualPrimary(AntaTest):
class VerifyMlagPrimaryPriority(AntaTest): class VerifyMlagPrimaryPriority(AntaTest):
"""Verify the MLAG (Multi-Chassis Link Aggregation) primary priority. """
Test class to verify the MLAG (Multi-Chassis Link Aggregation) primary priority.
Expected Results Expected Results:
----------------
* Success: The test will pass if the MLAG state is set as 'primary' and the priority matches the input. * Success: The test will pass if the MLAG state is set as 'primary' and the priority matches the input.
* Failure: The test will fail if the MLAG state is not 'primary' or the priority doesn't match the input. * Failure: The test will fail if the MLAG state is not 'primary' or the priority doesn't match the input.
* Skipped: The test will be skipped if MLAG is 'disabled'. * Skipped: The test will be skipped if MLAG is 'disabled'.
Examples
--------
```yaml
anta.tests.mlag:
- VerifyMlagPrimaryPriority:
primary_priority: 3276
```
""" """
name = "VerifyMlagPrimaryPriority"
description = "Verifies the configuration of the MLAG primary priority." description = "Verifies the configuration of the MLAG primary priority."
categories: ClassVar[list[str]] = ["mlag"] categories = ["mlag"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)] commands = [AntaCommand(command="show mlag detail")]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyMlagPrimaryPriority test.""" """Inputs for the VerifyMlagPrimaryPriority test."""
primary_priority: MlagPriority primary_priority: MlagPriority
"""The expected MLAG primary priority.""" """The expected MLAG primary priority."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyMlagPrimaryPriority."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
self.result.is_success() self.result.is_success()
# Skip the test if MLAG is disabled # Skip the test if MLAG is disabled
@ -283,5 +235,5 @@ class VerifyMlagPrimaryPriority(AntaTest):
# Check primary priority # Check primary priority
if primary_priority != self.inputs.primary_priority: if primary_priority != self.inputs.primary_priority:
self.result.is_failure( self.result.is_failure(
f"The primary priority does not match expected. Expected `{self.inputs.primary_priority}`, but found `{primary_priority}` instead.", f"The primary priority does not match expected. Expected `{self.inputs.primary_priority}`, but found `{primary_priority}` instead."
) )

View file

@ -1,52 +1,36 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to multicast and IGMP tests.""" """
Test functions related to multicast
"""
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar # Need to keep Dict for pydantic in python 3.8
from typing import Dict
from anta.custom_types import Vlan from anta.custom_types import Vlan
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyIGMPSnoopingVlans(AntaTest): class VerifyIGMPSnoopingVlans(AntaTest):
"""Verifies the IGMP snooping status for the provided VLANs. """
Verifies the IGMP snooping configuration for some VLANs.
Expected Results
----------------
* Success: The test will pass if the IGMP snooping status matches the expected status for the provided VLANs.
* Failure: The test will fail if the IGMP snooping status does not match the expected status for the provided VLANs.
Examples
--------
```yaml
anta.tests.multicast:
- VerifyIGMPSnoopingVlans:
vlans:
10: False
12: False
```
""" """
categories: ClassVar[list[str]] = ["multicast"] name = "VerifyIGMPSnoopingVlans"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)] description = "Verifies the IGMP snooping configuration for some VLANs."
categories = ["multicast", "igmp"]
commands = [AntaCommand(command="show ip igmp snooping")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyIGMPSnoopingVlans test.""" vlans: Dict[Vlan, bool]
"""Dictionary of VLANs with associated IGMP configuration status (True=enabled, False=disabled)"""
vlans: dict[Vlan, bool]
"""Dictionary with VLAN ID and whether IGMP snooping must be enabled (True) or disabled (False)."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyIGMPSnoopingVlans."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
self.result.is_success() self.result.is_success()
for vlan, enabled in self.inputs.vlans.items(): for vlan, enabled in self.inputs.vlans.items():
@ -60,34 +44,21 @@ class VerifyIGMPSnoopingVlans(AntaTest):
class VerifyIGMPSnoopingGlobal(AntaTest): class VerifyIGMPSnoopingGlobal(AntaTest):
"""Verifies the IGMP snooping global status. """
Verifies the IGMP snooping global configuration.
Expected Results
----------------
* Success: The test will pass if the IGMP snooping global status matches the expected status.
* Failure: The test will fail if the IGMP snooping global status does not match the expected status.
Examples
--------
```yaml
anta.tests.multicast:
- VerifyIGMPSnoopingGlobal:
enabled: True
```
""" """
categories: ClassVar[list[str]] = ["multicast"] name = "VerifyIGMPSnoopingGlobal"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)] description = "Verifies the IGMP snooping global configuration."
categories = ["multicast", "igmp"]
class Input(AntaTest.Input): commands = [AntaCommand(command="show ip igmp snooping")]
"""Input model for the VerifyIGMPSnoopingGlobal test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
enabled: bool enabled: bool
"""Whether global IGMP snopping must be enabled (True) or disabled (False).""" """Expected global IGMP snooping configuration (True=enabled, False=disabled)"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyIGMPSnoopingGlobal."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
self.result.is_success() self.result.is_success()
igmp_state = command_output["igmpSnoopingState"] igmp_state = command_output["igmpSnoopingState"]

View file

@ -1,159 +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.
"""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 ipaddress import IPv4Address
from typing import ClassVar
from pydantic import BaseModel
from anta.decorators import skip_on_platforms
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
if path_state not in ["ipsecEstablished", "routeResolved"]:
self.result.is_failure(f"Path state for peer {peer} in path-group {group} is `{path_state}`.")
# If the telemetry state of any path is inactive, the test fails
elif not session:
self.result.is_failure(f"Telemetry state for peer {peer} in path-group {group} is `inactive`.")
class VerifySpecificPath(AntaTest):
"""Verifies the path and telemetry state of a specific path for an IPv4 peer 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 the path state under router path-selection is either 'IPsec established' or 'Resolved'
and telemetry state as 'active'.
* Failure: The test will fail if router path-selection is not configured or if the path state is not 'IPsec established' or 'Resolved',
or if 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]] = [
AntaTemplate(template="show path-selection paths peer {peer} path-group {group} source {source} destination {destination}", revision=1)
]
class Input(AntaTest.Input):
"""Input model for the VerifySpecificPath test."""
paths: list[RouterPath]
"""List of router paths to verify."""
class RouterPath(BaseModel):
"""Detail of a router path."""
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 render(self, template: AntaTemplate) -> list[AntaCommand]:
"""Render the template for each router path."""
return [
template.render(peer=path.peer, group=path.path_group, source=path.source_address, destination=path.destination_address) for path in self.inputs.paths
]
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySpecificPath."""
self.result.is_success()
# Check the state of each path
for command in self.instance_commands:
peer = command.params.peer
path_group = command.params.group
source = command.params.source
destination = command.params.destination
command_output = command.json_output.get("dpsPeers", [])
# If the peer is not configured for the path group, the test fails
if not command_output:
self.result.is_failure(f"Path `peer: {peer} source: {source} destination: {destination}` is not configured for path-group `{path_group}`.")
continue
# Extract the state of the path
path_output = get_value(command_output, f"{peer}..dpsGroups..{path_group}..dpsPaths", separator="..")
path_state = next(iter(path_output.values())).get("state")
session = get_value(next(iter(path_output.values())), "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"Path state for `peer: {peer} source: {source} destination: {destination}` in path-group {path_group} is `{path_state}`.")
elif not session:
self.result.is_failure(
f"Telemetry state for path `peer: {peer} source: {source} destination: {destination}` in path-group {path_group} is `inactive`."
)

View file

@ -1,52 +1,36 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to ASIC profile tests.""" """
Test functions related to ASIC profiles
"""
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar, Literal from typing import Literal
from anta.decorators import skip_on_platforms from anta.decorators import skip_on_platforms
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyUnifiedForwardingTableMode(AntaTest): class VerifyUnifiedForwardingTableMode(AntaTest):
"""Verifies the device is using the expected UFT (Unified Forwarding Table) mode. """
Verifies the device is using the expected Unified Forwarding Table mode.
Expected Results
----------------
* Success: The test will pass if the device is using the expected UFT mode.
* Failure: The test will fail if the device is not using the expected UFT mode.
Examples
--------
```yaml
anta.tests.profiles:
- VerifyUnifiedForwardingTableMode:
mode: 3
```
""" """
description = "Verifies the device is using the expected UFT mode." name = "VerifyUnifiedForwardingTableMode"
categories: ClassVar[list[str]] = ["profiles"] description = ""
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show platform trident forwarding-table partition", revision=1)] categories = ["profiles"]
commands = [AntaCommand(command="show platform trident forwarding-table partition", ofmt="json")]
class Input(AntaTest.Input):
"""Input model for the VerifyUnifiedForwardingTableMode test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
mode: Literal[0, 1, 2, 3, 4, "flexible"] mode: Literal[0, 1, 2, 3, 4, "flexible"]
"""Expected UFT mode. Valid values are 0, 1, 2, 3, 4, or "flexible".""" """Expected UFT mode"""
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) @skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyUnifiedForwardingTableMode."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if command_output["uftMode"] == str(self.inputs.mode): if command_output["uftMode"] == str(self.inputs.mode):
self.result.is_success() self.result.is_success()
@ -55,36 +39,22 @@ class VerifyUnifiedForwardingTableMode(AntaTest):
class VerifyTcamProfile(AntaTest): class VerifyTcamProfile(AntaTest):
"""Verifies that the device is using the provided Ternary Content-Addressable Memory (TCAM) profile. """
Verifies the device is using the configured TCAM profile.
Expected Results
----------------
* Success: The test will pass if the provided TCAM profile is actually running on the device.
* Failure: The test will fail if the provided TCAM profile is not running on the device.
Examples
--------
```yaml
anta.tests.profiles:
- VerifyTcamProfile:
profile: vxlan-routing
```
""" """
description = "Verifies the device TCAM profile." name = "VerifyTcamProfile"
categories: ClassVar[list[str]] = ["profiles"] description = "Verify that the assigned TCAM profile is actually running on the device"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware tcam profile", revision=1)] categories = ["profiles"]
commands = [AntaCommand(command="show hardware tcam profile", ofmt="json")]
class Input(AntaTest.Input):
"""Input model for the VerifyTcamProfile test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
profile: str profile: str
"""Expected TCAM profile.""" """Expected TCAM profile"""
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"]) @skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyTcamProfile."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if command_output["pmfProfiles"]["FixedSystem"]["status"] == command_output["pmfProfiles"]["FixedSystem"]["config"] == self.inputs.profile: if command_output["pmfProfiles"]["FixedSystem"]["status"] == command_output["pmfProfiles"]["FixedSystem"]["config"] == self.inputs.profile:
self.result.is_success() self.result.is_success()

View file

@ -1,230 +1,33 @@
# Copyright (c) 2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to PTP tests.""" """
Test functions related to PTP (Precision Time Protocol) in EOS
# Mypy does not understand AntaTest.Input typing """
# mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from anta.decorators import skip_on_platforms
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyPtpStatus(AntaTest):
"""
Verifies whether the PTP agent is enabled globally.
class VerifyPtpModeStatus(AntaTest): Expected Results:
"""Verifies that the device is configured as a Precision Time Protocol (PTP) Boundary Clock (BC). * success: The test will pass if the PTP agent is enabled globally.
* failure: The test will fail if the PTP agent is enabled globally.
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.
* Skipped: The test will be skipped if PTP is not configured on the device.
Examples
--------
```yaml
anta.tests.ptp:
- VerifyPtpModeStatus:
```
""" """
description = "Verifies that the device is configured as a PTP Boundary Clock." name = "VerifyPtpStatus"
categories: ClassVar[list[str]] = ["ptp"] description = "Verifies if the PTP agent is enabled."
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] categories = ["ptp"]
commands = [AntaCommand(command="show ptp")]
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyPtpModeStatus."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if (ptp_mode := command_output.get("ptpMode")) is None: if "ptpMode" in command_output.keys():
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}'")
else:
self.result.is_success()
class VerifyPtpGMStatus(AntaTest):
"""Verifies that the device is locked to a valid Precision Time Protocol (PTP) Grandmaster (GM).
To test PTP failover, re-run the test with a secondary GMID configured.
Expected Results
----------------
* 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.
* Skipped: The test will be skipped if PTP is not configured on the device.
Examples
--------
```yaml
anta.tests.ptp:
- VerifyPtpGMStatus:
gmid: 0xec:46:70:ff:fe:00:ff:a9
```
"""
class Input(AntaTest.Input):
"""Input model for the VerifyPtpGMStatus test."""
gmid: str
"""Identifier of the Grandmaster to which the device should be locked."""
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"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyPtpGMStatus."""
command_output = self.instance_commands[0].json_output
if (ptp_clock_summary := command_output.get("ptpClockSummary")) is None:
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()
class VerifyPtpLockStatus(AntaTest):
"""Verifies that the device was locked to the upstream Precision Time Protocol (PTP) Grandmaster (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.
* Skipped: The test will be skipped if PTP is not configured on the device.
Examples
--------
```yaml
anta.tests.ptp:
- 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"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyPtpLockStatus."""
threshold = 60
command_output = self.instance_commands[0].json_output
if (ptp_clock_summary := command_output.get("ptpClockSummary")) is None:
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")
else:
self.result.is_success()
class VerifyPtpOffset(AntaTest):
"""Verifies that the Precision Time Protocol (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 on the device.
Examples
--------
```yaml
anta.tests.ptp:
- 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"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyPtpOffset."""
threshold = 1000
offset_interfaces: dict[str, list[int]] = {}
command_output = self.instance_commands[0].json_output
if not command_output["ptpMonitorData"]:
self.result.is_skipped("PTP is not configured")
return
for interface in command_output["ptpMonitorData"]:
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()
class VerifyPtpPortModeStatus(AntaTest):
"""Verifies that all interfaces are in a valid Precision Time Protocol (PTP) state.
The interfaces can be in one of the following state: Master, Slave, Passive, or Disabled.
Expected Results
----------------
* Success: The test will pass if all PTP enabled interfaces are in a valid state.
* Failure: The test will fail if there are no PTP enabled interfaces or if some interfaces are not in a valid state.
Examples
--------
```yaml
anta.tests.ptp:
- 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"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyPtpPortModeStatus."""
valid_state = ("psMaster", "psSlave", "psPassive", "psDisabled")
command_output = self.instance_commands[0].json_output
if not command_output["ptpIntfSummaries"]:
self.result.is_failure("No interfaces are PTP enabled")
return
invalid_interfaces = [
interface
for interface in command_output["ptpIntfSummaries"]
for vlan in command_output["ptpIntfSummaries"][interface]["ptpIntfVlanSummaries"]
if vlan["portState"] not in valid_state
]
if not invalid_interfaces:
self.result.is_success() self.result.is_success()
else: else:
self.result.is_failure(f"The following interface(s) are not in a valid PTP state: '{invalid_interfaces}'") self.result.is_failure("PTP agent disabled")

View file

@ -1,4 +1,3 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Package related to routing tests."""

File diff suppressed because it is too large Load diff

View file

@ -1,62 +1,41 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to generic routing tests.""" """
Generic routing test functions
# Mypy does not understand AntaTest.Input typing """
# mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from functools import cache from ipaddress import IPv4Address, ip_interface
from ipaddress import IPv4Address, IPv4Interface
from typing import TYPE_CHECKING, ClassVar, Literal # Need to keep List for pydantic in python 3.8
from typing import List, Literal
from pydantic import model_validator from pydantic import 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.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_value
if TYPE_CHECKING: # Mypy does not understand AntaTest.Input typing
import sys # mypy: disable-error-code=attr-defined
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
class VerifyRoutingProtocolModel(AntaTest): class VerifyRoutingProtocolModel(AntaTest):
"""Verifies the configured routing protocol model. """
Verifies the configured routing protocol model is the one we expect.
Expected Results And if there is no mismatch between the configured and operating routing protocol model.
----------------
* Success: The test will pass if the configured routing protocol model is the one we expect.
* Failure: The test will fail if the configured routing protocol model is not the one we expect.
Examples
--------
```yaml
anta.tests.routing:
generic:
- VerifyRoutingProtocolModel:
model: multi-agent
```
""" """
categories: ClassVar[list[str]] = ["routing"] name = "VerifyRoutingProtocolModel"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)] description = "Verifies the configured routing protocol model."
categories = ["routing"]
class Input(AntaTest.Input): commands = [AntaCommand(command="show ip route summary", revision=3)]
"""Input model for the VerifyRoutingProtocolModel test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
model: Literal["multi-agent", "ribd"] = "multi-agent" model: Literal["multi-agent", "ribd"] = "multi-agent"
"""Expected routing protocol model. Defaults to `multi-agent`.""" """Expected routing protocol model"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyRoutingProtocolModel."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
configured_model = command_output["protoModelStatus"]["configuredProtoModel"] configured_model = command_output["protoModelStatus"]["configuredProtoModel"]
operating_model = command_output["protoModelStatus"]["operatingProtoModel"] operating_model = command_output["protoModelStatus"]["operatingProtoModel"]
@ -67,46 +46,31 @@ class VerifyRoutingProtocolModel(AntaTest):
class VerifyRoutingTableSize(AntaTest): class VerifyRoutingTableSize(AntaTest):
"""Verifies the size of the IP routing table of the default VRF. """
Verifies the size of the IP routing table (default VRF).
Expected Results Should be between the two provided thresholds.
----------------
* Success: The test will pass if the routing table size is between the provided minimum and maximum values.
* Failure: The test will fail if the routing table size is not between the provided minimum and maximum values.
Examples
--------
```yaml
anta.tests.routing:
generic:
- VerifyRoutingTableSize:
minimum: 2
maximum: 20
```
""" """
categories: ClassVar[list[str]] = ["routing"] name = "VerifyRoutingTableSize"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)] description = "Verifies the size of the IP routing table (default VRF). Should be between the two provided thresholds."
categories = ["routing"]
commands = [AntaCommand(command="show ip route summary", revision=3)]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyRoutingTableSize test.""" minimum: int
"""Expected minimum routing table (default VRF) size"""
maximum: int
"""Expected maximum routing table (default VRF) size"""
minimum: PositiveInteger @model_validator(mode="after") # type: ignore
"""Expected minimum routing table size.""" def check_min_max(self) -> AntaTest.Input:
maximum: PositiveInteger """Validate that maximum is greater than minimum"""
"""Expected maximum routing table size."""
@model_validator(mode="after")
def check_min_max(self) -> Self:
"""Validate that maximum is greater than minimum."""
if self.minimum > self.maximum: if self.minimum > self.maximum:
msg = f"Minimum {self.minimum} is greater than maximum {self.maximum}" raise ValueError(f"Minimum {self.minimum} is greater than maximum {self.maximum}")
raise ValueError(msg)
return self return self
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyRoutingTableSize."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
total_routes = int(command_output["vrfs"]["default"]["totalRoutes"]) total_routes = int(command_output["vrfs"]["default"]["totalRoutes"])
if self.inputs.minimum <= total_routes <= self.inputs.maximum: if self.inputs.minimum <= total_routes <= self.inputs.maximum:
@ -116,143 +80,39 @@ class VerifyRoutingTableSize(AntaTest):
class VerifyRoutingTableEntry(AntaTest): class VerifyRoutingTableEntry(AntaTest):
"""Verifies that the provided routes are present in the routing table of a specified VRF. """
This test verifies that the provided routes are present in the routing table of a specified VRF.
Expected Results Expected Results:
---------------- * success: The test will pass if the provided routes are present in the routing table.
* Success: The test will pass if the provided routes are present in the routing table. * failure: The test will fail if one or many provided routes are missing from the routing table.
* Failure: The test will fail if one or many provided routes are missing from the routing table.
Examples
--------
```yaml
anta.tests.routing:
generic:
- VerifyRoutingTableEntry:
vrf: default
routes:
- 10.1.0.1
- 10.1.0.2
```
""" """
categories: ClassVar[list[str]] = ["routing"] name = "VerifyRoutingTableEntry"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ description = "Verifies that the provided routes are present in the routing table of a specified VRF."
AntaTemplate(template="show ip route vrf {vrf} {route}", revision=4), categories = ["routing"]
AntaTemplate(template="show ip route vrf {vrf}", revision=4), commands = [AntaTemplate(template="show ip route vrf {vrf} {route}")]
]
class Input(AntaTest.Input):
"""Input model for the VerifyRoutingTableEntry test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
vrf: str = "default" vrf: str = "default"
"""VRF context. Defaults to `default` VRF.""" """VRF context"""
routes: list[IPv4Address] routes: List[IPv4Address]
"""List of routes to verify.""" """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]: def render(self, template: AntaTemplate) -> list[AntaCommand]:
"""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] 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 @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyRoutingTableEntry.""" missing_routes = []
commands_output_route_ips = set()
for command in self.instance_commands: for command in self.instance_commands:
command_output_vrf = command.json_output["vrfs"][self.inputs.vrf] if "vrf" in command.params and "route" in command.params:
commands_output_route_ips |= {self.ip_interface_ip(route) for route in command_output_vrf["routes"]} vrf, route = command.params["vrf"], command.params["route"]
if len(routes := command.json_output["vrfs"][vrf]["routes"]) == 0 or route != ip_interface(list(routes)[0]).ip:
missing_routes = [str(route) for route in self.inputs.routes if route not in commands_output_route_ips] missing_routes.append(str(route))
if not missing_routes: if not missing_routes:
self.result.is_success() self.result.is_success()
else: 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}: {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]
@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}")

View file

@ -1,730 +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.
"""Module related to IS-IS tests."""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
from __future__ import annotations
from ipaddress import IPv4Address, IPv4Network
from typing import Any, ClassVar, Literal
from pydantic import BaseModel
from anta.custom_types import Interface
from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_value
def _count_isis_neighbor(isis_neighbor_json: dict[str, Any]) -> int:
"""Count the number of isis neighbors.
Parameters
----------
isis_neighbor_json
The JSON output of the `show isis neighbors` command.
Returns
-------
int
The number of isis neighbors.
"""
count = 0
for vrf_data in isis_neighbor_json["vrfs"].values():
for instance_data in vrf_data["isisInstances"].values():
count += len(instance_data.get("neighbors", {}))
return count
def _get_not_full_isis_neighbors(isis_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]:
"""Return the isis neighbors whose adjacency state is not `up`.
Parameters
----------
isis_neighbor_json
The JSON output of the `show isis neighbors` command.
Returns
-------
list[dict[str, Any]]
A list of isis neighbors whose adjacency state is not `UP`.
"""
return [
{
"vrf": vrf,
"instance": instance,
"neighbor": adjacency["hostname"],
"state": state,
}
for vrf, vrf_data in isis_neighbor_json["vrfs"].items()
for instance, instance_data in vrf_data.get("isisInstances").items()
for neighbor, neighbor_data in instance_data.get("neighbors").items()
for adjacency in neighbor_data.get("adjacencies")
if (state := adjacency["state"]) != "up"
]
def _get_full_isis_neighbors(isis_neighbor_json: dict[str, Any], neighbor_state: Literal["up", "down"] = "up") -> list[dict[str, Any]]:
"""Return the isis neighbors whose adjacency state is `up`.
Parameters
----------
isis_neighbor_json
The JSON output of the `show isis neighbors` command.
neighbor_state
Value of the neihbor state we are looking for. Defaults to `up`.
Returns
-------
list[dict[str, Any]]
A list of isis neighbors whose adjacency state is not `UP`.
"""
return [
{
"vrf": vrf,
"instance": instance,
"neighbor": adjacency["hostname"],
"neighbor_address": adjacency["routerIdV4"],
"interface": adjacency["interfaceName"],
"state": state,
}
for vrf, vrf_data in isis_neighbor_json["vrfs"].items()
for instance, instance_data in vrf_data.get("isisInstances").items()
for neighbor, neighbor_data in instance_data.get("neighbors").items()
for adjacency in neighbor_data.get("adjacencies")
if (state := adjacency["state"]) == neighbor_state
]
def _get_isis_neighbors_count(isis_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]:
"""Count number of IS-IS neighbor of the device."""
return [
{"vrf": vrf, "interface": interface, "mode": mode, "count": int(level_data["numAdjacencies"]), "level": int(level)}
for vrf, vrf_data in isis_neighbor_json["vrfs"].items()
for instance, instance_data in vrf_data.get("isisInstances").items()
for interface, interface_data in instance_data.get("interfaces").items()
for level, level_data in interface_data.get("intfLevels").items()
if (mode := level_data["passive"]) is not True
]
def _get_interface_data(interface: str, vrf: str, command_output: dict[str, Any]) -> dict[str, Any] | None:
"""Extract data related to an IS-IS interface for testing."""
if (vrf_data := get_value(command_output, f"vrfs.{vrf}")) is None:
return None
for instance_data in vrf_data.get("isisInstances").values():
if (intf_dict := get_value(dictionary=instance_data, key="interfaces")) is not None:
try:
return next(ifl_data for ifl, ifl_data in intf_dict.items() if ifl == interface)
except StopIteration:
return None
return None
def _get_adjacency_segment_data_by_neighbor(neighbor: str, instance: str, vrf: str, command_output: dict[str, Any]) -> dict[str, Any] | None:
"""Extract data related to an IS-IS interface for testing."""
search_path = f"vrfs.{vrf}.isisInstances.{instance}.adjacencySegments"
if get_value(dictionary=command_output, key=search_path, default=None) is None:
return None
isis_instance = get_value(dictionary=command_output, key=search_path, default=None)
return next(
(segment_data for segment_data in isis_instance if neighbor == segment_data["ipAddress"]),
None,
)
class VerifyISISNeighborState(AntaTest):
"""Verifies all IS-IS neighbors are in UP state.
Expected Results
----------------
* Success: The test will pass if all IS-IS neighbors are in UP state.
* Failure: The test will fail if some IS-IS neighbors are not in UP state.
* Skipped: The test will be skipped if no IS-IS neighbor is found.
Examples
--------
```yaml
anta.tests.routing:
isis:
- VerifyISISNeighborState:
```
"""
categories: ClassVar[list[str]] = ["isis"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis neighbors", revision=1)]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyISISNeighborState."""
command_output = self.instance_commands[0].json_output
if _count_isis_neighbor(command_output) == 0:
self.result.is_skipped("No IS-IS neighbor detected")
return
self.result.is_success()
not_full_neighbors = _get_not_full_isis_neighbors(command_output)
if not_full_neighbors:
self.result.is_failure(f"Some neighbors are not in the correct state (UP): {not_full_neighbors}.")
class VerifyISISNeighborCount(AntaTest):
"""Verifies number of IS-IS neighbors per level and per interface.
Expected Results
----------------
* Success: The test will pass if the number of neighbors is correct.
* Failure: The test will fail if the number of neighbors is incorrect.
* Skipped: The test will be skipped if no IS-IS neighbor is found.
Examples
--------
```yaml
anta.tests.routing:
isis:
- VerifyISISNeighborCount:
interfaces:
- name: Ethernet1
level: 1
count: 2
- name: Ethernet2
level: 2
count: 1
- name: Ethernet3
count: 2
# level is set to 2 by default
```
"""
categories: ClassVar[list[str]] = ["isis"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifyISISNeighborCount test."""
interfaces: list[InterfaceCount]
"""list of interfaces with their information."""
class InterfaceCount(BaseModel):
"""Input model for the VerifyISISNeighborCount test."""
name: Interface
"""Interface name to check."""
level: int = 2
"""IS-IS level to check."""
count: int
"""Number of IS-IS neighbors."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyISISNeighborCount."""
command_output = self.instance_commands[0].json_output
self.result.is_success()
isis_neighbor_count = _get_isis_neighbors_count(command_output)
if len(isis_neighbor_count) == 0:
self.result.is_skipped("No IS-IS neighbor detected")
return
for interface in self.inputs.interfaces:
eos_data = [ifl_data for ifl_data in isis_neighbor_count if ifl_data["interface"] == interface.name and ifl_data["level"] == interface.level]
if not eos_data:
self.result.is_failure(f"No neighbor detected for interface {interface.name}")
continue
if eos_data[0]["count"] != interface.count:
self.result.is_failure(
f"Interface {interface.name}: "
f"expected Level {interface.level}: count {interface.count}, "
f"got Level {eos_data[0]['level']}: count {eos_data[0]['count']}"
)
class VerifyISISInterfaceMode(AntaTest):
"""Verifies ISIS Interfaces are running in correct mode.
Expected Results
----------------
* Success: The test will pass if all listed interfaces are running in correct mode.
* Failure: The test will fail if any of the listed interfaces is not running in correct mode.
* Skipped: The test will be skipped if no ISIS neighbor is found.
Examples
--------
```yaml
anta.tests.routing:
isis:
- VerifyISISInterfaceMode:
interfaces:
- name: Loopback0
mode: passive
# vrf is set to default by default
- name: Ethernet2
mode: passive
level: 2
# vrf is set to default by default
- name: Ethernet1
mode: point-to-point
vrf: default
# level is set to 2 by default
```
"""
description = "Verifies interface mode for IS-IS"
categories: ClassVar[list[str]] = ["isis"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifyISISNeighborCount test."""
interfaces: list[InterfaceState]
"""list of interfaces with their information."""
class InterfaceState(BaseModel):
"""Input model for the VerifyISISNeighborCount test."""
name: Interface
"""Interface name to check."""
level: Literal[1, 2] = 2
"""ISIS level configured for interface. Default is 2."""
mode: Literal["point-to-point", "broadcast", "passive"]
"""Number of IS-IS neighbors."""
vrf: str = "default"
"""VRF where the interface should be configured"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyISISInterfaceMode."""
command_output = self.instance_commands[0].json_output
self.result.is_success()
if len(command_output["vrfs"]) == 0:
self.result.is_skipped("IS-IS is not configured on device")
return
# Check for p2p interfaces
for interface in self.inputs.interfaces:
interface_data = _get_interface_data(
interface=interface.name,
vrf=interface.vrf,
command_output=command_output,
)
# Check for correct VRF
if interface_data is not None:
interface_type = get_value(dictionary=interface_data, key="interfaceType", default="unset")
# Check for interfaceType
if interface.mode == "point-to-point" and interface.mode != interface_type:
self.result.is_failure(f"Interface {interface.name} in VRF {interface.vrf} is not running in {interface.mode} reporting {interface_type}")
# Check for passive
elif interface.mode == "passive":
json_path = f"intfLevels.{interface.level}.passive"
if interface_data is None or get_value(dictionary=interface_data, key=json_path, default=False) is False:
self.result.is_failure(f"Interface {interface.name} in VRF {interface.vrf} is not running in passive mode")
else:
self.result.is_failure(f"Interface {interface.name} not found in VRF {interface.vrf}")
class VerifyISISSegmentRoutingAdjacencySegments(AntaTest):
"""Verify that all expected Adjacency segments are correctly visible for each interface.
Expected Results
----------------
* Success: The test will pass if all listed interfaces have correct adjacencies.
* Failure: The test will fail if any of the listed interfaces has not expected list of adjacencies.
* Skipped: The test will be skipped if no ISIS SR Adjacency is found.
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]
class IsisInstance(BaseModel):
"""ISIS Instance model definition."""
name: str
"""ISIS instance name."""
vrf: str = "default"
"""VRF name where ISIS instance is configured."""
segments: list[Segment]
"""List of Adjacency segments configured in this instance."""
class Segment(BaseModel):
"""Segment model definition."""
interface: Interface
"""Interface name to check."""
level: Literal[1, 2] = 2
"""ISIS level configured for interface. Default is 2."""
sid_origin: Literal["dynamic"] = "dynamic"
"""Adjacency type"""
address: IPv4Address
"""IP address of remote end of segment."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyISISSegmentRoutingAdjacencySegments."""
command_output = self.instance_commands[0].json_output
self.result.is_success()
if len(command_output["vrfs"]) == 0:
self.result.is_skipped("IS-IS is not configured on device")
return
# initiate defaults
failure_message = []
skip_vrfs = []
skip_instances = []
# Check if VRFs and instances are present in output.
for instance in self.inputs.instances:
vrf_data = get_value(
dictionary=command_output,
key=f"vrfs.{instance.vrf}",
default=None,
)
if vrf_data is None:
skip_vrfs.append(instance.vrf)
failure_message.append(f"VRF {instance.vrf} is not configured to run segment routging.")
elif get_value(dictionary=vrf_data, key=f"isisInstances.{instance.name}", default=None) is None:
skip_instances.append(instance.name)
failure_message.append(f"Instance {instance.name} is not found in vrf {instance.vrf}.")
# Check Adjacency segments
for instance in self.inputs.instances:
if instance.vrf not in skip_vrfs and instance.name not in skip_instances:
for input_segment in instance.segments:
eos_segment = _get_adjacency_segment_data_by_neighbor(
neighbor=str(input_segment.address),
instance=instance.name,
vrf=instance.vrf,
command_output=command_output,
)
if eos_segment is None:
failure_message.append(f"Your segment has not been found: {input_segment}.")
elif (
eos_segment["localIntf"] != input_segment.interface
or eos_segment["level"] != input_segment.level
or eos_segment["sidOrigin"] != input_segment.sid_origin
):
failure_message.append(f"Your segment is not correct: Expected: {input_segment} - Found: {eos_segment}.")
if failure_message:
self.result.is_failure("\n".join(failure_message))
class VerifyISISSegmentRoutingDataplane(AntaTest):
"""Verify dataplane of a list of ISIS-SR instances.
Expected Results
----------------
* Success: The test will pass if all instances have correct dataplane configured
* Failure: The test will fail if one of the instances has incorrect dataplane configured
* Skipped: The test will be skipped if ISIS is not running
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]
class IsisInstance(BaseModel):
"""ISIS Instance model definition."""
name: str
"""ISIS instance name."""
vrf: str = "default"
"""VRF name where ISIS instance is configured."""
dataplane: Literal["MPLS", "mpls", "unset"] = "MPLS"
"""Configured dataplane for the instance."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyISISSegmentRoutingDataplane."""
command_output = self.instance_commands[0].json_output
self.result.is_success()
if len(command_output["vrfs"]) == 0:
self.result.is_skipped("IS-IS-SR is not running on device.")
return
# initiate defaults
failure_message = []
skip_vrfs = []
skip_instances = []
# Check if VRFs and instances are present in output.
for instance in self.inputs.instances:
vrf_data = get_value(
dictionary=command_output,
key=f"vrfs.{instance.vrf}",
default=None,
)
if vrf_data is None:
skip_vrfs.append(instance.vrf)
failure_message.append(f"VRF {instance.vrf} is not configured to run segment routing.")
elif get_value(dictionary=vrf_data, key=f"isisInstances.{instance.name}", default=None) is None:
skip_instances.append(instance.name)
failure_message.append(f"Instance {instance.name} is not found in vrf {instance.vrf}.")
# Check Adjacency segments
for instance in self.inputs.instances:
if instance.vrf not in skip_vrfs and instance.name not in skip_instances:
eos_dataplane = get_value(dictionary=command_output, key=f"vrfs.{instance.vrf}.isisInstances.{instance.name}.dataPlane", default=None)
if instance.dataplane.upper() != eos_dataplane:
failure_message.append(f"ISIS instance {instance.name} is not running dataplane {instance.dataplane} ({eos_dataplane})")
if failure_message:
self.result.is_failure("\n".join(failure_message))
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[Entry]
"""List of tunnels to check on device."""
class Entry(BaseModel):
"""Definition of a tunnel entry."""
endpoint: IPv4Network
"""Endpoint IP of the tunnel."""
vias: list[Vias] | None = None
"""Optional list of path to reach endpoint."""
class Vias(BaseModel):
"""Definition of a tunnel path."""
nexthop: IPv4Address | None = None
"""Nexthop of the tunnel. If None, then it is not tested. Default: None"""
type: Literal["ip", "tunnel"] | None = None
"""Type of the tunnel. If None, then it is not tested. Default: None"""
interface: Interface | None = None
"""Interface of the tunnel. If None, then it is not tested. Default: None"""
tunnel_id: Literal["TI-LFA", "ti-lfa", "unset"] | None = None
"""Computation method of the tunnel. If None, then it is not tested. Default: None"""
def _eos_entry_lookup(self, search_value: IPv4Network, entries: dict[str, Any], search_key: str = "endpoint") -> dict[str, Any] | None:
return next(
(entry_value for entry_id, entry_value in entries.items() if str(entry_value[search_key]) == str(search_value)),
None,
)
@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.
"""
command_output = self.instance_commands[0].json_output
self.result.is_success()
# initiate defaults
failure_message = []
if len(command_output["entries"]) == 0:
self.result.is_skipped("IS-IS-SR is not running on device.")
return
for input_entry in self.inputs.entries:
eos_entry = self._eos_entry_lookup(search_value=input_entry.endpoint, entries=command_output["entries"])
if eos_entry is None:
failure_message.append(f"Tunnel to {input_entry} is not found.")
elif input_entry.vias is not None:
failure_src = []
for via_input in input_entry.vias:
if not self._check_tunnel_type(via_input, eos_entry):
failure_src.append("incorrect tunnel type")
if not self._check_tunnel_nexthop(via_input, eos_entry):
failure_src.append("incorrect nexthop")
if not self._check_tunnel_interface(via_input, eos_entry):
failure_src.append("incorrect interface")
if not self._check_tunnel_id(via_input, eos_entry):
failure_src.append("incorrect tunnel ID")
if failure_src:
failure_message.append(f"Tunnel to {input_entry.endpoint!s} is incorrect: {', '.join(failure_src)}")
if failure_message:
self.result.is_failure("\n".join(failure_message))
def _check_tunnel_type(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool:
"""Check if the tunnel type specified in `via_input` matches any of the tunnel types in `eos_entry`.
Parameters
----------
via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
The input tunnel type to check.
eos_entry : dict[str, Any]
The EOS entry containing the tunnel types.
Returns
-------
bool
True if the tunnel type matches any of the tunnel types in `eos_entry`, False otherwise.
"""
if via_input.type is not None:
return any(
via_input.type
== get_value(
dictionary=eos_via,
key="type",
default="undefined",
)
for eos_via in eos_entry["vias"]
)
return True
def _check_tunnel_nexthop(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool:
"""Check if the tunnel nexthop matches the given input.
Parameters
----------
via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
The input via object.
eos_entry : dict[str, Any]
The EOS entry dictionary.
Returns
-------
bool
True if the tunnel nexthop matches, False otherwise.
"""
if via_input.nexthop is not None:
return any(
str(via_input.nexthop)
== get_value(
dictionary=eos_via,
key="nexthop",
default="undefined",
)
for eos_via in eos_entry["vias"]
)
return True
def _check_tunnel_interface(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool:
"""Check if the tunnel interface exists in the given EOS entry.
Parameters
----------
via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
The input via object.
eos_entry : dict[str, Any]
The EOS entry dictionary.
Returns
-------
bool
True if the tunnel interface exists, False otherwise.
"""
if via_input.interface is not None:
return any(
via_input.interface
== get_value(
dictionary=eos_via,
key="interface",
default="undefined",
)
for eos_via in eos_entry["vias"]
)
return True
def _check_tunnel_id(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool:
"""Check if the tunnel ID matches any of the tunnel IDs in the EOS entry's vias.
Parameters
----------
via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
The input vias to check.
eos_entry : dict[str, Any])
The EOS entry to compare against.
Returns
-------
bool
True if the tunnel ID matches any of the tunnel IDs in the EOS entry's vias, False otherwise.
"""
if via_input.tunnel_id is not None:
return any(
via_input.tunnel_id.upper()
== get_value(
dictionary=eos_via,
key="tunnelId.type",
default="undefined",
).upper()
for eos_via in eos_entry["vias"]
)
return True

View file

@ -1,120 +1,61 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to OSPF tests.""" """
OSPF test functions
"""
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar from typing import Any
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int: def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int:
"""Count the number of OSPF neighbors. """
Count the number of OSPF neighbors
Parameters
----------
ospf_neighbor_json
The JSON output of the `show ip ospf neighbor` command.
Returns
-------
int
The number of OSPF neighbors.
""" """
count = 0 count = 0
for vrf_data in ospf_neighbor_json["vrfs"].values(): for _, vrf_data in ospf_neighbor_json["vrfs"].items():
for instance_data in vrf_data["instList"].values(): for _, instance_data in vrf_data["instList"].items():
count += len(instance_data.get("ospfNeighborEntries", [])) count += len(instance_data.get("ospfNeighborEntries", []))
return count return count
def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]: 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`.
Parameters
----------
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 [ Return the OSPF neighbors whose adjacency state is not "full"
"""
not_full_neighbors = []
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":
not_full_neighbors.append(
{ {
"vrf": vrf, "vrf": vrf,
"instance": instance, "instance": instance,
"neighbor": neighbor_data["routerId"], "neighbor": neighbor_data["routerId"],
"state": state, "state": state,
} }
for vrf, vrf_data in ospf_neighbor_json["vrfs"].items() )
for instance, instance_data in vrf_data["instList"].items() return not_full_neighbors
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.
Parameters
----------
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): class VerifyOSPFNeighborState(AntaTest):
"""Verifies all OSPF neighbors are in FULL state. """
Verifies all OSPF neighbors are in FULL state.
Expected Results
----------------
* Success: The test will pass if all OSPF neighbors are in FULL state.
* Failure: The test will fail if some OSPF neighbors are not in FULL state.
* Skipped: The test will be skipped if no OSPF neighbor is found.
Examples
--------
```yaml
anta.tests.routing:
ospf:
- VerifyOSPFNeighborState:
```
""" """
categories: ClassVar[list[str]] = ["ospf"] name = "VerifyOSPFNeighborState"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)] description = "Verifies all OSPF neighbors are in FULL state."
categories = ["ospf"]
commands = [AntaCommand(command="show ip ospf neighbor")]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyOSPFNeighborState."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if _count_ospf_neighbor(command_output) == 0: if _count_ospf_neighbor(command_output) == 0:
self.result.is_skipped("no OSPF neighbor found") self.result.is_skipped("no OSPF neighbor found")
@ -126,36 +67,21 @@ class VerifyOSPFNeighborState(AntaTest):
class VerifyOSPFNeighborCount(AntaTest): class VerifyOSPFNeighborCount(AntaTest):
"""Verifies the number of OSPF neighbors in FULL state is the one we expect. """
Verifies the number of OSPF neighbors in FULL state is the one we expect.
Expected Results
----------------
* Success: The test will pass if the number of OSPF neighbors in FULL state is the one we expect.
* Failure: The test will fail if the number of OSPF neighbors in FULL state is not the one we expect.
* Skipped: The test will be skipped if no OSPF neighbor is found.
Examples
--------
```yaml
anta.tests.routing:
ospf:
- VerifyOSPFNeighborCount:
number: 3
```
""" """
categories: ClassVar[list[str]] = ["ospf"] name = "VerifyOSPFNeighborCount"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)] description = "Verifies the number of OSPF neighbors in FULL state is the one we expect."
categories = ["ospf"]
class Input(AntaTest.Input): commands = [AntaCommand(command="show ip ospf neighbor")]
"""Input model for the VerifyOSPFNeighborCount test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
number: int number: int
"""The expected number of OSPF neighbors in FULL state.""" """The expected number of OSPF neighbors in FULL state"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyOSPFNeighborCount."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if (neighbor_count := _count_ospf_neighbor(command_output)) == 0: if (neighbor_count := _count_ospf_neighbor(command_output)) == 0:
self.result.is_skipped("no OSPF neighbor found") self.result.is_skipped("no OSPF neighbor found")
@ -164,45 +90,6 @@ class VerifyOSPFNeighborCount(AntaTest):
if neighbor_count != self.inputs.number: if neighbor_count != self.inputs.number:
self.result.is_failure(f"device has {neighbor_count} neighbors (expected {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) not_full_neighbors = _get_not_full_ospf_neighbors(command_output)
print(not_full_neighbors)
if not_full_neighbors: if not_full_neighbors:
self.result.is_failure(f"Some neighbors are not correctly configured: {not_full_neighbors}.") self.result.is_failure(f"Some neighbors are not correctly configured: {not_full_neighbors}.")
class VerifyOSPFMaxLSA(AntaTest):
"""Verifies LSAs present in the OSPF link state database did not cross the maximum LSA Threshold.
Expected Results
----------------
* Success: The test will pass if all OSPF instances did not cross the maximum LSA Threshold.
* Failure: The test will fail if some OSPF instances crossed the maximum LSA Threshold.
* Skipped: The test will be skipped if no OSPF instance is found.
Examples
--------
```yaml
anta.tests.routing:
ospf:
- 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.")
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.")

View file

@ -1,61 +1,45 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to the EOS various security tests.""" """
Test functions related to the EOS various security settings
"""
from __future__ import annotations from __future__ import annotations
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from datetime import datetime, timezone from datetime import datetime
from typing import TYPE_CHECKING, ClassVar, get_args from typing import List, Union
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, conint, model_validator
from anta.custom_types import EcdsaKeySize, EncryptionAlgorithm, PositiveInteger, RsaKeySize from anta.custom_types import EcdsaKeySize, EncryptionAlgorithm, RsaKeySize
from anta.input_models.security import IPSecPeer, IPSecPeers
from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_failed_logs, get_item, get_value from anta.tools.get_item import get_item
from anta.tools.get_value import get_value
if TYPE_CHECKING: from anta.tools.utils import get_failed_logs
import sys
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
class VerifySSHStatus(AntaTest): class VerifySSHStatus(AntaTest):
"""Verifies if the SSHD agent is disabled in the default VRF. """
Verifies if the SSHD agent is disabled in the default VRF.
Expected Results Expected Results:
---------------- * success: The test will pass if the SSHD agent is disabled in the default VRF.
* Success: The test will pass if the SSHD agent is disabled in the default VRF. * failure: The test will fail if the SSHD agent is NOT disabled in the default VRF.
* Failure: The test will fail if the SSHD agent is NOT disabled in the default VRF.
Examples
--------
```yaml
anta.tests.security:
- VerifySSHStatus:
```
""" """
categories: ClassVar[list[str]] = ["security"] name = "VerifySSHStatus"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh", ofmt="text")] description = "Verifies if the SSHD agent is disabled in the default VRF."
categories = ["security"]
commands = [AntaCommand(command="show management ssh", ofmt="text")]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifySSHStatus."""
command_output = self.instance_commands[0].text_output command_output = self.instance_commands[0].text_output
try: line = [line for line in command_output.split("\n") if line.startswith("SSHD status")][0]
line = next(line for line in command_output.split("\n") if line.startswith("SSHD status")) status = line.split("is ")[1]
except StopIteration:
self.result.is_failure("Could not find SSH status in returned output.")
return
status = line.split()[-1]
if status == "disabled": if status == "disabled":
self.result.is_success() self.result.is_success()
@ -64,123 +48,97 @@ class VerifySSHStatus(AntaTest):
class VerifySSHIPv4Acl(AntaTest): class VerifySSHIPv4Acl(AntaTest):
"""Verifies if the SSHD agent has the right number IPv4 ACL(s) configured for a specified VRF. """
Verifies if the SSHD agent has the right number IPv4 ACL(s) configured for a specified VRF.
Expected Results Expected results:
---------------- * success: The test will pass if the SSHD agent has the provided number of IPv4 ACL(s) in the specified VRF.
* Success: The test will pass if the SSHD agent has the provided number of IPv4 ACL(s) in the specified VRF. * failure: The test will fail if the SSHD agent has not the right number of IPv4 ACL(s) in the specified VRF.
* Failure: The test will fail if the SSHD agent has not the right number of IPv4 ACL(s) in the specified VRF.
Examples
--------
```yaml
anta.tests.security:
- VerifySSHIPv4Acl:
number: 3
vrf: default
```
""" """
name = "VerifySSHIPv4Acl"
description = "Verifies if the SSHD agent has IPv4 ACL(s) configured." description = "Verifies if the SSHD agent has IPv4 ACL(s) configured."
categories: ClassVar[list[str]] = ["security"] categories = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ip access-list summary", revision=1)] commands = [AntaCommand(command="show management ssh ip access-list summary")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifySSHIPv4Acl test.""" number: conint(ge=0) # type:ignore
"""The number of expected IPv4 ACL(s)"""
number: PositiveInteger
"""The number of expected IPv4 ACL(s)."""
vrf: str = "default" vrf: str = "default"
"""The name of the VRF in which to check for the SSHD agent. Defaults to `default` VRF.""" """The name of the VRF in which to check for the SSHD agent"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifySSHIPv4Acl."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
ipv4_acl_list = command_output["ipAclList"]["aclList"] ipv4_acl_list = command_output["ipAclList"]["aclList"]
ipv4_acl_number = len(ipv4_acl_list) ipv4_acl_number = len(ipv4_acl_list)
not_configured_acl_list = []
if ipv4_acl_number != self.inputs.number: 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"Expected {self.inputs.number} SSH IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
return return
for ipv4_acl in ipv4_acl_list:
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 self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]:
not_configured_acl_list.append(ipv4_acl["name"])
if not_configured_acl: if not_configured_acl_list:
self.result.is_failure(f"SSH IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}") self.result.is_failure(f"SSH IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
else: else:
self.result.is_success() self.result.is_success()
class VerifySSHIPv6Acl(AntaTest): class VerifySSHIPv6Acl(AntaTest):
"""Verifies if the SSHD agent has the right number IPv6 ACL(s) configured for a specified VRF. """
Verifies if the SSHD agent has the right number IPv6 ACL(s) configured for a specified VRF.
Expected Results Expected results:
---------------- * success: The test will pass if the SSHD agent has the provided number of IPv6 ACL(s) in the specified VRF.
* Success: The test will pass if the SSHD agent has the provided number of IPv6 ACL(s) in the specified VRF. * failure: The test will fail if the SSHD agent has not the right number of IPv6 ACL(s) in the specified VRF.
* Failure: The test will fail if the SSHD agent has not the right number of IPv6 ACL(s) in the specified VRF.
Examples
--------
```yaml
anta.tests.security:
- VerifySSHIPv6Acl:
number: 3
vrf: default
```
""" """
name = "VerifySSHIPv6Acl"
description = "Verifies if the SSHD agent has IPv6 ACL(s) configured." description = "Verifies if the SSHD agent has IPv6 ACL(s) configured."
categories: ClassVar[list[str]] = ["security"] categories = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ipv6 access-list summary", revision=1)] commands = [AntaCommand(command="show management ssh ipv6 access-list summary")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifySSHIPv6Acl test.""" number: conint(ge=0) # type:ignore
"""The number of expected IPv6 ACL(s)"""
number: PositiveInteger
"""The number of expected IPv6 ACL(s)."""
vrf: str = "default" vrf: str = "default"
"""The name of the VRF in which to check for the SSHD agent. Defaults to `default` VRF.""" """The name of the VRF in which to check for the SSHD agent"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifySSHIPv6Acl."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
ipv6_acl_list = command_output["ipv6AclList"]["aclList"] ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
ipv6_acl_number = len(ipv6_acl_list) ipv6_acl_number = len(ipv6_acl_list)
not_configured_acl_list = []
if ipv6_acl_number != self.inputs.number: 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"Expected {self.inputs.number} SSH IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
return return
for ipv6_acl in ipv6_acl_list:
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 self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]:
not_configured_acl_list.append(ipv6_acl["name"])
if not_configured_acl: if not_configured_acl_list:
self.result.is_failure(f"SSH IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}") self.result.is_failure(f"SSH IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
else: else:
self.result.is_success() self.result.is_success()
class VerifyTelnetStatus(AntaTest): class VerifyTelnetStatus(AntaTest):
"""Verifies if Telnet is disabled in the default VRF. """
Verifies if Telnet is disabled in the default VRF.
Expected Results Expected Results:
---------------- * success: The test will pass if Telnet is disabled in the default VRF.
* Success: The test will pass if Telnet is disabled in the default VRF. * failure: The test will fail if Telnet is NOT disabled in the default VRF.
* Failure: The test will fail if Telnet is NOT disabled in the default VRF.
Examples
--------
```yaml
anta.tests.security:
- VerifyTelnetStatus:
```
""" """
categories: ClassVar[list[str]] = ["security"] name = "VerifyTelnetStatus"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management telnet", revision=1)] description = "Verifies if Telnet is disabled in the default VRF."
categories = ["security"]
commands = [AntaCommand(command="show management telnet")]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyTelnetStatus."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if command_output["serverState"] == "disabled": if command_output["serverState"] == "disabled":
self.result.is_success() self.result.is_success()
@ -189,27 +147,21 @@ class VerifyTelnetStatus(AntaTest):
class VerifyAPIHttpStatus(AntaTest): class VerifyAPIHttpStatus(AntaTest):
"""Verifies if eAPI HTTP server is disabled globally. """
Verifies if eAPI HTTP server is disabled globally.
Expected Results Expected Results:
---------------- * success: The test will pass if eAPI HTTP server is disabled globally.
* Success: The test will pass if eAPI HTTP server is disabled globally. * failure: The test will fail if eAPI HTTP server is NOT disabled globally.
* Failure: The test will fail if eAPI HTTP server is NOT disabled globally.
Examples
--------
```yaml
anta.tests.security:
- VerifyAPIHttpStatus:
```
""" """
categories: ClassVar[list[str]] = ["security"] name = "VerifyAPIHttpStatus"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)] description = "Verifies if eAPI HTTP server is disabled globally."
categories = ["security"]
commands = [AntaCommand(command="show management api http-commands")]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyAPIHttpStatus."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if command_output["enabled"] and not command_output["httpServer"]["running"]: if command_output["enabled"] and not command_output["httpServer"]["running"]:
self.result.is_success() self.result.is_success()
@ -218,35 +170,25 @@ class VerifyAPIHttpStatus(AntaTest):
class VerifyAPIHttpsSSL(AntaTest): class VerifyAPIHttpsSSL(AntaTest):
"""Verifies if eAPI HTTPS server SSL profile is configured and valid. """
Verifies if eAPI HTTPS server SSL profile is configured and valid.
Expected Results Expected results:
---------------- * success: The test will pass if the eAPI HTTPS server SSL profile is configured and valid.
* Success: The test will pass if the eAPI HTTPS server SSL profile is configured and valid. * failure: The test will fail if the eAPI HTTPS server SSL profile is NOT configured, misconfigured or invalid.
* Failure: The test will fail if the eAPI HTTPS server SSL profile is NOT configured, misconfigured or invalid.
Examples
--------
```yaml
anta.tests.security:
- VerifyAPIHttpsSSL:
profile: default
```
""" """
name = "VerifyAPIHttpsSSL"
description = "Verifies if the eAPI has a valid SSL profile." description = "Verifies if the eAPI has a valid SSL profile."
categories: ClassVar[list[str]] = ["security"] categories = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)] commands = [AntaCommand(command="show management api http-commands")]
class Input(AntaTest.Input):
"""Input model for the VerifyAPIHttpsSSL test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
profile: str profile: str
"""SSL profile to verify.""" """SSL profile to verify"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyAPIHttpsSSL."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
try: try:
if command_output["sslProfile"]["name"] == self.inputs.profile and command_output["sslProfile"]["state"] == "valid": if command_output["sslProfile"]["name"] == self.inputs.profile and command_output["sslProfile"]["state"] == "valid":
@ -259,143 +201,110 @@ class VerifyAPIHttpsSSL(AntaTest):
class VerifyAPIIPv4Acl(AntaTest): class VerifyAPIIPv4Acl(AntaTest):
"""Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF. """
Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF.
Expected Results Expected results:
---------------- * success: The test will pass if eAPI has the provided number of IPv4 ACL(s) in the specified VRF.
* Success: The test will pass if eAPI has the provided number of IPv4 ACL(s) in the specified VRF. * failure: The test will fail if eAPI has not the right number of IPv4 ACL(s) in the specified VRF.
* Failure: The test will fail if eAPI has not the right number of IPv4 ACL(s) in the specified VRF.
Examples
--------
```yaml
anta.tests.security:
- VerifyAPIIPv4Acl:
number: 3
vrf: default
```
""" """
categories: ClassVar[list[str]] = ["security"] name = "VerifyAPIIPv4Acl"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ip access-list summary", revision=1)] description = "Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF."
categories = ["security"]
commands = [AntaCommand(command="show management api http-commands ip access-list summary")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input parameters for the VerifyAPIIPv4Acl test.""" number: conint(ge=0) # type:ignore
"""The number of expected IPv4 ACL(s)"""
number: PositiveInteger
"""The number of expected IPv4 ACL(s)."""
vrf: str = "default" vrf: str = "default"
"""The name of the VRF in which to check for eAPI. Defaults to `default` VRF.""" """The name of the VRF in which to check for eAPI"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyAPIIPv4Acl."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
ipv4_acl_list = command_output["ipAclList"]["aclList"] ipv4_acl_list = command_output["ipAclList"]["aclList"]
ipv4_acl_number = len(ipv4_acl_list) ipv4_acl_number = len(ipv4_acl_list)
not_configured_acl_list = []
if ipv4_acl_number != self.inputs.number: 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"Expected {self.inputs.number} eAPI IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
return return
for ipv4_acl in ipv4_acl_list:
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 self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]:
not_configured_acl_list.append(ipv4_acl["name"])
if not_configured_acl: if not_configured_acl_list:
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"eAPI IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
else: else:
self.result.is_success() self.result.is_success()
class VerifyAPIIPv6Acl(AntaTest): class VerifyAPIIPv6Acl(AntaTest):
"""Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF. """
Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF.
Expected Results Expected results:
---------------- * success: The test will pass if eAPI has the provided number of IPv6 ACL(s) in the specified VRF.
* Success: The test will pass if eAPI has the provided number of IPv6 ACL(s) in the specified VRF. * failure: The test will fail if eAPI has not the right number of IPv6 ACL(s) in the specified VRF.
* Failure: The test will fail if eAPI has not the right number of IPv6 ACL(s) in the specified VRF. * skipped: The test will be skipped if the number of IPv6 ACL(s) or VRF parameter is not provided.
* Skipped: The test will be skipped if the number of IPv6 ACL(s) or VRF parameter is not provided.
Examples
--------
```yaml
anta.tests.security:
- VerifyAPIIPv6Acl:
number: 3
vrf: default
```
""" """
categories: ClassVar[list[str]] = ["security"] name = "VerifyAPIIPv6Acl"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ipv6 access-list summary", revision=1)] description = "Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF."
categories = ["security"]
commands = [AntaCommand(command="show management api http-commands ipv6 access-list summary")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input parameters for the VerifyAPIIPv6Acl test.""" number: conint(ge=0) # type:ignore
"""The number of expected IPv6 ACL(s)"""
number: PositiveInteger
"""The number of expected IPv6 ACL(s)."""
vrf: str = "default" vrf: str = "default"
"""The name of the VRF in which to check for eAPI. Defaults to `default` VRF.""" """The name of the VRF in which to check for eAPI"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyAPIIPv6Acl."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
ipv6_acl_list = command_output["ipv6AclList"]["aclList"] ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
ipv6_acl_number = len(ipv6_acl_list) ipv6_acl_number = len(ipv6_acl_list)
not_configured_acl_list = []
if ipv6_acl_number != self.inputs.number: 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"Expected {self.inputs.number} eAPI IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
return return
for ipv6_acl in ipv6_acl_list:
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 self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]:
not_configured_acl_list.append(ipv6_acl["name"])
if not_configured_acl: if not_configured_acl_list:
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"eAPI IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
else: else:
self.result.is_success() self.result.is_success()
class VerifyAPISSLCertificate(AntaTest): class VerifyAPISSLCertificate(AntaTest):
"""Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size. """
Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size.
Expected Results Expected Results:
---------------- * success: The test will pass if the certificate's expiry date is greater than the threshold,
* 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. 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, * 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. or if the certificate has an incorrect name, encryption algorithm, or key size.
Examples
--------
```yaml
anta.tests.security:
- VerifyAPISSLCertificate:
certificates:
- certificate_name: ARISTA_SIGNING_CA.crt
expiry_threshold: 30
common_name: AristaIT-ICA ECDSA Issuing Cert Authority
encryption_algorithm: ECDSA
key_size: 256
- certificate_name: ARISTA_ROOT_CA.crt
expiry_threshold: 30
common_name: Arista Networks Internal IT Root Cert Authority
encryption_algorithm: RSA
key_size: 4096
```
""" """
categories: ClassVar[list[str]] = ["security"] name = "VerifyAPISSLCertificate"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ description = "Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size."
AntaCommand(command="show management security ssl certificate", revision=1), categories = ["security"]
AntaCommand(command="show clock", revision=1), commands = [AntaCommand(command="show management security ssl certificate"), AntaCommand(command="show clock")]
]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input parameters for the VerifyAPISSLCertificate test.""" """
Input parameters for the VerifyAPISSLCertificate test.
"""
certificates: list[APISSLCertificate] certificates: List[APISSLCertificates]
"""List of API SSL certificates.""" """List of API SSL certificates"""
class APISSLCertificate(BaseModel): class APISSLCertificates(BaseModel):
"""Model for an API SSL certificate.""" """
This class defines the details of an API SSL certificate.
"""
certificate_name: str certificate_name: str
"""The name of the certificate to be verified.""" """The name of the certificate to be verified."""
@ -405,30 +314,31 @@ class VerifyAPISSLCertificate(AntaTest):
"""The common subject name of the certificate.""" """The common subject name of the certificate."""
encryption_algorithm: EncryptionAlgorithm encryption_algorithm: EncryptionAlgorithm
"""The encryption algorithm of the certificate.""" """The encryption algorithm of the certificate."""
key_size: RsaKeySize | EcdsaKeySize key_size: Union[RsaKeySize, EcdsaKeySize]
"""The encryption algorithm key size of the certificate.""" """The encryption algorithm key size of the certificate."""
@model_validator(mode="after") @model_validator(mode="after")
def validate_inputs(self) -> Self: def validate_inputs(self: BaseModel) -> BaseModel:
"""Validate the key size provided to the APISSLCertificates class. """
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 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 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): 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 ECDSA encryption. Allowed sizes are {get_args(EcdsaKeySize)}." raise ValueError(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__:
raise ValueError(
f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {EcdsaKeySize.__args__}."
)
return self return self
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyAPISSLCertificate."""
# Mark the result as success by default # Mark the result as success by default
self.result.is_success() self.result.is_success()
@ -446,7 +356,7 @@ class VerifyAPISSLCertificate(AntaTest):
continue continue
expiry_time = certificate_data["notAfter"] expiry_time = certificate_data["notAfter"]
day_difference = (datetime.fromtimestamp(expiry_time, tz=timezone.utc) - datetime.fromtimestamp(current_timestamp, tz=timezone.utc)).days day_difference = (datetime.fromtimestamp(expiry_time) - datetime.fromtimestamp(current_timestamp)).days
# Verify certificate expiry # Verify certificate expiry
if 0 < day_difference < certificate.expiry_threshold: if 0 < day_difference < certificate.expiry_threshold:
@ -471,37 +381,27 @@ class VerifyAPISSLCertificate(AntaTest):
class VerifyBannerLogin(AntaTest): class VerifyBannerLogin(AntaTest):
"""Verifies the login banner of a device. """
Verifies the login banner of a device.
Expected Results Expected results:
---------------- * success: The test will pass if the login banner matches the provided input.
* Success: The test will pass if the login banner matches the provided input. * failure: The test will fail if the login banner does not match the provided input.
* Failure: The test will fail if the login banner does not match the provided input.
Examples
--------
```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.
```
""" """
categories: ClassVar[list[str]] = ["security"] name = "VerifyBannerLogin"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner login", revision=1)] description = "Verifies the login banner of a device."
categories = ["security"]
commands = [AntaCommand(command="show banner login")]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyBannerLogin test.""" """Defines the input parameters for this test case."""
login_banner: str login_banner: str
"""Expected login banner of the device.""" """Expected login banner of the device."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyBannerLogin."""
login_banner = self.instance_commands[0].json_output["loginBanner"] login_banner = self.instance_commands[0].json_output["loginBanner"]
# Remove leading and trailing whitespaces from each line # Remove leading and trailing whitespaces from each line
@ -513,37 +413,27 @@ class VerifyBannerLogin(AntaTest):
class VerifyBannerMotd(AntaTest): class VerifyBannerMotd(AntaTest):
"""Verifies the motd banner of a device. """
Verifies the motd banner of a device.
Expected Results Expected results:
---------------- * success: The test will pass if the motd banner matches the provided input.
* Success: The test will pass if the motd banner matches the provided input. * failure: The test will fail if the motd banner does not match the provided input.
* Failure: The test will fail if the motd banner does not match the provided input.
Examples
--------
```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.
```
""" """
categories: ClassVar[list[str]] = ["security"] name = "VerifyBannerMotd"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner motd", revision=1)] description = "Verifies the motd banner of a device."
categories = ["security"]
commands = [AntaCommand(command="show banner motd")]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyBannerMotd test.""" """Defines the input parameters for this test case."""
motd_banner: str motd_banner: str
"""Expected motd banner of the device.""" """Expected motd banner of the device."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyBannerMotd."""
motd_banner = self.instance_commands[0].json_output["motd"] motd_banner = self.instance_commands[0].json_output["motd"]
# Remove leading and trailing whitespaces from each line # Remove leading and trailing whitespaces from each line
@ -555,75 +445,52 @@ class VerifyBannerMotd(AntaTest):
class VerifyIPv4ACL(AntaTest): class VerifyIPv4ACL(AntaTest):
"""Verifies the configuration of IPv4 ACLs. """
Verifies the configuration of IPv4 ACLs.
Expected Results Expected results:
---------------- * success: The test will pass if an IPv4 ACL is configured with the correct sequence entries.
* 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.
* Failure: The test will fail if an IPv4 ACL is not configured or entries are not in sequence.
Examples
--------
```yaml
anta.tests.security:
- VerifyIPv4ACL:
ipv4_access_lists:
- name: default-control-plane-acl
entries:
- sequence: 10
action: permit icmp any any
- sequence: 20
action: permit ip any any tracked
- sequence: 30
action: permit udp any any eq bfd ttl eq 255
- name: LabTest
entries:
- sequence: 10
action: permit icmp any any
- sequence: 20
action: permit tcp any any range 5900 5910
```
""" """
categories: ClassVar[list[str]] = ["security"] name = "VerifyIPv4ACL"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip access-lists {acl}", revision=1)] description = "Verifies the configuration of IPv4 ACLs."
categories = ["security"]
commands = [AntaTemplate(template="show ip access-lists {acl}")]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyIPv4ACL test.""" """Inputs for the VerifyIPv4ACL test."""
ipv4_access_lists: list[IPv4ACL] ipv4_access_lists: List[IPv4ACL]
"""List of IPv4 ACLs to verify.""" """List of IPv4 ACLs to verify"""
class IPv4ACL(BaseModel): class IPv4ACL(BaseModel):
"""Model for an IPv4 ACL.""" """Detail of IPv4 ACL"""
name: str name: str
"""Name of IPv4 ACL.""" """Name of IPv4 ACL"""
entries: list[IPv4ACLEntry] entries: List[IPv4ACLEntries]
"""List of IPv4 ACL entries.""" """List of IPv4 ACL entries"""
class IPv4ACLEntry(BaseModel): class IPv4ACLEntries(BaseModel):
"""Model for an IPv4 ACL entry.""" """IPv4 ACL entries details"""
sequence: int = Field(ge=1, le=4294967295) sequence: int = Field(ge=1, le=4294967295)
"""Sequence number of an ACL entry.""" """Sequence number of an ACL entry"""
action: str action: str
"""Action of an ACL entry.""" """Action of an ACL entry"""
def render(self, template: AntaTemplate) -> list[AntaCommand]: def render(self, template: AntaTemplate) -> list[AntaCommand]:
"""Render the template for each input ACL.""" return [template.render(acl=acl.name, entries=acl.entries) for acl in self.inputs.ipv4_access_lists]
return [template.render(acl=acl.name) for acl in self.inputs.ipv4_access_lists]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyIPv4ACL."""
self.result.is_success() self.result.is_success()
for command_output, acl in zip(self.instance_commands, self.inputs.ipv4_access_lists): for command_output in self.instance_commands:
# Collecting input ACL details # Collecting input ACL details
acl_name = command_output.params.acl acl_name = command_output.params["acl"]
# Retrieve the expected entries from the inputs acl_entries = command_output.params["entries"]
acl_entries = acl.entries
# Check if ACL is configured # Check if ACL is configured
ipv4_acl_list = command_output.json_output["aclList"] ipv4_acl_list = command_output.json_output["aclList"]
@ -645,173 +512,3 @@ class VerifyIPv4ACL(AntaTest):
if failed_log != f"{acl_name}:\n": if failed_log != f"{acl_name}:\n":
self.result.is_failure(f"{failed_log}") self.result.is_failure(f"{failed_log}")
class VerifyIPSecConnHealth(AntaTest):
"""Verifies all IPv4 security connections.
Expected Results
----------------
* Success: The test will pass if all the IPv4 security connections are established in all vrf.
* Failure: The test will fail if IPv4 security is not configured or any of IPv4 security connections are not established in any vrf.
Examples
--------
```yaml
anta.tests.security:
- VerifyIPSecConnHealth:
```
"""
categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip security connection vrf all")]
@AntaTest.anta_test
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.")
return
# Iterate over all ipsec connections
for conn_data in command_output.values():
state = next(iter(conn_data["pathDict"].values()))
if state != "Established":
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}.")
class VerifySpecificIPSecConn(AntaTest):
"""Verifies the IPv4 security connections.
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: 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
--------
```yaml
anta.tests.security:
- VerifySpecificIPSecConn:
ip_security_connections:
- peer: 10.255.0.1
- peer: 10.255.0.2
vrf: default
connections:
- source_address: 100.64.3.2
destination_address: 100.64.2.2
- source_address: 172.18.3.2
destination_address: 172.18.2.2
```
"""
categories: ClassVar[list[str]] = ["security"]
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[IPSecPeer]
"""List of IP4v security peers."""
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."""
return [template.render(peer=conn.peer, vrf=conn.vrf) for conn in self.inputs.ip_security_connections]
@AntaTest.anta_test
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"]
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"{input_peer} - Not configured")
continue
# If connection details are not provided then check all connections of a peer
if conn_input is None:
for conn_data in conn_output.values():
state = next(iter(conn_data["pathDict"].values()))
if state != "Established":
source = conn_data.get("saddr")
destination = conn_data.get("daddr")
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
existing_connections = {
(conn_data.get("saddr"), conn_data.get("daddr"), conn_data.get("tunnelNs")): next(iter(conn_data["pathDict"].values()))
for conn_data in conn_output.values()
}
for connection in conn_input:
source_input = str(connection.source_address)
destination_input = str(connection.destination_address)
if (source_input, destination_input, vrf) in existing_connections:
existing_state = existing_connections[(source_input, destination_input, vrf)]
if existing_state != "Established":
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"{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()

View file

@ -1,51 +1,48 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to the EOS various services tests.""" """
Test functions related to the EOS various services settings
"""
from __future__ import annotations from __future__ import annotations
from ipaddress import IPv4Address, IPv6Address
from typing import List, Union
from pydantic import BaseModel, Field
from anta.custom_types import ErrDisableInterval, ErrDisableReasons
from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools.get_dict_superset import get_dict_superset
from anta.tools.get_item import get_item
from anta.tools.utils import get_failed_logs
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from typing import ClassVar
from pydantic import BaseModel
from anta.custom_types import ErrDisableInterval, ErrDisableReasons
from anta.input_models.services import DnsServer
from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_dict_superset, get_failed_logs
class VerifyHostname(AntaTest): class VerifyHostname(AntaTest):
"""Verifies the hostname of a device. """
Verifies the hostname of a device.
Expected Results Expected results:
---------------- * success: The test will pass if the hostname matches the provided input.
* Success: The test will pass if the hostname matches the provided input. * failure: The test will fail if the hostname does not match the provided input.
* Failure: The test will fail if the hostname does not match the provided input.
Examples
--------
```yaml
anta.tests.services:
- VerifyHostname:
hostname: s1-spine1
```
""" """
categories: ClassVar[list[str]] = ["services"] name = "VerifyHostname"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hostname", revision=1)] description = "Verifies the hostname of a device."
categories = ["services"]
commands = [AntaCommand(command="show hostname")]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyHostname test.""" """Defines the input parameters for this test case."""
hostname: str hostname: str
"""Expected hostname of the device.""" """Expected hostname of the device."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyHostname."""
hostname = self.instance_commands[0].json_output["hostname"] hostname = self.instance_commands[0].json_output["hostname"]
if hostname != self.inputs.hostname: if hostname != self.inputs.hostname:
@ -55,47 +52,35 @@ class VerifyHostname(AntaTest):
class VerifyDNSLookup(AntaTest): class VerifyDNSLookup(AntaTest):
"""Verifies the DNS (Domain Name Service) name to IP address resolution. """
This class verifies the DNS (Domain name service) name to IP address resolution.
Expected Results Expected Results:
---------------- * success: The test will pass if a domain name is resolved to an IP address.
* Success: The test will pass if a domain name is resolved to an IP address. * failure: The test will fail if a domain name does not resolve to an IP address.
* Failure: The test will fail if a domain name does not resolve to an IP address. * error: This test will error out if a domain name is invalid.
* Error: This test will error out if a domain name is invalid.
Examples
--------
```yaml
anta.tests.services:
- VerifyDNSLookup:
domain_names:
- arista.com
- www.google.com
- arista.ca
```
""" """
name = "VerifyDNSLookup"
description = "Verifies the DNS name to IP address resolution." description = "Verifies the DNS name to IP address resolution."
categories: ClassVar[list[str]] = ["services"] categories = ["services"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="bash timeout 10 nslookup {domain}", revision=1)] commands = [AntaTemplate(template="bash timeout 10 nslookup {domain}")]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyDNSLookup test.""" """Inputs for the VerifyDNSLookup test."""
domain_names: list[str] domain_names: List[str]
"""List of domain names.""" """List of domain names"""
def render(self, template: AntaTemplate) -> list[AntaCommand]: def render(self, template: AntaTemplate) -> list[AntaCommand]:
"""Render the template for each domain name in the input list."""
return [template.render(domain=domain_name) for domain_name in self.inputs.domain_names] return [template.render(domain=domain_name) for domain_name in self.inputs.domain_names]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyDNSLookup."""
self.result.is_success() self.result.is_success()
failed_domains = [] failed_domains = []
for command in self.instance_commands: for command in self.instance_commands:
domain = command.params.domain domain = command.params["domain"]
output = command.json_output["messages"][0] output = command.json_output["messages"][0]
if f"Can't find {domain}: No answer" in output: if f"Can't find {domain}: No answer" in output:
failed_domains.append(domain) failed_domains.append(domain)
@ -104,109 +89,87 @@ class VerifyDNSLookup(AntaTest):
class VerifyDNSServers(AntaTest): class VerifyDNSServers(AntaTest):
"""Verifies if the DNS (Domain Name Service) servers are correctly configured. """
Verifies if the DNS (Domain Name Service) servers are correctly configured.
This test performs the following checks for each specified DNS Server: Expected Results:
* success: The test will pass if the DNS server specified in the input is configured with the correct VRF and priority.
1. Confirming correctly registered with a valid IPv4 or IPv6 address with the designated VRF. * 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.
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 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
--------
```yaml
anta.tests.services:
- VerifyDNSServers:
dns_servers:
- server_address: 10.14.0.1
vrf: default
priority: 1
- server_address: 10.14.0.11
vrf: MGMT
priority: 0
```
""" """
categories: ClassVar[list[str]] = ["services"] name = "VerifyDNSServers"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip name-server", revision=1)] description = "Verifies if the DNS servers are correctly configured."
categories = ["services"]
commands = [AntaCommand(command="show ip name-server")]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyDNSServers test.""" """Inputs for the VerifyDNSServers test."""
dns_servers: list[DnsServer] dns_servers: List[DnsServers]
"""List of DNS servers to verify.""" """List of DNS servers to verify."""
DnsServer: ClassVar[type[DnsServer]] = DnsServer
class DnsServers(BaseModel):
"""DNS server details"""
server_address: Union[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."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyDNSServers."""
self.result.is_success()
command_output = self.instance_commands[0].json_output["nameServerConfigs"] command_output = self.instance_commands[0].json_output["nameServerConfigs"]
self.result.is_success()
for server in self.inputs.dns_servers: for server in self.inputs.dns_servers:
address = str(server.server_address) address = str(server.server_address)
vrf = server.vrf vrf = server.vrf
priority = server.priority priority = server.priority
input_dict = {"ipAddr": address, "vrf": vrf} input_dict = {"ipAddr": address, "vrf": vrf}
# Check if the DNS server is configured with specified VRF. if get_item(command_output, "ipAddr", address) is None:
if (output := get_dict_superset(command_output, input_dict)) is None: self.result.is_failure(f"DNS server `{address}` is not configured with any VRF.")
self.result.is_failure(f"{server} - Not configured") continue
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}`.")
continue continue
# Check if the DNS server priority matches with expected.
if output["priority"] != priority: if output["priority"] != priority:
self.result.is_failure(f"{server} - Incorrect priority - Priority: {output['priority']}") self.result.is_failure(f"For DNS server `{address}`, the expected priority is `{priority}`, but `{output['priority']}` was found instead.")
class VerifyErrdisableRecovery(AntaTest): class VerifyErrdisableRecovery(AntaTest):
"""Verifies the errdisable recovery reason, status, and interval. """
Verifies the errdisable recovery reason, status, and interval.
Expected Results Expected Results:
----------------
* Success: The test will pass if the errdisable recovery reason status is enabled and the interval matches the input. * 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. * 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.
Examples
--------
```yaml
anta.tests.services:
- VerifyErrdisableRecovery:
reasons:
- reason: acl
interval: 30
- reason: bpduguard
interval: 30
```
""" """
categories: ClassVar[list[str]] = ["services"] name = "VerifyErrdisableRecovery"
# NOTE: Only `text` output format is supported for this command description = "Verifies the errdisable recovery reason, status, and interval."
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show errdisable recovery", ofmt="text")] categories = ["services"]
commands = [AntaCommand(command="show errdisable recovery", ofmt="text")] # Command does not support JSON output hence using text output
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyErrdisableRecovery test.""" """Inputs for the VerifyErrdisableRecovery test."""
reasons: list[ErrDisableReason] reasons: List[ErrDisableReason]
"""List of errdisable reasons.""" """List of errdisable reasons"""
class ErrDisableReason(BaseModel): class ErrDisableReason(BaseModel):
"""Model for an errdisable reason.""" """Details of an errdisable reason"""
reason: ErrDisableReasons reason: ErrDisableReasons
"""Type or name of the errdisable reason.""" """Type or name of the errdisable reason"""
interval: ErrDisableInterval interval: ErrDisableInterval
"""Interval of the reason in seconds.""" """Interval of the reason in seconds"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyErrdisableRecovery."""
command_output = self.instance_commands[0].text_output command_output = self.instance_commands[0].text_output
self.result.is_success() self.result.is_success()
for error_reason in self.inputs.reasons: for error_reason in self.inputs.reasons:

View file

@ -1,52 +1,38 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to the EOS various SNMP tests.""" """
Test functions related to the EOS various SNMP settings
"""
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar, get_args from pydantic import conint
from anta.custom_types import PositiveInteger, SnmpErrorCounter, SnmpPdu
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
from anta.tools import get_value
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifySnmpStatus(AntaTest): class VerifySnmpStatus(AntaTest):
"""Verifies whether the SNMP agent is enabled in a specified VRF. """
Verifies whether the SNMP agent is enabled in a specified VRF.
Expected Results Expected Results:
---------------- * success: The test will pass if the SNMP agent is enabled in the specified VRF.
* Success: The test will pass if the SNMP agent is enabled in the specified VRF. * failure: The test will fail if the SNMP agent is disabled in the specified VRF.
* Failure: The test will fail if the SNMP agent is disabled in the specified VRF.
Examples
--------
```yaml
anta.tests.snmp:
- VerifySnmpStatus:
vrf: default
```
""" """
name = "VerifySnmpStatus"
description = "Verifies if the SNMP agent is enabled." description = "Verifies if the SNMP agent is enabled."
categories: ClassVar[list[str]] = ["snmp"] categories = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] commands = [AntaCommand(command="show snmp")]
class Input(AntaTest.Input):
"""Input model for the VerifySnmpStatus test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
vrf: str = "default" vrf: str = "default"
"""The name of the VRF in which to check for the SNMP agent. Defaults to `default` VRF.""" """The name of the VRF in which to check for the SNMP agent"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifySnmpStatus."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if command_output["enabled"] and self.inputs.vrf in command_output["vrfs"]["snmpVrfs"]: if command_output["enabled"] and self.inputs.vrf in command_output["vrfs"]["snmpVrfs"]:
self.result.is_success() self.result.is_success()
@ -55,136 +41,105 @@ class VerifySnmpStatus(AntaTest):
class VerifySnmpIPv4Acl(AntaTest): 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 the right number IPv4 ACL(s) configured for a specified VRF.
Expected Results Expected results:
---------------- * success: The test will pass if the SNMP agent has the provided number of IPv4 ACL(s) in the specified VRF.
* Success: The test will pass if the SNMP agent has the provided number of IPv4 ACL(s) in the specified VRF. * failure: The test will fail if the SNMP agent has not the right number of IPv4 ACL(s) in the specified VRF.
* Failure: The test will fail if the SNMP agent has not the right number of IPv4 ACL(s) in the specified VRF.
Examples
--------
```yaml
anta.tests.snmp:
- VerifySnmpIPv4Acl:
number: 3
vrf: default
```
""" """
name = "VerifySnmpIPv4Acl"
description = "Verifies if the SNMP agent has IPv4 ACL(s) configured." description = "Verifies if the SNMP agent has IPv4 ACL(s) configured."
categories: ClassVar[list[str]] = ["snmp"] categories = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv4 access-list summary", revision=1)] commands = [AntaCommand(command="show snmp ipv4 access-list summary")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifySnmpIPv4Acl test.""" number: conint(ge=0) # type:ignore
"""The number of expected IPv4 ACL(s)"""
number: PositiveInteger
"""The number of expected IPv4 ACL(s)."""
vrf: str = "default" vrf: str = "default"
"""The name of the VRF in which to check for the SNMP agent. Defaults to `default` VRF.""" """The name of the VRF in which to check for the SNMP agent"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifySnmpIPv4Acl."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
ipv4_acl_list = command_output["ipAclList"]["aclList"] ipv4_acl_list = command_output["ipAclList"]["aclList"]
ipv4_acl_number = len(ipv4_acl_list) ipv4_acl_number = len(ipv4_acl_list)
not_configured_acl_list = []
if ipv4_acl_number != self.inputs.number: 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"Expected {self.inputs.number} SNMP IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
return return
for ipv4_acl in ipv4_acl_list:
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 self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]:
not_configured_acl_list.append(ipv4_acl["name"])
if not_configured_acl: if not_configured_acl_list:
self.result.is_failure(f"SNMP IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}") self.result.is_failure(f"SNMP IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
else: else:
self.result.is_success() self.result.is_success()
class VerifySnmpIPv6Acl(AntaTest): 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 the right number IPv6 ACL(s) configured for a specified VRF.
Expected Results Expected results:
---------------- * success: The test will pass if the SNMP agent has the provided number of IPv6 ACL(s) in the specified VRF.
* Success: The test will pass if the SNMP agent has the provided number of IPv6 ACL(s) in the specified VRF. * failure: The test will fail if the SNMP agent has not the right number of IPv6 ACL(s) in the specified VRF.
* Failure: The test will fail if the SNMP agent has not the right number of IPv6 ACL(s) in the specified VRF.
Examples
--------
```yaml
anta.tests.snmp:
- VerifySnmpIPv6Acl:
number: 3
vrf: default
```
""" """
name = "VerifySnmpIPv6Acl"
description = "Verifies if the SNMP agent has IPv6 ACL(s) configured." description = "Verifies if the SNMP agent has IPv6 ACL(s) configured."
categories: ClassVar[list[str]] = ["snmp"] categories = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv6 access-list summary", revision=1)] commands = [AntaCommand(command="show snmp ipv6 access-list summary")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifySnmpIPv6Acl test.""" number: conint(ge=0) # type:ignore
"""The number of expected IPv6 ACL(s)"""
number: PositiveInteger
"""The number of expected IPv6 ACL(s)."""
vrf: str = "default" vrf: str = "default"
"""The name of the VRF in which to check for the SNMP agent. Defaults to `default` VRF.""" """The name of the VRF in which to check for the SNMP agent"""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifySnmpIPv6Acl."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
ipv6_acl_list = command_output["ipv6AclList"]["aclList"] ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
ipv6_acl_number = len(ipv6_acl_list) ipv6_acl_number = len(ipv6_acl_list)
not_configured_acl_list = []
if ipv6_acl_number != self.inputs.number: 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"Expected {self.inputs.number} SNMP IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
return return
for ipv6_acl in ipv6_acl_list:
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 self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]:
not_configured_acl_list.append(ipv6_acl["name"])
if acl_not_configured: if not_configured_acl_list:
self.result.is_failure(f"SNMP IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {acl_not_configured}") self.result.is_failure(f"SNMP IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
else: else:
self.result.is_success() self.result.is_success()
class VerifySnmpLocation(AntaTest): class VerifySnmpLocation(AntaTest):
"""Verifies the SNMP location of a device. """
This class verifies the SNMP location of a device.
Expected Results Expected results:
---------------- * success: The test will pass if the SNMP location matches the provided input.
* Success: The test will pass if the SNMP location matches the provided input. * failure: The test will fail if the SNMP location does not match the provided input.
* Failure: The test will fail if the SNMP location does not match the provided input.
Examples
--------
```yaml
anta.tests.snmp:
- VerifySnmpLocation:
location: New York
```
""" """
categories: ClassVar[list[str]] = ["snmp"] name = "VerifySnmpLocation"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] description = "Verifies the SNMP location of a device."
categories = ["snmp"]
commands = [AntaCommand(command="show snmp")]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifySnmpLocation test.""" """Defines the input parameters for this test case."""
location: str location: str
"""Expected SNMP location of the device.""" """Expected SNMP location of the device."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifySnmpLocation.""" location = self.instance_commands[0].json_output["location"]["location"]
# 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: if location != self.inputs.location:
self.result.is_failure(f"Expected `{self.inputs.location}` as the location, but found `{location}` instead.") self.result.is_failure(f"Expected `{self.inputs.location}` as the location, but found `{location}` instead.")
else: else:
@ -192,150 +147,30 @@ class VerifySnmpLocation(AntaTest):
class VerifySnmpContact(AntaTest): class VerifySnmpContact(AntaTest):
"""Verifies the SNMP contact of a device. """
This class verifies the SNMP contact of a device.
Expected Results Expected results:
---------------- * success: The test will pass if the SNMP contact matches the provided input.
* Success: The test will pass if the SNMP contact matches the provided input. * failure: The test will fail if the SNMP contact does not match the provided input.
* Failure: The test will fail if the SNMP contact does not match the provided input.
Examples
--------
```yaml
anta.tests.snmp:
- VerifySnmpContact:
contact: Jon@example.com
```
""" """
categories: ClassVar[list[str]] = ["snmp"] name = "VerifySnmpContact"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] description = "Verifies the SNMP contact of a device."
categories = ["snmp"]
commands = [AntaCommand(command="show snmp")]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifySnmpContact test.""" """Defines the input parameters for this test case."""
contact: str contact: str
"""Expected SNMP contact details of the device.""" """Expected SNMP contact details of the device."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifySnmpContact.""" contact = self.instance_commands[0].json_output["contact"]["contact"]
# 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: if contact != self.inputs.contact:
self.result.is_failure(f"Expected `{self.inputs.contact}` as the contact, but found `{contact}` instead.") self.result.is_failure(f"Expected `{self.inputs.contact}` as the contact, but found `{contact}` instead.")
else: else:
self.result.is_success() self.result.is_success()
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."""
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: value for pdu in snmp_pdus if (value := pdu_counters.get(pdu, "Not Found")) == "Not Found" or value == 0}
# Check if any failures
if not failures:
self.result.is_success()
else:
self.result.is_failure(f"The following SNMP PDU counters are not found or have zero PDU counters:\n{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."""
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: value for counter in error_counters if (value := snmp_counters.get(counter))}
# Check if any failures
if not error_counters_not_ok:
self.result.is_success()
else:
self.result.is_failure(f"The following SNMP error counters are not found or have non-zero error counters:\n{error_counters_not_ok}")

View file

@ -1,52 +1,35 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to the EOS software tests.""" """
Test functions related to the EOS software
"""
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar # Need to keep List for pydantic in python 3.8
from typing import List
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyEOSVersion(AntaTest): class VerifyEOSVersion(AntaTest):
"""Verifies that the device is running one of the allowed EOS version. """
Verifies the device is running one of the allowed EOS version.
Expected Results
----------------
* Success: The test will pass if the device is running one of the allowed EOS version.
* Failure: The test will fail if the device is not running one of the allowed EOS version.
Examples
--------
```yaml
anta.tests.software:
- VerifyEOSVersion:
versions:
- 4.25.4M
- 4.26.1F
```
""" """
description = "Verifies the EOS version of the device." name = "VerifyEOSVersion"
categories: ClassVar[list[str]] = ["software"] description = "Verifies the device is running one of the allowed EOS version."
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)] categories = ["software"]
commands = [AntaCommand(command="show version")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyEOSVersion test.""" versions: List[str]
"""List of allowed EOS versions"""
versions: list[str]
"""List of allowed EOS versions."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyEOSVersion."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if command_output["version"] in self.inputs.versions: if command_output["version"] in self.inputs.versions:
self.result.is_success() self.result.is_success()
@ -55,37 +38,21 @@ class VerifyEOSVersion(AntaTest):
class VerifyTerminAttrVersion(AntaTest): class VerifyTerminAttrVersion(AntaTest):
"""Verifies that he device is running one of the allowed TerminAttr version. """
Verifies the device is running one of the allowed TerminAttr version.
Expected Results
----------------
* Success: The test will pass if the device is running one of the allowed TerminAttr version.
* Failure: The test will fail if the device is not running one of the allowed TerminAttr version.
Examples
--------
```yaml
anta.tests.software:
- VerifyTerminAttrVersion:
versions:
- v1.13.6
- v1.8.0
```
""" """
description = "Verifies the TerminAttr version of the device." name = "VerifyTerminAttrVersion"
categories: ClassVar[list[str]] = ["software"] description = "Verifies the device is running one of the allowed TerminAttr version."
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)] categories = ["software"]
commands = [AntaCommand(command="show version detail")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifyTerminAttrVersion test.""" versions: List[str]
"""List of allowed TerminAttr versions"""
versions: list[str]
"""List of allowed TerminAttr versions."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyTerminAttrVersion."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
command_output_data = command_output["details"]["packages"]["TerminAttr-core"]["version"] command_output_data = command_output["details"]["packages"]["TerminAttr-core"]["version"]
if command_output_data in self.inputs.versions: if command_output_data in self.inputs.versions:
@ -95,30 +62,17 @@ class VerifyTerminAttrVersion(AntaTest):
class VerifyEOSExtensions(AntaTest): class VerifyEOSExtensions(AntaTest):
"""Verifies that all EOS extensions installed on the device are enabled for boot persistence. """
Verifies all EOS extensions installed on the device are enabled for boot persistence.
Expected Results
----------------
* Success: The test will pass if all EOS extensions installed on the device are enabled for boot persistence.
* Failure: The test will fail if some EOS extensions installed on the device are not enabled for boot persistence.
Examples
--------
```yaml
anta.tests.software:
- VerifyEOSExtensions:
```
""" """
categories: ClassVar[list[str]] = ["software"] name = "VerifyEOSExtensions"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ description = "Verifies all EOS extensions installed on the device are enabled for boot persistence."
AntaCommand(command="show extensions", revision=2), categories = ["software"]
AntaCommand(command="show boot-extensions", revision=1), commands = [AntaCommand(command="show extensions"), AntaCommand(command="show boot-extensions")]
]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyEOSExtensions."""
boot_extensions = [] boot_extensions = []
show_extensions_command_output = self.instance_commands[0].json_output show_extensions_command_output = self.instance_commands[0].json_output
show_boot_extensions_command_output = self.instance_commands[1].json_output show_boot_extensions_command_output = self.instance_commands[1].json_output
@ -126,9 +80,9 @@ class VerifyEOSExtensions(AntaTest):
extension for extension, extension_data in show_extensions_command_output["extensions"].items() if extension_data["status"] == "installed" extension for extension, extension_data in show_extensions_command_output["extensions"].items() if extension_data["status"] == "installed"
] ]
for extension in show_boot_extensions_command_output["extensions"]: for extension in show_boot_extensions_command_output["extensions"]:
formatted_extension = extension.strip("\n") extension = extension.strip("\n")
if formatted_extension != "": if extension != "":
boot_extensions.append(formatted_extension) boot_extensions.append(extension)
installed_extensions.sort() installed_extensions.sort()
boot_extensions.sort() boot_extensions.sort()
if installed_extensions == boot_extensions: if installed_extensions == boot_extensions:

View file

@ -1,69 +1,52 @@
# Copyright (c) 2023-2024 Arista Networks, Inc. # Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0 # Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file. # that can be found in the LICENSE file.
"""Module related to various Spanning Tree Protocol (STP) tests.""" """
Test functions related to various Spanning Tree Protocol (STP) settings
"""
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from typing import Any, ClassVar, Literal # Need to keep List for pydantic in python 3.8
from typing import List, Literal
from pydantic import Field
from anta.custom_types import Vlan from anta.custom_types import Vlan
from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_value from anta.tools.get_value import get_value
class VerifySTPMode(AntaTest): class VerifySTPMode(AntaTest):
"""Verifies the configured STP mode for a provided list of VLAN(s). """
Verifies the configured STP mode for a provided list of VLAN(s).
Expected Results Expected Results:
---------------- * success: The test will pass if the STP mode is configured properly in the specified VLAN(s).
* Success: The test will pass if the STP mode is configured properly in the specified VLAN(s). * failure: The test will fail if the STP mode is NOT configured properly for one or more specified VLAN(s).
* Failure: The test will fail if the STP mode is NOT configured properly for one or more specified VLAN(s).
Examples
--------
```yaml
anta.tests.stp:
- VerifySTPMode:
mode: rapidPvst
vlans:
- 10
- 20
```
""" """
categories: ClassVar[list[str]] = ["stp"] name = "VerifySTPMode"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree vlan {vlan}", revision=1)] description = "Verifies the configured STP mode for a provided list of VLAN(s)."
categories = ["stp"]
class Input(AntaTest.Input): commands = [AntaTemplate(template="show spanning-tree vlan {vlan}")]
"""Input model for the VerifySTPMode test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
mode: Literal["mstp", "rstp", "rapidPvst"] = "mstp" mode: Literal["mstp", "rstp", "rapidPvst"] = "mstp"
"""STP mode to verify. Supported values: mstp, rstp, rapidPvst. Defaults to mstp.""" """STP mode to verify"""
vlans: list[Vlan] vlans: List[Vlan]
"""List of VLAN on which to verify STP mode.""" """List of VLAN on which to verify STP mode"""
def render(self, template: AntaTemplate) -> list[AntaCommand]: def render(self, template: AntaTemplate) -> list[AntaCommand]:
"""Render the template for each VLAN in the input list."""
return [template.render(vlan=vlan) for vlan in self.inputs.vlans] return [template.render(vlan=vlan) for vlan in self.inputs.vlans]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifySTPMode."""
not_configured = [] not_configured = []
wrong_stp_mode = [] wrong_stp_mode = []
for command in self.instance_commands: for command in self.instance_commands:
vlan_id = command.params.vlan if "vlan" in command.params:
if not ( vlan_id = command.params["vlan"]
stp_mode := get_value( if not (stp_mode := get_value(command.json_output, f"spanningTreeVlanInstances.{vlan_id}.spanningTreeVlanInstance.protocol")):
command.json_output,
f"spanningTreeVlanInstances.{vlan_id}.spanningTreeVlanInstance.protocol",
)
):
not_configured.append(vlan_id) not_configured.append(vlan_id)
elif stp_mode != self.inputs.mode: elif stp_mode != self.inputs.mode:
wrong_stp_mode.append(vlan_id) wrong_stp_mode.append(vlan_id)
@ -76,27 +59,21 @@ class VerifySTPMode(AntaTest):
class VerifySTPBlockedPorts(AntaTest): class VerifySTPBlockedPorts(AntaTest):
"""Verifies there is no STP blocked ports. """
Verifies there is no STP blocked ports.
Expected Results Expected Results:
---------------- * success: The test will pass if there are NO ports blocked by STP.
* Success: The test will pass if there are NO ports blocked by STP. * failure: The test will fail if there are ports blocked by STP.
* Failure: The test will fail if there are ports blocked by STP.
Examples
--------
```yaml
anta.tests.stp:
- VerifySTPBlockedPorts:
```
""" """
categories: ClassVar[list[str]] = ["stp"] name = "VerifySTPBlockedPorts"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree blockedports", revision=1)] description = "Verifies there is no STP blocked ports."
categories = ["stp"]
commands = [AntaCommand(command="show spanning-tree blockedports")]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifySTPBlockedPorts."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if not (stp_instances := command_output["spanningTreeInstances"]): if not (stp_instances := command_output["spanningTreeInstances"]):
self.result.is_success() self.result.is_success()
@ -107,27 +84,21 @@ class VerifySTPBlockedPorts(AntaTest):
class VerifySTPCounters(AntaTest): class VerifySTPCounters(AntaTest):
"""Verifies there is no errors in STP BPDU packets. """
Verifies there is no errors in STP BPDU packets.
Expected Results 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 interfaces participating in STP. * failure: The test will fail if there are STP BPDU packet errors on one or many interface(s).
* Failure: The test will fail if there are STP BPDU packet errors on one or many interface(s).
Examples
--------
```yaml
anta.tests.stp:
- VerifySTPCounters:
```
""" """
categories: ClassVar[list[str]] = ["stp"] name = "VerifySTPCounters"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree counters", revision=1)] description = "Verifies there is no errors in STP BPDU packets."
categories = ["stp"]
commands = [AntaCommand(command="show spanning-tree counters")]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifySTPCounters."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
interfaces_with_errors = [ interfaces_with_errors = [
interface for interface, counters in command_output["interfaces"].items() if counters["bpduTaggedError"] or counters["bpduOtherError"] != 0 interface for interface, counters in command_output["interfaces"].items() if counters["bpduTaggedError"] or counters["bpduOtherError"] != 0
@ -139,102 +110,77 @@ class VerifySTPCounters(AntaTest):
class VerifySTPForwardingPorts(AntaTest): class VerifySTPForwardingPorts(AntaTest):
"""Verifies that all interfaces are in a forwarding state for a provided list of VLAN(s). """
Verifies that all interfaces are in a forwarding state for a provided list of VLAN(s).
Expected Results Expected Results:
---------------- * success: The test will pass if all interfaces are in a forwarding state for the specified VLAN(s).
* Success: The test will pass if all interfaces are in a forwarding state for the specified VLAN(s). * failure: The test will fail if one or many interfaces are NOT in a forwarding state in the specified VLAN(s).
* Failure: The test will fail if one or many interfaces are NOT in a forwarding state in the specified VLAN(s).
Examples
--------
```yaml
anta.tests.stp:
- VerifySTPForwardingPorts:
vlans:
- 10
- 20
```
""" """
name = "VerifySTPForwardingPorts"
description = "Verifies that all interfaces are forwarding for a provided list of VLAN(s)." description = "Verifies that all interfaces are forwarding for a provided list of VLAN(s)."
categories: ClassVar[list[str]] = ["stp"] categories = ["stp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree topology vlan {vlan} status", revision=1)] commands = [AntaTemplate(template="show spanning-tree topology vlan {vlan} status")]
class Input(AntaTest.Input): class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
"""Input model for the VerifySTPForwardingPorts test.""" vlans: List[Vlan]
"""List of VLAN on which to verify forwarding states"""
vlans: list[Vlan]
"""List of VLAN on which to verify forwarding states."""
def render(self, template: AntaTemplate) -> list[AntaCommand]: def render(self, template: AntaTemplate) -> list[AntaCommand]:
"""Render the template for each VLAN in the input list."""
return [template.render(vlan=vlan) for vlan in self.inputs.vlans] return [template.render(vlan=vlan) for vlan in self.inputs.vlans]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifySTPForwardingPorts."""
not_configured = [] not_configured = []
not_forwarding = [] not_forwarding = []
for command in self.instance_commands: for command in self.instance_commands:
vlan_id = command.params.vlan if "vlan" in command.params:
vlan_id = command.params["vlan"]
if not (topologies := get_value(command.json_output, "topologies")): if not (topologies := get_value(command.json_output, "topologies")):
not_configured.append(vlan_id) not_configured.append(vlan_id)
else: else:
interfaces_not_forwarding = []
for value in topologies.values(): for value in topologies.values():
if vlan_id and int(vlan_id) in value["vlans"]: if int(vlan_id) in value["vlans"]:
interfaces_not_forwarding = [interface for interface, state in value["interfaces"].items() if state["state"] != "forwarding"] interfaces_not_forwarding = [interface for interface, state in value["interfaces"].items() if state["state"] != "forwarding"]
if interfaces_not_forwarding: if interfaces_not_forwarding:
not_forwarding.append({f"VLAN {vlan_id}": interfaces_not_forwarding}) not_forwarding.append({f"VLAN {vlan_id}": interfaces_not_forwarding})
if not_configured: if not_configured:
self.result.is_failure(f"STP instance is not configured for the following VLAN(s): {not_configured}") self.result.is_failure(f"STP instance is not configured for the following VLAN(s): {not_configured}")
if not_forwarding: if not_forwarding:
self.result.is_failure(f"The following VLAN(s) have interface(s) that are not in a forwarding state: {not_forwarding}") self.result.is_failure(f"The following VLAN(s) have interface(s) that are not in a fowarding state: {not_forwarding}")
if not not_configured and not interfaces_not_forwarding: if not not_configured and not interfaces_not_forwarding:
self.result.is_success() self.result.is_success()
class VerifySTPRootPriority(AntaTest): class VerifySTPRootPriority(AntaTest):
"""Verifies the STP root priority for a provided list of VLAN or MST instance ID(s). """
Verifies the STP root priority for a provided list of VLAN or MST instance ID(s).
Expected Results Expected Results:
---------------- * success: The test will pass if the STP root priority is configured properly for the specified VLAN or MST instance ID(s).
* Success: The test will pass if the STP root priority is configured properly for the specified VLAN or MST instance ID(s). * failure: The test will fail if the STP root priority is NOT configured properly for the specified VLAN or MST instance ID(s).
* Failure: The test will fail if the STP root priority is NOT configured properly for the specified VLAN or MST instance ID(s).
Examples
--------
```yaml
anta.tests.stp:
- VerifySTPRootPriority:
priority: 32768
instances:
- 10
- 20
```
""" """
categories: ClassVar[list[str]] = ["stp"] name = "VerifySTPRootPriority"
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree root detail", revision=1)] description = "Verifies the STP root priority for a provided list of VLAN or MST instance ID(s)."
categories = ["stp"]
class Input(AntaTest.Input): commands = [AntaCommand(command="show spanning-tree root detail")]
"""Input model for the VerifySTPRootPriority test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
priority: int priority: int
"""STP root priority to verify.""" """STP root priority to verify"""
instances: list[Vlan] = Field(default=[]) instances: List[Vlan] = []
"""List of VLAN or MST instance ID(s). If empty, ALL VLAN or MST instance ID(s) will be verified.""" """List of VLAN or MST instance ID(s). If empty, ALL VLAN or MST instance ID(s) will be verified."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifySTPRootPriority."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if not (stp_instances := command_output["instances"]): if not (stp_instances := command_output["instances"]):
self.result.is_failure("No STP instances configured") self.result.is_failure("No STP instances configured")
return return
# Checking the type of instances based on first instance # Checking the type of instances based on first instance
first_name = next(iter(stp_instances)) first_name = list(stp_instances)[0]
if first_name.startswith("MST"): if first_name.startswith("MST"):
prefix = "MST" prefix = "MST"
elif first_name.startswith("VL"): elif first_name.startswith("VL"):
@ -250,62 +196,3 @@ class VerifySTPRootPriority(AntaTest):
self.result.is_failure(f"The following instance(s) have the wrong STP root priority configured: {wrong_priority_instances}") self.result.is_failure(f"The following instance(s) have the wrong STP root priority configured: {wrong_priority_instances}")
else: else:
self.result.is_success() self.result.is_success()
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."""
failures: dict[str, Any] = {"topologies": {}}
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():
interfaces = {
interface: {"Number of changes": num_of_changes}
for interface, details in topology_details.get("interfaces", {}).items()
if (num_of_changes := details.get("numChanges")) > self.inputs.threshold
}
if interfaces:
failures["topologies"][topology] = interfaces
if failures["topologies"]:
self.result.is_failure(f"The following STP topologies are not configured or number of changes not within the threshold:\n{failures}")
else:
self.result.is_success()

View file

@ -1,154 +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.
"""Test functions related to various STUN 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 deprecated_test_class
from anta.input_models.stun import StunClientTranslation
from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_value
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:
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: 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:
- VerifyStunClientTranslation:
stun_clients:
- source_address: 172.18.3.2
public_address: 172.18.3.21
source_port: 4500
public_port: 6006
- source_address: 100.64.3.2
public_address: 100.64.3.21
source_port: 4500
public_port: 6006
```
"""
categories: ClassVar[list[str]] = ["stun"]
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 VerifyStunClientTranslation test."""
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."""
return [template.render(source_address=client.source_address, source_port=client.source_port) for client in self.inputs.stun_clients]
@AntaTest.anta_test
def test(self) -> None:
"""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"]
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"{client_input} - STUN client translation not found.")
continue
# Extract the transaction ID from the bindings
transaction_id = next(iter(bindings.keys()))
# 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()

Some files were not shown because too many files have changed in this diff Show more