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

View file

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

View file

@ -1,5 +1,6 @@
#!/usr/bin/env python
"""generate_release.py.
"""
generate_release.py
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
@ -19,18 +20,21 @@ CATEGORIES = {
"fix": "Bug Fixes",
"cut": "Cut",
"doc": "Documentation",
# "CI": "CI",
"bump": "Bump",
# "test": "Test",
"revert": "Revert",
"refactor": "Refactoring",
}
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):
return super().increase_indent(flow=flow, indentless=False)
@ -56,7 +60,7 @@ if __name__ == "__main__":
{
"title": "Breaking Changes",
"labels": breaking_labels,
},
}
)
# Add new features
@ -67,7 +71,7 @@ if __name__ == "__main__":
{
"title": "New features and enhancements",
"labels": feat_labels,
},
}
)
# Add fixes
@ -78,7 +82,7 @@ if __name__ == "__main__":
{
"title": "Fixed issues",
"labels": fixes_labels,
},
}
)
# Add Documentation
@ -89,7 +93,7 @@ if __name__ == "__main__":
{
"title": "Documentation",
"labels": doc_labels,
},
}
)
# Add the catch all
@ -97,7 +101,7 @@ if __name__ == "__main__":
{
"title": "Other Changes",
"labels": ["*"],
},
}
)
with open(r"release.yml", "w", encoding="utf-8") as release_file:
yaml.dump(
@ -105,7 +109,7 @@ if __name__ == "__main__":
"changelog": {
"exclude": {"labels": exclude_list},
"categories": categories_list,
},
}
},
release_file,
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
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
gh pr merge --squash

View file

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

View file

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

View file

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

View file

@ -7,13 +7,8 @@ on:
jobs:
pypi:
name: Publish Python 🐍 distribution 📦 to PyPI
name: Publish version to Pypi servers
runs-on: ubuntu-latest
environment:
name: production
url: https://pypi.org/p/anta
permissions:
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
@ -24,8 +19,11 @@ jobs:
- name: Build package
run: |
python -m build
- name: Publish distribution 📦 to PyPI
- name: Publish package to Pypi
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
release-coverage:
name: Updated ANTA release coverage badge
@ -102,7 +100,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
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__
*.pyc
.pages
.coverage
.pytest_cache
.mypy_cache
.ruff_cache
.cache
build
dist
*.egg-info
@ -32,6 +30,7 @@ share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
.flake8
# PyInstaller
# Usually these files are written by a python script from a template
@ -48,13 +47,14 @@ htmlcov/
.tox/
.nox/
.coverage
coverage_html_report
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
report.html
@ -99,3 +99,16 @@ venv.bak/
# VScode settings
.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/hooks.html for more hooks
ci:
autoupdate_commit_msg: "ci: pre-commit autoupdate"
files: ^(anta|docs|scripts|tests|asynceapi)/
files: ^(anta|docs|scripts|tests)/
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v4.4.0
hooks:
- id: trailing-whitespace
exclude: docs/.*.svg
- id: end-of-file-fixer
- id: check-added-large-files
exclude: tests/data/.*$
- id: check-merge-conflict
- repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.5.5
rev: v1.5.4
hooks:
- name: Check and insert license on Python files
id: insert-license
@ -35,7 +30,7 @@ repos:
- name: Check and insert license on Markdown files
id: insert-license
files: .*\.md$
exclude: ^tests/data/.*\.md$
# exclude:
args:
- --license-filepath
- .github/license-short.txt
@ -45,82 +40,66 @@ repos:
- --comment-style
- '<!--| ~| -->'
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: ruff
name: Run Ruff linter
args: [ --fix ]
- id: ruff-format
name: Run Ruff formatter
- id: isort
name: Check for changes when running isort on all python files
- repo: https://github.com/pycqa/pylint
rev: "v3.3.2"
- repo: https://github.com/psf/black
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:
- 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.
types: [python]
args:
- -rn # Only display messages
- -sn # Don't display the score
- --rcfile=pyproject.toml # Link to config file
additional_dependencies:
- anta[cli]
- types-PyYAML
- types-requests
- types-pyOpenSSL
- pylint_pydantic
- pytest
- pytest-codspeed
- respx
- --rcfile=pylintrc # Link to config file
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
hooks:
- id: codespell
name: Checks for common misspellings in text files.
entry: codespell
language: python
types: [text]
# Prepare to turn on ruff
# - repo: https://github.com/astral-sh/ruff-pre-commit
# # Ruff version.
# rev: v0.0.280
# hooks:
# - id: ruff
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.14.0
rev: v1.7.1
hooks:
- id: mypy
name: Check typing with mypy
args:
- --config-file=pyproject.toml
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-paramiko
- types-requests
- types-pyOpenSSL
- pytest
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,
"ruff.configuration": "pyproject.toml",
"python.testing.pytestEnabled": true,
"black-formatter.importStrategy": "fromEnvironment",
"pylint.importStrategy": "fromEnvironment",
"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": [
"tests"
],
"githubIssues.issueBranchTitle": "issues/${issueNumber}-${issueTitle}",
"pylint.importStrategy": "fromEnvironment",
"pylint.args": [
"--rcfile=pyproject.toml"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"isort.importStrategy": "fromEnvironment",
"isort.check": true,
}

View file

@ -3,30 +3,23 @@ ARG IMG_OPTION=alpine
### BUILDER
FROM python:${PYTHON_VER}-${IMG_OPTION} AS BUILDER
FROM python:${PYTHON_VER}-${IMG_OPTION} as BUILDER
RUN pip install --upgrade pip
WORKDIR /local
COPY . /local
RUN python -m venv /opt/venv
ENV PYTHONPATH=/local
ENV PATH=$PATH:/root/.local/bin
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]"
RUN pip --no-cache-dir install --user .
# ----------------------------------- #
### BASE
FROM python:${PYTHON_VER}-${IMG_OPTION} AS BASE
# Add a system user
RUN adduser --system anta
FROM python:${PYTHON_VER}-${IMG_OPTION} as BASE
# Opencontainer labels
# Labels version and revision will be updating
@ -37,22 +30,17 @@ RUN adduser --system 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.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.documentation"="https://anta.arista.com" \
"org.opencontainers.image.documentation"="https://www.anta.ninja" \
"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.base.name"="python" \
"org.opencontainers.image.revision"="dev" \
"org.opencontainers.image.version"="dev"
# Copy artifacts from builder
COPY --from=BUILDER /opt/venv /opt/venv
COPY --from=BUILDER /root/.local/ /root/.local
ENV PATH=$PATH:/root/.local/bin
# Define PATH and default user
ENV PATH="/opt/venv/bin:$PATH"
USER anta
ENTRYPOINT [ "/opt/venv/bin/anta" ]
ENTRYPOINT [ "/root/.local/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
# that can be found in the LICENSE file.
"""Arista Network Test Automation (ANTA) Framework."""
import importlib.metadata
import os
@ -17,13 +16,10 @@ __credits__ = [
"Guillaume Mulocher",
"Thomas Grimonet",
]
__copyright__ = "Copyright 2022-2024, Arista Networks, Inc."
__copyright__ = "Copyright 2022, Arista EMEA AS"
# ANTA Debug Mode environment variable
__DEBUG__ = os.environ.get("ANTA_DEBUG", "").lower() == "true"
if __DEBUG__:
# enable asyncio DEBUG mode when __DEBUG__ is enabled
os.environ["PYTHONASYNCIODEBUG"] = "1"
# Global ANTA debug mode environment variable
__DEBUG__ = bool(os.environ.get("ANTA_DEBUG", "").lower() == "true")
# Source: https://rich.readthedocs.io/en/stable/appendix/colors.html
@ -48,4 +44,4 @@ RICH_COLOR_THEME = {
"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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Catalog related functions."""
"""
Catalog related functions
"""
from __future__ import annotations
import importlib
import logging
import math
from collections import defaultdict
from inspect import isclass
from itertools import chain
from json import load as json_load
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
from warnings import warn
from types import ModuleType
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_core import PydanticCustomError
from yaml import YAMLError, safe_dump, safe_load
from yaml import YAMLError, safe_load
from anta.logger import anta_log_exception
from anta.models import AntaTest
if TYPE_CHECKING:
import sys
from types import ModuleType
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
logger = logging.getLogger(__name__)
# { <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 > ), ... ]
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):
"""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)
test: type[AntaTest]
test: Type[AntaTest]
inputs: AntaTest.Input
@model_serializer()
def serialize_model(self) -> dict[str, AntaTest.Input]:
"""Serialize the AntaTestDefinition model.
The dictionary representing the model will be look like:
```
<AntaTest subclass name>:
<AntaTest.Input compliant dictionary>
```
Returns
-------
dict
A dictionary representing the model.
def __init__(self, **data: Any) -> None:
"""
return {self.test.__name__: self.inputs}
def __init__(self, **data: type[AntaTest] | AntaTest.Input | dict[str, Any] | None) -> None:
"""Inject test in the context to allow to instantiate Input in the BeforeValidator.
https://docs.pydantic.dev/2.0/usage/validators/#using-validation-context-with-basemodel-initialization.
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(
data,
@ -89,79 +56,65 @@ class AntaTestDefinition(BaseModel):
@field_validator("inputs", mode="before")
@classmethod
def instantiate_inputs(
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.
def instantiate_inputs(cls, data: AntaTest.Input | dict[str, Any] | None, info: ValidationInfo) -> AntaTest.Input:
"""
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.
This model validator will instantiate an Input class from the `test` class field.
"""
if info.context is None:
msg = "Could not validate inputs as no test class could be identified"
raise ValueError(msg)
raise ValueError("Could not validate inputs as no test class could be identified")
# 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
test_class = info.context["test"]
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(msg)
raise ValueError(f"Could not validate inputs as no test class {test_class} is not a subclass of AntaTest")
if isinstance(data, AntaTest.Input):
return data
try:
if data is None:
return test_class.Input()
if isinstance(data, AntaTest.Input):
return data
if isinstance(data, dict):
return test_class.Input(**data)
except ValidationError as e:
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)
raise ValueError(f"Coud not instantiate inputs as type {type(data).__name__} is not valid")
@model_validator(mode="after")
def check_inputs(self) -> Self:
"""Check the `inputs` field typing.
def check_inputs(self) -> "AntaTestDefinition":
"""
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):
msg = 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
raise ValueError(f"Test input has type {self.inputs.__class__.__qualname__} but expected type {self.test.Input.__qualname__}")
return self
class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods
"""Represents an ANTA Test Catalog File.
class AntaCatalogFile(RootModel[Dict[ImportString[Any], List[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods
"""
This model represents an ANTA Test Catalog File.
Example
-------
A valid test catalog file must have the following structure:
```
<Python module>:
- <AntaTest subclass>:
<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]]:
"""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:
generic:
@ -170,16 +123,14 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
- <AntaTestDefinition>
```
`anta.tests.routing.generic` and `anta.tests.routing.bgp` are importable Python modules.
"""
modules: dict[ModuleType, list[Any]] = {}
for module_name, tests in data.items():
if package and not module_name.startswith("."):
# PLW2901 - we redefine the loop variable on purpose here.
module_name = f".{module_name}" # noqa: PLW2901
module_name = f".{module_name}"
try:
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.
# 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 ''}"
@ -188,103 +139,50 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
raise ValueError(message) from e
if isinstance(tests, dict):
# This is an inner Python module
modules.update(AntaCatalogFile.flatten_modules(data=tests, package=module.__name__))
elif isinstance(tests, list):
modules.update(flatten_modules(data=tests, package=module.__name__))
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
modules[module] = tests
else:
msg = f"Syntax error when parsing: {tests}\nIt must be a list of ANTA tests. Check the test catalog."
raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError
return modules
# ANN401 - Any ok for this validator as we are validating the received data
# 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 not data:
return data
typed_data: dict[ModuleType, list[Any]] = AntaCatalogFile.flatten_modules(data)
typed_data: dict[ModuleType, list[Any]] = flatten_modules(data)
for module, tests in typed_data.items():
test_definitions: list[AntaTestDefinition] = []
for test_definition in tests:
if isinstance(test_definition, AntaTestDefinition):
test_definitions.append(test_definition)
continue
if not isinstance(test_definition, dict):
msg = f"Syntax error when parsing: {test_definition}\nIt must be a dictionary. Check the test catalog."
raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError
raise ValueError(f"Syntax error when parsing: {test_definition}\nIt must be a dictionary. Check the test catalog.")
if len(test_definition) != 1:
msg = (
f"Syntax error when parsing: {test_definition}\n"
"It must be a dictionary with a single entry. Check the indentation in the test catalog."
raise ValueError(
f"Syntax error when parsing: {test_definition}\nIt must be a dictionary with a single entry. Check the indentation in the test catalog."
)
raise ValueError(msg)
for test_name, test_inputs in test_definition.copy().items():
test: type[AntaTest] | None = getattr(module, test_name, None)
if test is None:
msg = (
f"{test_name} is not defined in Python module {module.__name__}"
f"{f' (from {module.__file__})' if module.__file__ is not None else ''}"
raise ValueError(
f"{test_name} is not defined in Python module {module.__name__}{f' (from {module.__file__})' if module.__file__ is not None else ''}"
)
raise ValueError(msg)
test_definitions.append(AntaTestDefinition(test=test, inputs=test_inputs))
typed_data[module] = test_definitions
return typed_data
return 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 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__(
self,
tests: list[AntaTestDefinition] | None = None,
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.
def __init__(self, tests: list[AntaTestDefinition] | None = None, filename: str | Path | None = None) -> None:
"""
Constructor of AntaCatalog.
Args:
tests: A list of AntaTestDefinition instances.
filename: The path from which the catalog is loaded.
"""
self._tests: list[AntaTestDefinition] = []
if tests is not None:
@ -296,61 +194,37 @@ class AntaCatalog:
else:
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
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
@property
def tests(self) -> list[AntaTestDefinition]:
"""List of AntaTestDefinition in this catalog."""
"""List of AntaTestDefinition in this catalog"""
return self._tests
@tests.setter
def tests(self, value: list[AntaTestDefinition]) -> None:
if not isinstance(value, list):
msg = "The catalog must contain a list of tests"
raise TypeError(msg)
raise ValueError("The catalog must contain a list of tests")
for t in value:
if not isinstance(t, AntaTestDefinition):
msg = "A test in the catalog must be an AntaTestDefinition instance"
raise TypeError(msg)
raise ValueError("A test in the catalog must be an AntaTestDefinition instance")
self._tests = value
@staticmethod
def parse(filename: str | Path, file_format: Literal["yaml", "json"] = "yaml") -> 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.
def parse(filename: str | Path) -> AntaCatalog:
"""
if file_format not in ["yaml", "json"]:
message = f"'{file_format}' is not a valid format for an AntaCatalog file. Only 'yaml' and 'json' are supported."
raise ValueError(message)
Create an AntaCatalog instance from a test catalog file.
Args:
filename: Path to test catalog YAML file
"""
try:
file: Path = filename if isinstance(filename, Path) else Path(filename)
with file.open(encoding="UTF-8") as f:
data = safe_load(f) if file_format == "yaml" else json_load(f)
except (TypeError, YAMLError, OSError, ValueError) as e:
with open(file=filename, mode="r", encoding="UTF-8") as file:
data = safe_load(file)
except (TypeError, YAMLError, OSError) as e:
message = f"Unable to parse ANTA Test Catalog file '{filename}'"
anta_log_exception(e, message, logger)
raise
@ -359,23 +233,15 @@ class AntaCatalog:
@staticmethod
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.
It is the data structure returned by `yaml.load()` function of a valid
YAML Test Catalog file.
Parameters
----------
data
Python dictionary used to instantiate the AntaCatalog instance.
filename
value to be set as AntaCatalog instance attribute
Returns
-------
AntaCatalog
An AntaCatalog populated with the 'data' dictionary content.
Args:
data: Python dictionary used to instantiate the AntaCatalog instance
filename: value to be set as AntaCatalog instance attribute
"""
tests: list[AntaTestDefinition] = []
if data is None:
@ -383,17 +249,12 @@ class AntaCatalog:
return AntaCatalog(filename=filename)
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 TypeError(msg)
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__}")
try:
catalog_data = AntaCatalogFile(data) # type: ignore[arg-type]
catalog_data = AntaCatalogFile(**data) # type: ignore[arg-type]
except ValidationError as e:
anta_log_exception(
e,
f"Test catalog is invalid!{f' (from {filename})' if filename is not None else ''}",
logger,
)
anta_log_exception(e, f"Test catalog is invalid!{f' (from {filename})' if filename is not None else ''}", logger)
raise
for t in catalog_data.root.values():
tests.extend(t)
@ -401,19 +262,12 @@ class AntaCatalog:
@staticmethod
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.
Parameters
----------
data
Python list used to instantiate the AntaCatalog instance.
Returns
-------
AntaCatalog
An AntaCatalog populated with the 'data' list content.
Args:
data: Python list used to instantiate the AntaCatalog instance
"""
tests: list[AntaTestDefinition] = []
try:
@ -423,120 +277,15 @@ class AntaCatalog:
raise
return AntaCatalog(tests)
@classmethod
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.
def get_tests_by_tags(self, tags: list[str], strict: bool = False) -> list[AntaTestDefinition]:
"""
combined_tests = list(chain(*(catalog.tests for catalog in catalogs)))
return cls(tests=combined_tests)
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.
Return all the tests that have matching tags in their input filters.
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.
"""
# TODO: Use a decorator to deprecate this method instead. See https://github.com/aristanetworks/anta/issues/754
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]] = {}
result: list[AntaTestDefinition] = []
for test in self.tests:
# Cannot use AntaTest.module property as the class is not instantiated
root.setdefault(test.test.__module__, []).append(test)
return AntaCatalogFile(root=root)
def build_indexes(self, filtered_tests: set[str] | None = None) -> None:
"""Indexes tests by their tags for quick access during filtering operations.
If a `filtered_tests` set is provided, only the tests in this set will be indexed.
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)
if test.inputs.filters and (f := test.inputs.filters.tags):
if (strict and all(t in tags for t in f)) or (not strict and any(t in tags for t in f)):
result.append(test)
return result

View file

@ -1,41 +1,72 @@
#!/usr/bin/env python
# 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."""
"""
ANTA CLI
"""
from __future__ import annotations
import logging
import pathlib
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.
try:
from ._main import anta, cli
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
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:
"""Error message if any CLI dependency is missing."""
print(
"The ANTA command line client could not run because the required "
"dependencies were not installed.\nMake sure you've installed "
"everything with: pip install 'anta[cli]'"
@click.group(cls=AliasedGroup)
@click.pass_context
@click.version_option(__version__)
@click.option(
"--log-file",
help="Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout.",
show_envvar=True,
type=click.Path(file_okay=True, dir_okay=False, writable=True, path_type=pathlib.Path),
)
if __DEBUG__:
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__":
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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Click commands to validate configuration files."""
"""
Click commands to validate configuration files
"""
import click
from anta.cli.check import commands
@ -10,7 +11,7 @@ from anta.cli.check import commands
@click.group
def check() -> None:
"""Commands to validate configuration files."""
"""Commands to validate configuration files"""
check.add_command(commands.catalog)

View file

@ -2,28 +2,28 @@
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
# pylint: disable = redefined-outer-name
"""Click commands to validate configuration files."""
"""
Click commands to validate configuration files
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import click
from rich.pretty import pretty_repr
from anta.catalog import AntaCatalog
from anta.cli.console import console
from anta.cli.utils import catalog_options
if TYPE_CHECKING:
from anta.catalog import AntaCatalog
logger = logging.getLogger(__name__)
@click.command
@catalog_options
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(pretty_repr(catalog.tests))

View file

@ -1,9 +1,9 @@
# 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 Top-level Console.
https://rich.readthedocs.io/en/stable/console.html#console-api.
"""
ANTA Top-level Console
https://rich.readthedocs.io/en/stable/console.html#console-api
"""
from rich.console import Console

View file

@ -1,8 +1,9 @@
# 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.
"""Click commands to execute EOS commands on remote devices."""
"""
Click commands to execute EOS commands on remote devices
"""
import click
from anta.cli.debug import commands
@ -10,7 +11,7 @@ from anta.cli.debug import commands
@click.group
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)

View file

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

View file

@ -1,56 +1,40 @@
# 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.
"""Utils functions to use with anta.cli.debug module."""
"""
Utils functions to use with anta.cli.debug module.
"""
from __future__ import annotations
import functools
import logging
from typing import TYPE_CHECKING, Any, Callable
from typing import Any
import click
from anta.cli.utils import ExitCode, core_options
if TYPE_CHECKING:
from anta.cli.utils import ExitCode, inventory_options
from anta.inventory import AntaInventory
logger = logging.getLogger(__name__)
def debug_options(f: Callable[..., Any]) -> Callable[..., Any]:
"""Click common options required to execute a command on a specific device."""
def debug_options(f: Any) -> Any:
"""Click common options required to execute a command on a specific device"""
@core_options
@click.option(
"--ofmt",
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",
)
@inventory_options
@click.option("--ofmt", 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("--device", "-d", type=str, required=True, help="Device from inventory to use")
@click.pass_context
@functools.wraps(f)
def wrapper(
ctx: click.Context,
*args: tuple[Any],
inventory: AntaInventory,
device: str,
**kwargs: Any,
) -> Any:
# 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)
def wrapper(ctx: click.Context, *args: tuple[Any], inventory: AntaInventory, tags: list[str] | None, device: str, **kwargs: dict[str, Any]) -> Any:
# pylint: disable=unused-argument
try:
d = inventory[device]
except KeyError as e:
message = f"Device {device} does not exist in Inventory"
logger.error(e, message)
ctx.exit(ExitCode.USAGE_ERROR)
return f(*args, device=d, **kwargs)

View file

@ -1,18 +1,19 @@
# 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.
"""Click commands to execute various scripts on EOS devices."""
"""
Click commands to execute various scripts on EOS devices
"""
import click
from anta.cli.exec import commands
@click.group("exec")
def _exec() -> None:
"""Commands to execute various scripts on EOS devices."""
@click.group
def exec() -> None: # pylint: disable=redefined-builtin
"""Commands to execute various scripts on EOS devices"""
_exec.add_command(commands.clear_counters)
_exec.add_command(commands.snapshot)
_exec.add_command(commands.collect_tech_support)
exec.add_command(commands.clear_counters)
exec.add_command(commands.snapshot)
exec.add_command(commands.collect_tech_support)

View file

@ -1,25 +1,22 @@
# 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.
"""Click commands to execute various scripts on EOS devices."""
"""
Click commands to execute various scripts on EOS devices
"""
from __future__ import annotations
import asyncio
import logging
import sys
from datetime import datetime, timezone
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING
import click
from yaml import safe_load
from anta.cli.console import console
from anta.cli.exec import utils
from anta.cli.exec.utils import clear_counters_utils, collect_commands, collect_scheduled_show_tech
from anta.cli.utils import inventory_options
if TYPE_CHECKING:
from anta.inventory import AntaInventory
logger = logging.getLogger(__name__)
@ -27,9 +24,9 @@ logger = logging.getLogger(__name__)
@click.command
@inventory_options
def clear_counters(inventory: AntaInventory, tags: set[str] | None) -> None:
"""Clear counter statistics on EOS devices."""
asyncio.run(utils.clear_counters(inventory, tags=tags))
def clear_counters(inventory: AntaInventory, tags: list[str] | None) -> None:
"""Clear counter statistics on EOS devices"""
asyncio.run(clear_counters_utils(inventory, tags=tags))
@click.command()
@ -48,57 +45,34 @@ def clear_counters(inventory: AntaInventory, tags: set[str] | None) -> None:
show_envvar=True,
type=click.Path(file_okay=False, dir_okay=True, exists=False, writable=True, path_type=Path),
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,
)
def snapshot(inventory: AntaInventory, tags: set[str] | None, commands_list: Path, output: Path) -> None:
"""Collect commands output from devices in inventory."""
console.print(f"Collecting data for {commands_list}")
console.print(f"Output directory is {output}")
def snapshot(inventory: AntaInventory, tags: list[str] | None, commands_list: Path, output: Path) -> None:
"""Collect commands output from devices in inventory"""
print(f"Collecting data for {commands_list}")
print(f"Output directory is {output}")
try:
with commands_list.open(encoding="UTF-8") as file:
with open(commands_list, "r", encoding="UTF-8") as file:
file_content = file.read()
eos_commands = safe_load(file_content)
except FileNotFoundError:
logger.error("Error reading %s", commands_list)
logger.error(f"Error reading {commands_list}")
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()
@inventory_options
@click.option(
"--output",
"-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("--output", "-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(
"--configure",
help=(
"[DEPRECATED] Ensure devices have 'aaa authorization exec default local' configured (required for SCP on EOS). "
"THIS WILL CHANGE THE CONFIGURATION OF YOUR NETWORK."
),
help="Ensure devices have 'aaa authorization exec default local' configured (required for SCP on EOS). THIS WILL CHANGE THE CONFIGURATION OF YOUR NETWORK.",
default=False,
is_flag=True,
show_default=True,
)
def collect_tech_support(
inventory: AntaInventory,
tags: set[str] | None,
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))
def collect_tech_support(inventory: AntaInventory, tags: list[str] | None, output: Path, latest: int | None, configure: bool) -> None:
"""Collect scheduled tech-support from EOS devices"""
asyncio.run(collect_scheduled_show_tech(inventory, output, configure, tags=tags, latest=latest))

View file

@ -2,35 +2,35 @@
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Exec CLI helpers."""
"""
Exec CLI helpers
"""
from __future__ import annotations
import asyncio
import itertools
import json
import logging
import re
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 anta.device import AntaDevice, AsyncEOSDevice
from anta.models import AntaCommand
from anta.tools import safe_command
from asynceapi import EapiCommandError
if TYPE_CHECKING:
from anta.inventory import AntaInventory
from anta.models import AntaCommand
EOS_SCHEDULED_TECH_SUPPORT = "/mnt/flash/schedule/tech-support"
INVALID_CHAR = "`~!@#$/"
logger = logging.getLogger(__name__)
async def clear_counters(anta_inventory: AntaInventory, tags: set[str] | None = None) -> None:
"""Clear counters."""
async def clear_counters_utils(anta_inventory: AntaInventory, tags: list[str] | None = None) -> None:
"""
Clear counters
"""
async def clear(dev: AntaDevice) -> None:
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)
for command in commands:
if not command.collected:
logger.error("Could not clear counters on device %s: %s", dev.name, command.errors)
logger.info("Cleared counters on %s (%s)", dev.name, dev.hw_model)
logger.error(f"Could not clear counters on device {dev.name}: {command.errors}")
logger.info(f"Cleared counters on {dev.name} ({dev.hw_model})")
logger.info("Connecting to devices...")
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...")
await asyncio.gather(*(clear(device) for device in devices))
async def collect_commands(
inv: AntaInventory,
commands: dict[str, list[str]],
commands: dict[str, str],
root_dir: Path,
tags: set[str] | None = None,
tags: list[str] | None = None,
) -> None:
"""Collect EOS commands."""
"""
Collect EOS commands
"""
async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "text"]) -> None:
outdir = Path() / root_dir / dev.name / outformat
outdir.mkdir(parents=True, exist_ok=True)
safe_command = re.sub(r"(/|\|$)", "_", command)
c = AntaCommand(command=command, ofmt=outformat)
await dev.collect(c)
if not c.collected:
logger.error("Could not collect commands on device %s: %s", dev.name, c.errors)
logger.error(f"Could not collect commands on device {dev.name}: {c.errors}")
return
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)
elif c.ofmt == "text":
outfile = outdir / f"{safe_command(command)}.log"
outfile = outdir / f"{safe_command}.log"
content = c.text_output
else:
logger.error("Command outformat is not in ['json', 'text'] for command '%s'", command)
return
with outfile.open(mode="w", encoding="UTF-8") as f:
f.write(content)
logger.info("Collected command '%s' from device %s (%s)", command, dev.name, dev.hw_model)
logger.info(f"Collected command '{command}' from device {dev.name} ({dev.hw_model})")
logger.info("Connecting to devices...")
await inv.connect_inventory()
devices = inv.get_inventory(established_only=True, tags=tags).devices
if not devices:
logger.info("No online device found. Exiting")
return
devices = inv.get_inventory(established_only=True, tags=tags).values()
logger.info("Collecting commands from remote devices")
coros = []
if "json_format" in commands:
@ -93,14 +90,18 @@ async def collect_commands(
res = await asyncio.gather(*coros, return_exceptions=True)
for r in res:
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:
"""Collect scheduled show-tech on devices."""
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
"""
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:
# Get the tech-support filename to retrieve
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}"
command = AntaCommand(command=cmd, ofmt="text")
await device.collect(command=command)
if not (command.collected and command.text_output):
logger.error("Unable to get tech-support filenames on %s: verify that %s is not empty", device.name, EOS_SCHEDULED_TECH_SUPPORT)
if command.collected and command.text_output:
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
filenames = [Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}") for f in command.text_output.splitlines()]
# Create directories
outdir = Path() / root_dir / f"{device.name.lower()}"
outdir.mkdir(parents=True, exist_ok=True)
@ -123,49 +124,38 @@ async def collect_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bo
await device.collect(command=command)
if command.collected and not command.text_output:
logger.debug("'aaa authorization exec default local' is not configured on device %s", device.name)
if not configure:
logger.error("Unable to collect tech-support on %s: configuration 'aaa authorization exec default local' is not present", device.name)
return
# TODO: ANTA 2.0.0
msg = (
"[DEPRECATED] Using '--configure' for collecting show-techs is deprecated and will be removed in ANTA 2.0.0. "
"Please add the required configuration on your devices before running this command from ANTA."
)
logger.warning(msg)
logger.debug(f"'aaa authorization exec default local' is not configured on device {device.name}")
if configure:
# Otherwise mypy complains about enable
assert isinstance(device, AsyncEOSDevice)
# TODO - @mtache - add `config` field to `AntaCommand` object to handle this use case.
commands = []
# TODO: @mtache - add `config` field to `AntaCommand` object to handle this use case.
# Otherwise mypy complains about enable as it is only implemented for AsyncEOSDevice
# TODO: Should enable be also included in AntaDevice?
if not isinstance(device, AsyncEOSDevice):
msg = "anta exec collect-tech-support is only supported with AsyncEOSDevice for now."
raise UsageError(msg)
if device.enable and device._enable_password is not None:
commands.append({"cmd": "enable", "input": device._enable_password})
if device.enable and device._enable_password is not None: # pylint: disable=protected-access
commands.append({"cmd": "enable", "input": device._enable_password}) # pylint: disable=protected-access
elif device.enable:
commands.append({"cmd": "enable"})
commands.extend(
[
{"cmd": "configure terminal"},
{"cmd": "aaa authorization exec default local"},
],
]
)
logger.warning("Configuring 'aaa authorization exec default local' on device %s", device.name)
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")
await device._session.cli(commands=commands)
logger.info("Configured 'aaa authorization exec default local' on device %s", device.name)
logger.debug("'aaa authorization exec default local' is already configured on device %s", device.name)
await device._session.cli(commands=commands) # pylint: disable=protected-access
logger.info(f"Configured 'aaa authorization exec default local' on device {device.name}")
else:
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")
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:
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...")
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))

View file

@ -1,8 +1,9 @@
# 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.
"""Click commands to get information from or generate inventories."""
"""
Click commands to get information from or generate inventories
"""
import click
from anta.cli.get import commands
@ -10,11 +11,10 @@ from anta.cli.get import commands
@click.group
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_ansible)
get.add_command(commands.inventory)
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
# that can be found in the LICENSE file.
# 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
import asyncio
import json
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any
import click
import requests
from cvprac.cvp_client import CvpClient
from cvprac.cvp_client_errors import CvpApiError
from rich.pretty import pretty_repr
@ -21,12 +20,10 @@ from rich.pretty import pretty_repr
from anta.cli.console import console
from anta.cli.get.utils import inventory_output_options
from anta.cli.utils import ExitCode, inventory_options
from .utils import create_inventory_from_ansible, create_inventory_from_cvp, explore_package, get_cv_token
if TYPE_CHECKING:
from anta.inventory import AntaInventory
from .utils import create_inventory_from_ansible, create_inventory_from_cvp, get_cv_token
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("--password", "-p", help="CloudVision password", type=str, required=True)
@click.option("--container", "-c", help="CloudVision container where devices are configured", type=str)
@click.option(
"--ignore-cert",
help="Ignore verifying the SSL certificate when connecting to CloudVision",
show_envvar=True,
is_flag=True,
default=False,
)
def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str | None, *, ignore_cert: bool) -> None:
"""Build ANTA inventory from CloudVision.
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.
def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str | None) -> None:
"""
# TODO: - Handle get_cv_token, get_inventory and get_devices_in_container failures.
logger.info("Getting authentication token for user '%s' from CloudVision instance '%s'", username, host)
try:
token = get_cv_token(cvp_ip=host, cvp_username=username, cvp_password=password, verify_cert=not ignore_cert)
except requests.exceptions.SSLError as error:
logger.error("Authentication to CloudVison failed: %s.", error)
ctx.exit(ExitCode.USAGE_ERROR)
Build ANTA inventory from Cloudvision
TODO - handle get_inventory and get_devices_in_container failure
"""
logger.info(f"Getting authentication token for user '{username}' from CloudVision instance '{host}'")
token = get_cv_token(cvp_ip=host, cvp_username=username, cvp_password=password)
clnt = CvpClient()
try:
clnt.connect(nodes=[host], username="", password="", api_token=token)
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)
logger.info("Connected to CloudVision instance '%s'", host)
logger.info(f"Connected to CloudVision instance '{host}'")
cvp_inventory = None
if container is None:
# 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()
else:
# 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)
try:
create_inventory_from_cvp(cvp_inventory, output)
except OSError as e:
logger.error(str(e))
ctx.exit(ExitCode.USAGE_ERROR)
@click.command
@ -93,19 +74,15 @@ def from_cvp(ctx: click.Context, output: Path, host: str, username: str, passwor
required=True,
)
def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_inventory: Path) -> None:
"""Build ANTA inventory from an ansible inventory YAML file.
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)
"""Build ANTA inventory from an ansible inventory YAML file"""
logger.info(f"Building inventory from ansible file '{ansible_inventory}'")
try:
create_inventory_from_ansible(
inventory=ansible_inventory,
output=output,
ansible_group=ansible_group,
)
except (ValueError, OSError) as e:
except ValueError as e:
logger.error(str(e))
ctx.exit(ExitCode.USAGE_ERROR)
@ -113,11 +90,10 @@ def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_i
@click.command
@inventory_options
@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."""
# TODO: @gmuloc - tags come from context - we cannot have everything..
# ruff: noqa: ARG001
logger.debug("Requesting devices for tags: %s", tags)
logger.debug(f"Requesting devices for tags: {tags}")
console.print("Current inventory content is:", style="white on blue")
if connected:
@ -129,32 +105,11 @@ def inventory(inventory: AntaInventory, tags: set[str] | None, *, connected: boo
@click.command
@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."""
tags: set[str] = set()
tags_found = []
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_json(json.dumps(sorted(tags), indent=2))
@click.command
@click.pass_context
@click.option("--module", help="Filter tests by module name.", default="anta.tests", show_default=True)
@click.option("--test", help="Filter by specific test name. If module is specified, searches only within that module.", type=str)
@click.option("--short", help="Display test names without their inputs.", is_flag=True, default=False)
@click.option("--count", help="Print only the number of tests found.", is_flag=True, default=False)
def tests(ctx: click.Context, module: str, test: str | None, *, short: bool, count: bool) -> None:
"""Show all builtin ANTA tests with an example output retrieved from each test documentation."""
try:
tests_found = explore_package(module, test_name=test, short=short, count=count)
if 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)
console.print_json(json.dumps(tags_found, indent=2))

View file

@ -1,41 +1,34 @@
# 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.
"""Utils functions to use with anta.cli.get.commands module."""
"""
Utils functions to use with anta.cli.get.commands module.
"""
from __future__ import annotations
import functools
import importlib
import inspect
import json
import logging
import pkgutil
import re
import sys
import textwrap
from pathlib import Path
from sys import stdin
from typing import Any, Callable
from typing import Any
import click
import requests
import urllib3
import yaml
from anta.cli.console import console
from anta.cli.utils import ExitCode
from anta.inventory import AntaInventory
from anta.inventory.models import AntaInventoryHost, AntaInventoryInput
from anta.models import AntaTest
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logger = logging.getLogger(__name__)
def inventory_output_options(f: Callable[..., Any]) -> Callable[..., Any]:
"""Click common options required when an inventory is being generated."""
def inventory_output_options(f: Any) -> Any:
"""Click common options required when an inventory is being generated"""
@click.option(
"--output",
@ -57,13 +50,7 @@ def inventory_output_options(f: Callable[..., Any]) -> Callable[..., Any]:
)
@click.pass_context
@functools.wraps(f)
def wrapper(
ctx: click.Context,
*args: tuple[Any],
output: Path,
overwrite: bool,
**kwargs: dict[str, Any],
) -> Any:
def wrapper(ctx: click.Context, *args: tuple[Any], output: Path, overwrite: bool, **kwargs: dict[str, Any]) -> Any:
# Boolean to check if the file is empty
output_is_not_empty = output.exists() and output.stat().st_size != 0
# Check overwrite when file is not empty
@ -71,10 +58,7 @@ def inventory_output_options(f: Callable[..., Any]) -> Callable[..., Any]:
is_tty = stdin.isatty()
if is_tty:
# File has content and it is in an interactive TTY --> Prompt user
click.confirm(
f"Your destination file '{output}' is not empty, continue?",
abort=True,
)
click.confirm(f"Your destination file '{output}' is not empty, continue?", abort=True)
else:
# 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)")
@ -85,102 +69,66 @@ def inventory_output_options(f: Callable[..., Any]) -> Callable[..., Any]:
return wrapper
def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str, *, verify_cert: bool) -> str:
"""Generate the authentication token from CloudVision using username and password.
def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str) -> str:
"""Generate AUTH token from CVP using password"""
# TODO, need to handle requests 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
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})
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"]
def write_inventory_to_file(hosts: list[AntaInventoryHost], output: Path) -> None:
"""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.
"""
"""Write a file inventory from pydantic models"""
i = AntaInventoryInput(hosts=hosts)
try:
with output.open(mode="w", encoding="UTF-8") as out_fd:
out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: yaml.safe_load(i.yaml())}))
logger.info("ANTA inventory file has been created: '%s'", output)
except OSError as exc:
msg = f"Could not write inventory to path '{output}'."
raise OSError(msg) from exc
with open(output, "w", encoding="UTF-8") as out_fd:
out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: i.model_dump(exclude_unset=True)}))
logger.info(f"ANTA inventory file has been created: '{output}'")
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 = []
for dev in inv:
logger.info(" * adding entry for %s", dev["hostname"])
hosts.append(
AntaInventoryHost(
name=dev["hostname"],
host=dev["ipAddress"],
tags={dev["containerName"].lower()},
)
)
logger.info(f" * adding entry for {dev['hostname']}")
hosts.append(AntaInventoryHost(name=dev["hostname"], host=dev["ipAddress"], tags=[dev["containerName"].lower()]))
write_inventory_to_file(hosts, output)
def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group: str = "all") -> None:
"""
Create an ANTA inventory from an Ansible inventory YAML file
Args:
inventory: Ansible Inventory file to read
output: ANTA inventory file to generate.
ansible_group: Ansible group from where to extract data.
"""
def find_ansible_group(data: dict[str, Any], group: str) -> dict[str, Any] | None:
"""Retrieve Ansible group from an input data dict."""
for k, v in data.items():
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
d = find_ansible_group(v, group)
if d is not None:
return d
return None
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:
hosts = []
for key, value in data.items():
if isinstance(value, dict) and "ansible_host" in value:
logger.info(" * adding entry for %s", key)
if isinstance(value, dict) and "ansible_host" in value.keys():
logger.info(f" * adding entry for {key}")
hosts.append(AntaInventoryHost(name=key, host=value["ansible_host"]))
elif isinstance(value, dict):
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
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:
with inventory.open(encoding="utf-8") as inv:
with open(inventory, encoding="utf-8") as inv:
ansible_inventory = yaml.safe_load(inv)
except yaml.constructor.ConstructorError as exc:
if exc.problem and "!vault" in exc.problem:
logger.error(
"`anta get from-ansible` does not support inline vaulted variables, comment them out to generate your inventory. "
"If the vaulted variable is necessary to build the inventory (e.g. `ansible_host`), it needs to be unvaulted for "
"`from-ansible` command to work."
)
msg = f"Could not parse {inventory}."
raise ValueError(msg) from exc
except OSError as exc:
msg = f"Could not parse {inventory}."
raise ValueError(msg) from exc
raise ValueError(f"Could not parse {inventory}.") from exc
if not ansible_inventory:
msg = f"Ansible inventory {inventory} is empty"
raise ValueError(msg)
raise ValueError(f"Ansible inventory {inventory} is empty")
ansible_inventory = find_ansible_group(ansible_inventory, ansible_group)
if ansible_inventory is None:
msg = f"Group {ansible_group} not found in Ansible inventory"
raise ValueError(msg)
raise ValueError(f"Group {ansible_group} not found in Ansible inventory")
ansible_hosts = deep_yaml_parsing(ansible_inventory)
write_inventory_to_file(ansible_hosts, output)
def explore_package(module_name: str, test_name: str | None = None, *, short: bool = False, count: bool = False) -> 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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Click commands that run ANTA tests using anta.runner."""
"""
Click commands that run ANTA tests using anta.runner
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import asyncio
import click
from anta.catalog import AntaCatalog
from anta.cli.nrfu import commands
from anta.cli.utils import AliasedGroup, catalog_options, inventory_options
from anta.result_manager import ResultManager
from anta.result_manager.models import AntaTestStatus
if TYPE_CHECKING:
from anta.catalog import AntaCatalog
from anta.inventory import AntaInventory
from anta.models import AntaTest
from anta.result_manager import ResultManager
from anta.runner import main
from .utils import anta_progress_bar, print_settings
class IgnoreRequiredWithHelp(AliasedGroup):
"""Custom Click Group.
"""
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
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]:
"""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
ctx.ensure_object(dict)
ctx.obj["args"] = args
if "--help" in args:
ctx.obj["_anta_help"] = True
@ -49,101 +51,31 @@ class IgnoreRequiredWithHelp(AliasedGroup):
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.pass_context
@inventory_options
@catalog_options
@click.option(
"--device",
"-d",
help="Run tests on a specific device. Can be provided multiple times.",
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."""
@click.option("--ignore-status", help="Always exit with success", show_envvar=True, is_flag=True, default=False)
@click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False)
def nrfu(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, catalog: AntaCatalog, ignore_status: bool, ignore_error: bool) -> None:
"""Run ANTA tests on devices"""
# If help is invoke somewhere, skip the command
if ctx.obj.get("_anta_help"):
return
# We use ctx.obj to pass stuff to the next Click functions
ctx.ensure_object(dict)
ctx.obj["result_manager"] = ResultManager()
ctx.obj["ignore_status"] = ignore_status
ctx.obj["ignore_error"] = ignore_error
ctx.obj["hide"] = set(hide) if hide else None
ctx.obj["catalog"] = catalog
ctx.obj["catalog_format"] = catalog_format
ctx.obj["inventory"] = inventory
ctx.obj["tags"] = tags
ctx.obj["device"] = device
ctx.obj["test"] = test
ctx.obj["dry_run"] = dry_run
print_settings(inventory, catalog)
with anta_progress_bar() as AntaTest.progress:
asyncio.run(main(ctx.obj["result_manager"], inventory, catalog, tags=tags))
# Invoke `anta nrfu table` if no command is passed
if not ctx.invoked_subcommand:
if ctx.invoked_subcommand is None:
ctx.invoke(commands.table)
nrfu.add_command(commands.table)
nrfu.add_command(commands.csv)
nrfu.add_command(commands.json)
nrfu.add_command(commands.text)
nrfu.add_command(commands.tpl_report)
nrfu.add_command(commands.md_report)

View file

@ -1,36 +1,33 @@
# 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.
"""Click commands that render ANTA tests results."""
"""
Click commands that render ANTA tests results
"""
from __future__ import annotations
import logging
import pathlib
from typing import Literal
import click
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__)
@click.command()
@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(
"--group-by",
default=None,
type=click.Choice(["device", "test"], case_sensitive=False),
help="Group result by test or device.",
required=False,
"--group-by", default=None, type=click.Choice(["device", "test"], case_sensitive=False), help="Group result by test or host. default none", required=False
)
def table(ctx: click.Context, group_by: Literal["device", "test"] | None) -> None:
"""ANTA command to check network state with table results."""
run_tests(ctx)
print_table(ctx, group_by=group_by)
def table(ctx: click.Context, device: str | None, test: str | None, group_by: str) -> None:
"""ANTA command to check network states with table result"""
print_table(results=ctx.obj["result_manager"], device=device, group_by=group_by, test=test)
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),
show_envvar=True,
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:
"""ANTA command to check network state with JSON results."""
run_tests(ctx)
print_json(ctx, output=output)
"""ANTA command to check network state with JSON result"""
print_json(results=ctx.obj["result_manager"], output=output)
exit_with_code(ctx)
@click.command()
@click.pass_context
def text(ctx: click.Context) -> None:
"""ANTA command to check network state with text results."""
run_tests(ctx)
print_text(ctx)
exit_with_code(ctx)
@click.command()
@click.pass_context
@click.option(
"--csv-output",
type=click.Path(
file_okay=True,
dir_okay=False,
exists=False,
writable=True,
path_type=pathlib.Path,
),
show_envvar=True,
required=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)
@click.option("--search", "-s", help="Regular expression to search in both name and test", type=str, required=False)
@click.option("--skip-error", help="Hide tests in errors due to connectivity issue", default=False, is_flag=True, show_default=True, required=False)
def text(ctx: click.Context, search: str | None, skip_error: bool) -> None:
"""ANTA command to check network states with text result"""
print_text(results=ctx.obj["result_manager"], search=search, skip_error=skip_error)
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",
)
def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path | None) -> None:
"""ANTA command to check network state with templated report."""
run_tests(ctx)
"""ANTA command to check network state with templated report"""
print_jinja(results=ctx.obj["result_manager"], template=template, output=output)
exit_with_code(ctx)
@click.command()
@click.pass_context
@click.option(
"--md-output",
type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path),
show_envvar=True,
required=True,
help="Path to save the report as a Markdown file",
)
def md_report(ctx: click.Context, md_output: pathlib.Path) -> None:
"""ANTA command to check network state with Markdown report."""
run_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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Utils functions to use with anta.cli.nrfu.commands module."""
"""
Utils functions to use with anta.cli.nrfu.commands module.
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import TYPE_CHECKING, Literal
import pathlib
import re
import rich
from rich.panel import Panel
from rich.pretty import pprint
from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn
from anta.cli.console import console
from anta.cli.utils import ExitCode
from anta.models import AntaTest
from anta.reporter import ReportJinja, ReportTable
from anta.reporter.csv_reporter import ReportCsv
from anta.reporter.md_reporter import MDReportGenerator
from anta.runner import main
if TYPE_CHECKING:
import pathlib
import click
from anta.catalog import AntaCatalog
from anta.cli.console import console
from anta.inventory import AntaInventory
from anta.reporter import ReportJinja, ReportTable
from anta.result_manager import ResultManager
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(
inventory: AntaInventory,
catalog: AntaCatalog,
) -> None:
"""Print ANTA settings before running tests."""
message = f"- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests"
"""Print ANTA settings before running 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()
def print_table(ctx: click.Context, group_by: Literal["device", "test"] | None = None) -> None:
"""Print result in a table."""
def print_table(results: ResultManager, device: str | None = None, test: str | None = None, group_by: str | None = None) -> None:
"""Print result in a table"""
reporter = ReportTable()
console.print()
results = _get_result_manager(ctx)
if group_by == "device":
console.print(reporter.report_summary_devices(results))
if device:
console.print(reporter.report_all(result_manager=results, host=device))
elif test:
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":
console.print(reporter.report_summary_tests(results))
console.print(reporter.report_summary_tests(result_manager=results, testcase=None))
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:
"""Print results as JSON. If output is provided, save to file instead."""
results = _get_result_manager(ctx)
if output is None:
def print_json(results: ResultManager, output: pathlib.Path | None = None) -> None:
"""Print result in a json format"""
console.print()
console.print(Panel("JSON results", style="cyan"))
rich.print_json(results.json)
else:
try:
with output.open(mode="w", encoding="utf-8") as file:
file.write(results.json)
console.print(f"JSON results saved to {output}", style="cyan")
except OSError:
console.print(f"Failed to save JSON results to {output}", style="cyan")
ctx.exit(ExitCode.USAGE_ERROR)
console.print(Panel("JSON results of all tests", style="cyan"))
rich.print_json(results.get_json_results())
if output is not None:
with open(output, "w", encoding="utf-8") as fout:
fout.write(results.get_json_results())
def print_text(ctx: click.Context) -> None:
"""Print results as simple text."""
def print_list(results: ResultManager, output: pathlib.Path | None = None) -> None:
"""Print result in a list"""
console.print()
for test in _get_result_manager(ctx).results:
if len(test.messages) <= 1:
message = test.messages[0] if len(test.messages) == 1 else ""
console.print(f"{test.name} :: {test.test} :: [{test.result}]{test.result.upper()}[/{test.result}]({message})", highlight=False)
else: # len(test.messages) > 1
console.print(f"{test.name} :: {test.test} :: [{test.result}]{test.result.upper()}[/{test.result}]", highlight=False)
console.print("\n".join(f" {message}" for message in test.messages), highlight=False)
console.print(Panel.fit("List results of all tests", style="cyan"))
pprint(results.get_results())
if output is not None:
with open(output, "w", encoding="utf-8") as fout:
fout.write(str(results.get_results()))
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:
"""Print result based on template."""
console.print()
reporter = ReportJinja(template_path=template)
json_data = json.loads(results.json)
json_data = json.loads(results.get_json_results())
report = reporter.render(json_data)
console.print(report)
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)
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
# so ignore warning for redefinition
rich.spinner.SPINNERS = { # type: ignore[attr-defined]
rich.spinner.SPINNERS = { # type: ignore[attr-defined] # noqa: F811
"anta": {
"interval": 150,
"frames": [
@ -183,12 +112,14 @@ rich.spinner.SPINNERS = { # type: ignore[attr-defined]
"( 🐌 )",
"( 🐌)",
],
},
}
}
def anta_progress_bar() -> Progress:
"""Return a customized Progress for progress bar."""
"""
Return a customized Progress for progress bar
"""
return Progress(
SpinnerColumn("anta"),
TextColumn(""),

View file

@ -1,22 +1,24 @@
# 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.
"""Utils functions to use with anta.cli module."""
"""
Utils functions to use with anta.cli module.
"""
from __future__ import annotations
import enum
import functools
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
from typing import TYPE_CHECKING, Any
import click
from pydantic import ValidationError
from yaml import YAMLError
from anta.catalog import AntaCatalog
from anta.inventory import AntaInventory
from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError
from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError
if TYPE_CHECKING:
from click import Option
@ -25,7 +27,10 @@ logger = logging.getLogger(__name__)
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.
OK = 0
@ -39,17 +44,19 @@ class ExitCode(enum.IntEnum):
TESTS_FAILED = 4
def parse_tags(ctx: click.Context, param: Option, value: str | None) -> set[str] | None:
# ruff: noqa: ARG001
"""Click option callback to parse an ANTA inventory tags."""
def parse_tags(ctx: click.Context, param: Option, value: str) -> list[str] | None:
# pylint: disable=unused-argument
"""
Click option callback to parse an ANTA inventory tags
"""
if value is not None:
return set(value.split(",")) if "," in value else {value}
return value.split(",") if "," in value else [value]
return 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`
from the `ResultManger` instance.
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:
* 0 if `ignore_status` is `True` or global test status is `unset`, `skipped` or `success`
* 1 if status is `failure`
* 2 if status is `error`.
Parameters
----------
ctx
Click Context.
* 2 if status is `error`
Args:
ctx: Click Context
"""
if ctx.obj.get("ignore_status"):
ctx.exit(ExitCode.OK)
@ -79,19 +83,18 @@ def exit_with_code(ctx: click.Context) -> None:
ctx.exit(ExitCode.TESTS_ERROR)
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(msg)
raise ValueError(f"Unknown status returned by the ResultManager: {status}. Please gather logs and open an issue on Github.")
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)
From Click documentation.
From Click documentation
"""
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)
if rv is not None:
return rv
@ -104,16 +107,15 @@ class AliasedGroup(click.Group):
return None
def resolve_command(self, ctx: click.Context, args: Any) -> Any:
"""Todo: document code."""
"""Todo: document code"""
# always return the full command name
_, cmd, args = super().resolve_command(ctx, args)
if not cmd:
return None, None, None
return cmd.name, cmd, args
return cmd.name, cmd, args # type: ignore
def core_options(f: Callable[..., Any]) -> Callable[..., Any]:
"""Click common options when requiring an inventory to interact with devices."""
# TODO: check code of click.pass_context that raise mypy errors for types and adapt this decorator
def inventory_options(f: Any) -> Any:
"""Click common options when requiring an inventory to interact with devices"""
@click.option(
"--username",
@ -157,105 +159,35 @@ def core_options(f: Callable[..., Any]) -> Callable[..., Any]:
)
@click.option(
"--timeout",
help="Global API timeout. This value will be used for all devices.",
default=30.0,
help="Global connection timeout",
default=30,
show_envvar=True,
envvar="ANTA_TIMEOUT",
show_default=True,
)
@click.option(
"--insecure",
help="Disable SSH Host Key validation.",
help="Disable SSH Host Key validation",
default=False,
show_envvar=True,
envvar="ANTA_INSECURE",
is_flag=True,
show_default=True,
)
@click.option(
"--disable-cache",
help="Disable cache globally.",
show_envvar=True,
envvar="ANTA_DISABLE_CACHE",
show_default=True,
is_flag=True,
default=False,
)
@click.option("--disable-cache", help="Disable cache globally", show_envvar=True, envvar="ANTA_DISABLE_CACHE", show_default=True, is_flag=True, default=False)
@click.option(
"--inventory",
"-i",
help="Path to the inventory YAML file.",
help="Path to the inventory YAML file",
envvar="ANTA_INVENTORY",
show_envvar=True,
required=True,
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(
"--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,
envvar="ANTA_TAGS",
type=str,
@ -267,59 +199,75 @@ def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]:
def wrapper(
ctx: click.Context,
*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],
) -> Any:
# pylint: disable=too-many-arguments
# If help is invoke somewhere, do not parse inventory
if ctx.obj.get("_anta_help"):
return f(*args, tags=tags, **kwargs)
return f(*args, tags=tags, **kwargs)
return f(*args, inventory=None, 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
def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]:
"""Click common options when requiring a test catalog to execute ANTA tests."""
def catalog_options(f: Any) -> Any:
"""Click common options when requiring a test catalog to execute ANTA tests"""
@click.option(
"--catalog",
"-c",
envvar="ANTA_CATALOG",
show_envvar=True,
help="Path to the test catalog file",
type=click.Path(
file_okay=True,
dir_okay=False,
exists=True,
readable=True,
path_type=Path,
),
help="Path to the test catalog YAML file",
type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path),
required=True,
)
@click.option(
"--catalog-format",
envvar="ANTA_CATALOG_FORMAT",
show_envvar=True,
help="Format of the catalog file, either 'yaml' or 'json'",
default="yaml",
type=click.Choice(["yaml", "json"], case_sensitive=False),
)
@click.pass_context
@functools.wraps(f)
def wrapper(
ctx: click.Context,
*args: tuple[Any],
catalog: Path,
catalog_format: str,
**kwargs: dict[str, Any],
) -> Any:
def wrapper(ctx: click.Context, *args: tuple[Any], catalog: Path, **kwargs: dict[str, Any]) -> Any:
# If help is invoke somewhere, do not parse catalog
if ctx.obj.get("_anta_help"):
return f(*args, catalog=None, **kwargs)
try:
file_format = catalog_format.lower()
c = AntaCatalog.parse(catalog, file_format=file_format) # type: ignore[arg-type]
except (TypeError, ValueError, YAMLError, OSError):
c = AntaCatalog.parse(catalog)
except (ValidationError, TypeError, ValueError, YAMLError, OSError):
ctx.exit(ExitCode.USAGE_ERROR)
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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module that provides predefined types for AntaTest.Input instances."""
"""
Module that provides predefined types for AntaTest.Input instances
"""
import re
from typing import Annotated, Literal
from typing import Literal
from pydantic import Field
from pydantic.functional_validators import AfterValidator, BeforeValidator
# 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."""
from typing_extensions import Annotated
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"]
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:
- `et`, `eth` will be changed to `Ethernet`
- `po` will be changed to `Port-Channel`
- `lo` will be changed to `Loopback`
"""
intf_id_re = re.compile(REGEXP_INTERFACE_ID)
- `lo` will be changed to `Loopback`"""
intf_id_re = re.compile(r"[0-9]+(\/[0-9]+)*(\.[0-9]+)?")
m = intf_id_re.search(v)
if m is None:
msg = f"Could not parse interface ID in interface '{v}'"
raise ValueError(msg)
raise ValueError(f"Could not parse interface ID in interface '{v}'")
intf_id = m[0]
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:
"""Reformat interface name to match expected case sensitivity.
Examples
--------
Examples:
- ethernet -> Ethernet
- vlan -> Vlan
- 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 v
def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str:
"""Abbreviations for different BGP multiprotocol capabilities.
Examples
--------
"""
Abbreviations for different BGP multiprotocol capabilities.
Examples:
- IPv4 Unicast
- L2vpnEVPN
- ipv4 MPLS Labels
- ipv4Mplsvpn
"""
patterns = {
REGEXP_BGP_L2VPN_AFI: "l2VpnEvpn",
REGEXP_BGP_IPV4_MPLS_LABELS: "ipv4MplsLabels",
REGEX_BGP_IPV4_MPLS_VPN: "ipv4MplsVpn",
REGEX_BGP_IPV4_UNICAST: "ipv4Unicast",
r"\b(l2[\s\-]?vpn[\s\-]?evpn)\b": "l2VpnEvpn",
r"\bipv4[\s_-]?mpls[\s_-]?label(s)?\b": "ipv4MplsLabels",
r"\bipv4[\s_-]?mpls[\s_-]?vpn\b": "ipv4MplsVpn",
r"\bipv4[\s_-]?uni[\s_-]?cast\b": "ipv4Unicast",
}
for pattern, replacement in patterns.items():
@ -104,15 +77,8 @@ def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str:
return value
def validate_regex(value: str) -> str:
"""Validate that the input value is a valid regex format."""
try:
re.compile(value)
except re.error as e:
msg = f"Invalid regex: {e}"
raise ValueError(msg) from e
return value
# ANTA framework
TestStatus = Literal["unset", "success", "failure", "error", "skipped"]
# AntaTest.Input types
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)]
Interface = Annotated[
str,
Field(pattern=REGEXP_TYPE_EOS_INTERFACE),
BeforeValidator(interface_autocomplete),
BeforeValidator(interface_case_sensitivity),
]
EthernetInterface = Annotated[
str,
Field(pattern=r"^Ethernet[0-9]+(\/[0-9]+)*$"),
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),
]
VxlanSrcIntf = Annotated[
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_case_sensitivity),
]
PortChannelInterface = Annotated[
str,
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"]
Afi = Literal["ipv4", "ipv6", "vpn-ipv4", "vpn-ipv6", "evpn", "rt-membership"]
Safi = Literal["unicast", "multicast", "labeled-unicast"]
EncryptionAlgorithm = Literal["RSA", "ECDSA"]
RsaKeySize = Literal[2048, 3072, 4096]
EcdsaKeySize = Literal[256, 384, 512]
EcdsaKeySize = Literal[256, 384, 521]
MultiProtocolCaps = Annotated[str, BeforeValidator(bgp_multiprotocol_capabilities_abbreviations)]
BfdInterval = Annotated[int, Field(ge=50, le=60000)]
BfdMultiplier = Annotated[int, Field(ge=3, le=50)]
@ -166,75 +120,3 @@ ErrDisableReasons = Literal[
"uplink-failure-detection",
]
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
# that can be found in the LICENSE file.
"""decorators for tests."""
from __future__ import annotations
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
if TYPE_CHECKING:
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])
# TODO: Remove this decorator in ANTA v2.0.0 in favor of deprecated_test_class
def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]: # pragma: no cover
"""Return a decorator to log a message of WARNING severity when a test is deprecated.
def deprecated_test(new_tests: Optional[list[str]] = None) -> Callable[[F], F]:
"""
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.
Returns
-------
Callable[[F], F]
A decorator that can be used to wrap test functions.
Args:
new_tests (Optional[list[str]]): 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.
"""
def decorator(function: F) -> F:
"""Actual decorator that logs the message.
"""
Actual decorator that logs the message.
Parameters
----------
function
The test function to be decorated.
Returns
-------
F
The decorated function.
Args:
function (F): The test function to be decorated.
Returns:
F: The decorated function.
"""
@wraps(function)
@ -53,9 +43,9 @@ def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]: #
anta_test = args[0]
if 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:
logger.warning("%s test is deprecated.", anta_test.name)
logger.warning(f"{anta_test.name} test is deprecated.")
return await function(*args, **kwargs)
return cast(F, wrapper)
@ -63,93 +53,35 @@ def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]: #
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]:
"""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
the test is run on. If the model is in the list of platforms specified, the test will be skipped.
Parameters
----------
platforms
List of hardware models on which the test should be skipped.
Returns
-------
Callable[[F], F]
A decorator that can be used to wrap test functions.
Args:
platforms (list[str]): 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.
"""
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
----------
function
The test function to be decorated.
Returns
-------
F
The decorated function.
Args:
function (F): The test function to be decorated.
Returns:
F: The decorated function.
"""
@wraps(function)
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.
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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""ANTA Device Abstraction Module."""
"""
ANTA Device Abstraction Module
"""
from __future__ import annotations
import asyncio
import logging
from abc import ABC, abstractmethod
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 httpcore
from aiocache import Cache
from aiocache.plugins import HitMissRatioPlugin
from asyncssh import SSHClientConnection, SSHClientConnectionOptions
from httpx import ConnectError, HTTPError, TimeoutException
from httpx import ConnectError, HTTPError
import asynceapi
from anta import __DEBUG__
from anta.logger import anta_log_exception, exc_to_str
from anta import __DEBUG__, aioeapi
from anta.models import AntaCommand
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
from anta.tools.misc import exc_to_str
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):
"""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
`refresh()`.
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.
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.
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
cache: In-memory cache from aiocache library for this device (None if cache is disabled)
cache_locks: Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled
"""
def __init__(self, name: str, tags: set[str] | None = None, *, disable_cache: bool = False) -> None:
"""Initialize an AntaDevice.
Parameters
----------
name
Device name.
tags
Tags for this device.
disable_cache
Disable caching for all commands for this device.
def __init__(self, name: str, tags: Optional[list[str]] = None, disable_cache: bool = False) -> None:
"""
Constructor of AntaDevice
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.hw_model: str | None = None
self.tags: set[str] = tags if tags is not None else set()
self.hw_model: Optional[str] = None
self.tags: list[str] = tags if tags is not None else []
# 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.established: bool = False
self.cache: Cache | None = None
self.cache_locks: defaultdict[str, asyncio.Lock] | None = None
self.cache: Optional[Cache] = None
self.cache_locks: Optional[defaultdict[str, asyncio.Lock]] = None
# Initialize cache if not disabled
if not disable_cache:
@ -89,24 +68,34 @@ class AntaDevice(ABC):
@property
@abstractmethod
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:
"""Implement equality for AntaDevice objects."""
"""
Implement equality for AntaDevice objects.
"""
return self._keys == other._keys if isinstance(other, self.__class__) else False
def __hash__(self) -> int:
"""Implement hashing for AntaDevice objects."""
"""
Implement hashing for AntaDevice objects.
"""
return hash(self._keys)
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_locks = defaultdict(asyncio.Lock)
@property
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
# https://github.com/pylint-dev/pylint/issues/7258
if self.cache is not None:
@ -115,9 +104,9 @@ class AntaDevice(ABC):
return None
def __rich_repr__(self) -> Iterator[tuple[str, Any]]:
"""Implement Rich Repr Protocol.
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol.
"""
Implements Rich Repr Protocol
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
"""
yield "name", self.name
yield "tags", self.tags
@ -126,21 +115,10 @@ class AntaDevice(ABC):
yield "established", self.established
yield "disable_cache", self.cache is None
def __repr__(self) -> str:
"""Return a printable representation of an AntaDevice."""
return (
f"AntaDevice({self.name!r}, "
f"tags={self.tags!r}, "
f"hw_model={self.hw_model!r}, "
f"is_online={self.is_online!r}, "
f"established={self.established!r}, "
f"disable_cache={self.cache is None!r})"
)
@abstractmethod
async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None:
"""Collect device command output.
async def _collect(self, command: AntaCommand) -> None:
"""
Collect device command output.
This abstract coroutine can be used to implement any command collection method
for a device in ANTA.
@ -151,16 +129,13 @@ class AntaDevice(ABC):
exception and implement proper logging, the `output` attribute of the
`AntaCommand` object passed as argument would be `None` in this case.
Parameters
----------
command
The command to collect.
collection_id
An identifier used to build the eAPI request ID.
Args:
command: the command to collect
"""
async def collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None:
"""Collect the output for a specified command.
async def collect(self, command: AntaCommand) -> None:
"""
Collects the output for a specified 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,
@ -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
via the private `_collect` method without interacting with the cache.
Parameters
----------
command
The command to collect.
collection_id
An identifier used to build the eAPI request ID.
Args:
command (AntaCommand): The command to process.
"""
# 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
@ -184,125 +155,100 @@ class AntaDevice(ABC):
cached_output = await self.cache.get(command.uid) # pylint: disable=no-member
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
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
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:
"""Collect multiple commands.
Parameters
----------
commands
The commands to collect.
collection_id
An identifier used to build the eAPI request ID.
async def collect_commands(self, commands: list[AntaCommand]) -> None:
"""
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
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:
- `is_online`: When the device IP is reachable and a port can be open.
- `established`: When a command execution succeeds.
- `hw_model`: The hardware model of the device.
- `is_online`: When the device IP is reachable and a port can be open
- `established`: When a command execution succeeds
- `hw_model`: The hardware model of the device
"""
async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None:
"""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.
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.
"""
_ = (sources, destination, direction)
msg = f"copy() method has not been implemented in {self.__class__.__name__} definition"
raise NotImplementedError(msg)
raise NotImplementedError(f"copy() method has not been implemented in {self.__class__.__name__} definition")
class AsyncEOSDevice(AntaDevice):
"""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.
"""
Implementation of AntaDevice for EOS using aio-eapi.
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,
host: str,
username: str,
password: str,
name: str | None = 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",
*,
name: Optional[str] = None,
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,
proto: Literal["http", "https"] = "https",
disable_cache: bool = False,
) -> None:
"""Instantiate an 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.
"""
Constructor of AsyncEOSDevice
Args:
host: Device FQDN or IP
username: Username to connect to eAPI and SSH
password: Password to connect to eAPI and SSH
name: Device name
enable: 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:
message = "'host' is required to create an AsyncEOSDevice"
@ -310,7 +256,7 @@ class AsyncEOSDevice(AntaDevice):
raise ValueError(message)
if name is None:
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:
message = f"'username' is required to instantiate device '{self.name}'"
logger.error(message)
@ -321,18 +267,16 @@ class AsyncEOSDevice(AntaDevice):
raise ValueError(message)
self.enable = enable
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] = {}
if insecure:
ssh_params["known_hosts"] = None
self._ssh_opts: SSHClientConnectionOptions = SSHClientConnectionOptions(
host=host, port=ssh_port, username=username, password=password, client_keys=CLIENT_KEYS, **ssh_params
)
self._ssh_opts: SSHClientConnectionOptions = SSHClientConnectionOptions(host=host, port=ssh_port, username=username, password=password, **ssh_params)
def __rich_repr__(self) -> Iterator[tuple[str, Any]]:
"""Implement Rich Repr Protocol.
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol.
"""
Implements Rich Repr Protocol
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
"""
yield from super().__rich_repr__()
yield ("host", self._session.host)
@ -342,154 +286,107 @@ class AsyncEOSDevice(AntaDevice):
yield ("insecure", self._ssh_opts.known_hosts is None)
if __DEBUG__:
_ssh_opts = vars(self._ssh_opts).copy()
removed_pw = "<removed>"
_ssh_opts["password"] = removed_pw
_ssh_opts["kwargs"]["password"] = removed_pw
PASSWORD_VALUE = "<removed>"
_ssh_opts["password"] = PASSWORD_VALUE
_ssh_opts["kwargs"]["password"] = PASSWORD_VALUE
yield ("_session", vars(self._session))
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
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.
"""
return (self._session.host, self._session.port)
async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None:
"""Collect device command output from EOS using aio-eapi.
async def _collect(self, command: AntaCommand) -> None:
"""
Collect device command output from EOS using aio-eapi.
Supports outformat `json` and `text` as output structure.
Gain privileged access using the `enable_password` attribute
of the `AntaDevice` instance if populated.
Parameters
----------
command
The command to collect.
collection_id
An identifier used to build the eAPI request ID.
Args:
command: the command to collect
"""
commands: list[dict[str, str | int]] = []
commands = []
if self.enable and self._enable_password is not None:
commands.append(
{
"cmd": "enable",
"input": str(self._enable_password),
},
}
)
elif self.enable:
# No password
commands.append({"cmd": "enable"})
commands += [{"cmd": command.command, "revision": command.revision}] if command.revision else [{"cmd": command.command}]
if command.revision:
commands.append({"cmd": command.command, "revision": command.revision})
else:
commands.append({"cmd": command.command})
try:
response: list[dict[str, Any] | str] = await self._session.cli(
response: list[dict[str, Any]] = await self._session.cli(
commands=commands,
ofmt=command.ofmt,
version=command.version,
req_id=f"ANTA-{collection_id}-{id(command)}" if collection_id else f"ANTA-{id(command)}",
) # 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:
# 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."""
except aioeapi.EapiCommandError as e:
command.errors = e.errors
if command.requires_privileges:
logger.error("Command '%s' requires privileged mode on %s. Verify user permissions and if the `enable` option is required.", command.command, self.name)
if not command.supported:
logger.debug("Command '%s' is not supported on '%s' (%s)", command.command, self.name, self.hw_model)
elif command.returned_known_eos_error:
logger.debug("Command '%s' returned a known error '%s': %s", command.command, self.name, command.errors)
if self.supports(command):
message = f"Command '{command.command}' failed on {self.name}"
logger.error(message)
except (HTTPError, ConnectError) as e:
command.errors = [str(e)]
message = f"Cannot connect to device {self.name}"
logger.error(message)
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:
"""Update attributes of an AsyncEOSDevice instance.
"""
Update attributes of an AsyncEOSDevice instance.
This coroutine must update the following attributes of AsyncEOSDevice:
- is_online: When a device IP is reachable and a port can be open
- established: When a command execution succeeds
- 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()
if self.is_online:
show_version = AntaCommand(command="show version")
await self._collect(show_version)
if not show_version.collected:
logger.warning("Cannot get hardware information from device %s", self.name)
COMMAND: str = "show version"
HW_MODEL_KEY: str = "modelName"
try:
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:
self.hw_model = show_version.json_output.get("modelName", None)
if self.hw_model is None:
logger.critical("Cannot parse 'show version' returned by device %s", self.name)
# 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)
if HW_MODEL_KEY in response:
self.hw_model = response[HW_MODEL_KEY]
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)
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().
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.
"""
Copy files to and from the device using asyncssh.scp().
Args:
sources: List of files to copy to or from the device.
destination: Local or remote destination when copying the files. Can be a folder.
direction: Defines if this coroutine copies files to or from the device.
"""
async with asyncssh.connect(
host=self._ssh_opts.host,
@ -499,24 +396,22 @@ class AsyncEOSDevice(AntaDevice):
local_addr=self._ssh_opts.local_addr,
options=self._ssh_opts,
) as conn:
src: list[tuple[SSHClientConnection, Path]] | list[Path]
dst: tuple[SSHClientConnection, Path] | Path
src: Union[list[tuple[SSHClientConnection, Path]], list[Path]]
dst: Union[tuple[SSHClientConnection, Path], Path]
if direction == "from":
src = [(conn, file) for file in sources]
dst = destination
for file in sources:
message = f"Copying '{file}' from device {self.name} to '{destination}' locally"
logger.info(message)
logger.info(f"Copying '{file}' from device {self.name} to '{destination}' locally")
elif direction == "to":
src = sources
dst = conn, destination
for file in src:
message = f"Copying '{file}' to device {self.name} to '{destination}' remotely"
logger.info(message)
logger.info(f"Copying '{file}' to device {self.name} to '{destination}' remotely")
else:
logger.critical("'direction' argument to copy() function is invalid: %s", direction)
logger.critical(f"'direction' argument to copy() fonction is invalid: {direction}")
return
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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Inventory module for ANTA."""
"""
Inventory Module for ANTA.
"""
from __future__ import annotations
@ -9,29 +11,32 @@ import asyncio
import logging
from ipaddress import ip_address, ip_network
from pathlib import Path
from typing import Any, ClassVar
from typing import Any, Optional
from pydantic import ValidationError
from yaml import YAMLError, safe_load
from anta.device import AntaDevice, AsyncEOSDevice
from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError
from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError
from anta.inventory.models import AntaInventoryInput
from anta.logger import anta_log_exception
logger = logging.getLogger(__name__)
class AntaInventory(dict[str, AntaDevice]):
"""Inventory abstraction for ANTA framework."""
class AntaInventory(dict): # type: ignore
# 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
INVENTORY_ROOT_KEY = "anta_inventory"
# Supported Output format
INVENTORY_OUTPUT_FORMAT: ClassVar[list[str]] = ["native", "json"]
INVENTORY_OUTPUT_FORMAT = ["native", "json"]
def __str__(self) -> str:
"""Human readable string representing the inventory."""
"""Human readable string representing the inventory"""
devs = {}
for dev in self.values():
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()])}"
@staticmethod
def _update_disable_cache(kwargs: dict[str, Any], *, inventory_disable_cache: bool) -> dict[str, Any]:
"""Return new dictionary, replacing kwargs with added disable_cache value from inventory_value if disable_cache has not been set by CLI.
Parameters
----------
inventory_disable_cache
The value of disable_cache in the inventory.
kwargs
The kwargs to instantiate the device.
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.
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["disable_cache"] = inventory_disable_cache or kwargs.get("disable_cache")
return updated_kwargs
@staticmethod
def _parse_hosts(
inventory_input: AntaInventoryInput,
inventory: AntaInventory,
**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.
def _parse_hosts(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None:
"""
Parses the host section of an AntaInventoryInput and add the devices to the inventory
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:
return
for host in inventory_input.hosts:
updated_kwargs = AntaInventory._update_disable_cache(kwargs, inventory_disable_cache=host.disable_cache)
device = AsyncEOSDevice(
name=host.name,
host=str(host.host),
port=host.port,
tags=host.tags,
**updated_kwargs,
)
updated_kwargs = AntaInventory._update_disable_cache(host.disable_cache, kwargs)
device = AsyncEOSDevice(name=host.name, host=str(host.host), port=host.port, tags=host.tags, **updated_kwargs)
inventory.add_device(device)
@staticmethod
def _parse_networks(
inventory_input: AntaInventoryInput,
inventory: AntaInventory,
**kwargs: dict[str, Any],
) -> None:
"""Parse the network section of an AntaInventoryInput and add the devices to the inventory.
def _parse_networks(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None:
"""
Parses the network 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.
Raises
------
InventoryIncorrectSchemaError
Inventory file is not following AntaInventory Schema.
Args:
inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices
inventory (AntaInventory): AntaInventory to add the parsed devices to
Raises:
InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema.
"""
if inventory_input.networks is None:
return
try:
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)):
device = AsyncEOSDevice(host=str(host_ip), tags=network.tags, **updated_kwargs)
inventory.add_device(device)
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)
raise InventoryIncorrectSchemaError(message) from e
raise InventoryIncorrectSchema(message) from e
@staticmethod
def _parse_ranges(
inventory_input: AntaInventoryInput,
inventory: AntaInventory,
**kwargs: dict[str, Any],
) -> None:
"""Parse the range section of an AntaInventoryInput and add the devices to the inventory.
def _parse_ranges(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None:
"""
Parses the range 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.
Raises
------
InventoryIncorrectSchemaError
Inventory file is not following AntaInventory Schema.
Args:
inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices
inventory (AntaInventory): AntaInventory to add the parsed devices to
Raises:
InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema.
"""
if inventory_input.ranges is None:
return
try:
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_stop = ip_address(str(range_def.end))
while range_increment <= range_stop: # type: ignore[operator]
@ -163,57 +129,45 @@ class AntaInventory(dict[str, AntaDevice]):
inventory.add_device(device)
range_increment += 1
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)
raise InventoryIncorrectSchemaError(message) from e
raise InventoryIncorrectSchema(message) from 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)
raise InventoryIncorrectSchemaError(message) from e
raise InventoryIncorrectSchema(message) from e
@staticmethod
def parse(
filename: str | Path,
username: str,
password: str,
enable_password: str | None = None,
timeout: float | None = None,
*,
enable: bool = False,
enable_password: Optional[str] = None,
timeout: Optional[float] = None,
insecure: bool = False,
disable_cache: bool = False,
) -> 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.
Parameters
----------
filename
Path to device inventory YAML file.
username
Username to use to connect to devices.
password
Password to use to connect to devices.
enable_password
Enable password to use if required.
timeout
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.
Args:
filename (str): Path to device inventory YAML file
username (str): Username to use to connect to devices
password (str): Password to use to connect to devices
enable (bool): Whether or not the commands need to be run in enable mode towards the devices
enable_password (str, optional): Enable password to use if required
timeout (float, optional): timeout in seconds for every API call.
insecure (bool): Disable SSH Host Key validation
disable_cache (bool): Disable cache globally
Raises:
InventoryRootKeyError: Root key of inventory is missing.
InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema.
"""
inventory = AntaInventory()
kwargs: dict[str, Any] = {
"username": username,
@ -234,8 +188,7 @@ class AntaInventory(dict[str, AntaDevice]):
raise ValueError(message)
try:
filename = Path(filename)
with filename.open(encoding="UTF-8") as file:
with open(file=filename, mode="r", encoding="UTF-8") as file:
data = safe_load(file)
except (TypeError, YAMLError, OSError) as e:
message = f"Unable to parse ANTA Device Inventory file '{filename}'"
@ -260,11 +213,6 @@ class AntaInventory(dict[str, AntaDevice]):
return inventory
@property
def devices(self) -> list[AntaDevice]:
"""List of AntaDevice in this inventory."""
return list(self.values())
###########################################################################
# Public methods
###########################################################################
@ -273,35 +221,30 @@ class AntaInventory(dict[str, AntaDevice]):
# GET methods
###########################################################################
def get_inventory(self, *, established_only: bool = False, tags: set[str] | None = None, devices: set[str] | None = None) -> AntaInventory:
"""Return a filtered inventory.
def get_inventory(self, established_only: bool = False, tags: Optional[list[str]] = None) -> AntaInventory:
"""
Returns a filtered inventory.
Parameters
----------
established_only
Whether or not to include only established devices.
tags
Tags to filter devices.
devices
Names to filter devices.
Args:
established_only: Whether or not to include only established devices. Default False.
tags: List of tags to filter devices.
Returns
-------
AntaInventory
An inventory with filtered AntaDevice objects.
Returns:
AntaInventory: An inventory with filtered AntaDevice objects.
"""
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):
return False
if devices is None or device.name in devices:
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()
for device in filtered_devices:
for device in devices:
result.add_device(device)
return result
@ -310,20 +253,15 @@ class AntaInventory(dict[str, AntaDevice]):
###########################################################################
def __setitem__(self, key: str, value: AntaDevice) -> None:
"""Set a device in the inventory."""
if key != value.name:
msg = f"The key must be the device name for device '{value.name}'. Use AntaInventory.add_device()."
raise RuntimeError(msg)
raise RuntimeError(f"The key must be the device name for device '{value.name}'. Use AntaInventory.add_device().")
return super().__setitem__(key, value)
def add_device(self, device: AntaDevice) -> None:
"""Add a device to final inventory.
Parameters
----------
device
Device object to be added.
Args:
device: Device object to be added
"""
self[device.name] = device

View file

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

View file

@ -6,107 +6,87 @@
from __future__ import annotations
import logging
import math
from typing import List, Optional, Union
import yaml
from pydantic import BaseModel, ConfigDict, IPvAnyAddress, IPvAnyNetwork
from anta.custom_types import Hostname, Port
# Need to keep List for pydantic in python 3.8
from pydantic import BaseModel, ConfigDict, IPvAnyAddress, IPvAnyNetwork, conint, constr
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):
"""Host entry of AntaInventoryInput.
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.
"""
Host definition for user's inventory.
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")
name: str | None = None
host: Hostname | IPvAnyAddress
port: Port | None = None
tags: set[str] | None = None
name: Optional[str] = None
host: Union[constr(pattern=RFC_1123_REGEX), IPvAnyAddress] # type: ignore
port: Optional[conint(gt=1, lt=65535)] = None # type: ignore
tags: Optional[List[str]] = None
disable_cache: bool = False
class AntaInventoryNetwork(BaseModel):
"""Network entry of AntaInventoryInput.
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.
"""
Network definition for user's inventory.
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")
network: IPvAnyNetwork
tags: set[str] | None = None
tags: Optional[List[str]] = None
disable_cache: bool = False
class AntaInventoryRange(BaseModel):
"""IP Range entry of AntaInventoryInput.
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.
"""
IP Range definition for user's inventory.
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")
start: IPvAnyAddress
end: IPvAnyAddress
tags: set[str] | None = None
tags: Optional[List[str]] = None
disable_cache: bool = False
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")
networks: list[AntaInventoryNetwork] | None = None
hosts: list[AntaInventoryHost] | None = None
ranges: list[AntaInventoryRange] | None = None
def yaml(self) -> str:
"""Return a YAML representation string of this model.
Returns
-------
str
The YAML representation string of this model.
"""
# TODO: Pydantic and YAML serialization/deserialization is not supported natively.
# This could be improved.
# https://github.com/pydantic/pydantic/issues/1043
# Explore if this worth using this: https://github.com/NowanIlfideme/pydantic-yaml
return yaml.safe_dump(yaml.safe_load(self.model_dump_json(serialize_as_any=True, exclude_unset=True)), indent=2, width=math.inf)
networks: Optional[List[AntaInventoryNetwork]] = None
hosts: Optional[List[AntaInventoryHost]] = None
ranges: Optional[List[AntaInventoryRange]] = None

View file

@ -1,28 +1,26 @@
# 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.
"""Configure logging for ANTA."""
"""
Configure logging for ANTA
"""
from __future__ import annotations
import logging
import traceback
from datetime import timedelta
from enum import Enum
from typing import TYPE_CHECKING, Literal
from pathlib import Path
from typing import Literal, Optional
from rich.logging import RichHandler
from anta import __DEBUG__
if TYPE_CHECKING:
from pathlib import Path
from anta.tools.misc import exc_to_str
logger = logging.getLogger(__name__)
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)
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:
"""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:
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
be logged to stdout while all levels will be logged in the file.
Parameters
----------
level
ANTA logging level
file
Send logs to a file
Args:
level: ANTA logging level
file: Send logs to a file
"""
# Init root logger
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())
root.setLevel(loglevel)
# 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)
# Add RichHandler for stdout
rich_handler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False)
# Show Python module in stdout at DEBUG level
fmt_string = "[grey58]\\[%(name)s][/grey58] %(message)s" if loglevel == logging.DEBUG else "%(message)s"
richHandler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False)
# In ANTA debug mode, show Python module in stdout
if __DEBUG__:
fmt_string = r"[grey58]\[%(name)s][/grey58] %(message)s"
else:
fmt_string = "%(message)s"
formatter = logging.Formatter(fmt=fmt_string, datefmt="[%X]")
rich_handler.setFormatter(formatter)
root.addHandler(rich_handler)
richHandler.setFormatter(formatter)
root.addHandler(richHandler)
# Add FileHandler if file is provided
if file:
file_handler = logging.FileHandler(file)
fileHandler = logging.FileHandler(file)
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
file_handler.setFormatter(formatter)
root.addHandler(file_handler)
fileHandler.setFormatter(formatter)
root.addHandler(fileHandler)
# If level is DEBUG and file is provided, do not send DEBUG level to stdout
if loglevel == logging.DEBUG:
rich_handler.setLevel(logging.INFO)
richHandler.setLevel(logging.INFO)
if __DEBUG__:
logger.debug("ANTA Debug Mode enabled")
def format_td(seconds: float, digits: int = 3) -> str:
"""Return a formatted string from a float number representing seconds and a number of digits."""
isec, fsec = divmod(round(seconds * 10**digits), 10**digits)
return f"{timedelta(seconds=isec)}.{fsec:0{digits}.0f}"
def anta_log_exception(exception: BaseException, message: Optional[str] = None, calling_logger: Optional[logging.Logger] = None) -> None:
"""
Helper function to help log exceptions:
* if anta.__DEBUG__ is True then the logger.exception method is called to get the traceback
* otherwise logger.error is called
def exc_to_str(exception: BaseException) -> str:
"""Return a human readable string from an BaseException object."""
return f"{type(exception).__name__}{f': {exception}' if str(exception) else ''}"
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.
Args:
exception (BAseException): The Exception being logged
message (str): An optional message
calling_logger (logging.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:
calling_logger = logger
calling_logger.critical(f"{message}\n{exc_to_str(exception)}" if message else exc_to_str(exception))
if __DEBUG__:
msg = f"[ANTA Debug Mode]{f' {message}' if message else ''}"
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__))
calling_logger.exception(f"[ANTA Debug Mode]{f' {message}' if message else ''}", exc_info=exception)

View file

@ -1,135 +1,102 @@
# 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.
"""Models to define a TestStructure."""
"""
Models to define a TestStructure
"""
from __future__ import annotations
import hashlib
import logging
import re
import time
from abc import ABC, abstractmethod
from copy import deepcopy
from datetime import timedelta
from functools import wraps
from string import Formatter
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, TypeVar
from pydantic import BaseModel, ConfigDict, ValidationError, create_model
from anta import GITHUB_SUGGESTION
from anta.constants import KNOWN_EOS_ERRORS
from anta.custom_types import REGEXP_EOS_BLACKLIST_CMDS, Revision
from anta.logger import anta_log_exception, exc_to_str
from anta.result_manager.models import AntaTestStatus, TestResult
if TYPE_CHECKING:
from collections.abc import Coroutine
# 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.logger import anta_log_exception
from anta.result_manager.models import TestResult
from anta.tools.misc import exc_to_str
if TYPE_CHECKING:
from anta.device import AntaDevice
F = TypeVar("F", bound=Callable[..., Any])
# Proper way to type input class - revisit this later if we get any issue @gmuloc
# This would imply overhead to define classes
# 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__)
class AntaParamsBaseModel(BaseModel):
"""Extends BaseModel and overwrite __getattr__ to return None on missing attribute."""
class AntaMissingParamException(Exception):
"""
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.
Can render a command from parameters.
Attributes
----------
template
Python f-string. Example: 'show vlan {vlan_id}'.
version
eAPI version - valid values are 1 or "latest".
revision
Revision of the command. Valid values are 1 to 99. Revision has precedence over version.
ofmt
eAPI output - json or text.
use_cache
Enable or disable caching for this AntaTemplate if the AntaDevice supports it.
Attributes:
template: Python f-string. Example: 'show vlan {vlan_id}'
version: eAPI version - valid values are 1 or "latest" - default is "latest"
revision: Revision of the command. Valid values are 1 to 99. Revision has precedence over version.
ofmt: eAPI output - json or text - default is json
use_cache: Enable or disable caching for this AntaTemplate if the AntaDevice supports it - default is True
"""
# 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__(
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:
def render(self, **params: dict[str, Any]) -> AntaCommand:
"""Render an AntaCommand from an AntaTemplate instance.
Keep the parameters used in the AntaTemplate instance.
Parameters
----------
params
Dictionary of variables with string values to render the Python f-string.
Args:
params: dictionary of variables with string values to render the Python f-string
Returns
-------
AntaCommand
The rendered AntaCommand.
Returns:
command: The rendered AntaCommand.
This AntaCommand instance have a template attribute that references this
AntaTemplate instance.
Raises
------
AntaTemplateRenderError
If a parameter is missing to render the AntaTemplate instance.
"""
try:
command = self.template.format(**params)
except (KeyError, SyntaxError) as e:
raise AntaTemplateRenderError(self, e.args[0]) from e
return AntaCommand(
command=command,
command=self.template.format(**params),
ofmt=self.ofmt,
version=self.version,
revision=self.revision,
template=self,
params=self.params_schema(**params),
params=params,
use_cache=self.use_cache,
)
except KeyError as e:
raise AntaTemplateRenderError(self, e.args[0]) from e
class AntaCommand(BaseModel):
@ -146,147 +113,70 @@ class AntaCommand(BaseModel):
__Revision has precedence over version.__
Attributes
----------
command
Device command.
version
eAPI version - valid values are 1 or "latest".
revision
eAPI revision of the command. Valid values are 1 to 99. Revision has precedence over version.
ofmt
eAPI output - json or text.
output
Output of the command. Only defined if there was no errors.
template
AntaTemplate object used to render this command.
errors
If the command execution fails, eAPI returns a list of strings detailing the error(s).
params
Pydantic Model containing the variables values used to render the template.
use_cache
Enable or disable caching for this AntaCommand if the AntaDevice supports it.
Attributes:
command: Device command
version: eAPI version - valid values are 1 or "latest" - default is "latest"
revision: eAPI revision of the command. Valid values are 1 to 99. Revision has precedence over version.
ofmt: eAPI output - json or text - default is json
output: Output of the command populated by the collect() function
template: AntaTemplate object used to render this command
params: Dictionary of variables with string values to render the template
errors: If the command execution fails, eAPI returns a list of strings detailing the error
use_cache: Enable or disable caching for this AntaCommand if the AntaDevice supports it - default is True
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
command: str
version: Literal[1, "latest"] = "latest"
revision: Revision | None = None
revision: Optional[conint(ge=1, le=99)] = None # type: ignore
ofmt: Literal["json", "text"] = "json"
output: dict[str, Any] | str | None = None
template: AntaTemplate | None = None
errors: list[str] = []
params: AntaParamsBaseModel = AntaParamsBaseModel()
output: Optional[Union[Dict[str, Any], str]] = None
template: Optional[AntaTemplate] = None
errors: List[str] = []
params: Dict[str, Any] = {}
use_cache: bool = True
@property
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}"
# Ignoring S324 probable use of insecure hash function - sha1 is enough for our needs.
return hashlib.sha1(uid_str.encode()).hexdigest() # noqa: S324
return hashlib.sha1(uid_str.encode()).hexdigest()
@property
def json_output(self) -> dict[str, Any]:
"""Get the command output as JSON."""
"""Get the command output as JSON"""
if self.output is None:
msg = f"There is no output for command '{self.command}'"
raise RuntimeError(msg)
raise RuntimeError(f"There is no output for command {self.command}")
if self.ofmt != "json" or not isinstance(self.output, dict):
msg = f"Output of command '{self.command}' is invalid"
raise RuntimeError(msg)
raise RuntimeError(f"Output of command {self.command} is invalid")
return dict(self.output)
@property
def text_output(self) -> str:
"""Get the command output as a string."""
"""Get the command output as a string"""
if self.output is None:
msg = f"There is no output for command '{self.command}'"
raise RuntimeError(msg)
raise RuntimeError(f"There is no output for command {self.command}")
if self.ofmt != "text" or not isinstance(self.output, str):
msg = f"Output of command '{self.command}' is invalid"
raise RuntimeError(msg)
raise RuntimeError(f"Output of command {self.command} is invalid")
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
def collected(self) -> bool:
"""Return True if the command has been collected, False otherwise.
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)
"""Return True if the command has been collected"""
return self.output is not None and not self.errors
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:
"""Initialize an AntaTemplateRenderError.
Parameters
----------
template
The AntaTemplate instance that failed to render.
key
Key that has not been provided to render the template.
def __init__(self, template: AntaTemplate, key: str):
"""Constructor for AntaTemplateRenderError
Args:
template: The AntaTemplate instance that failed to render
key: Key that has not been provided to render the template
"""
self.template = template
self.key = key
@ -294,17 +184,17 @@ class AntaTemplateRenderError(RuntimeError):
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
writing a test as simple as possible.
Examples
--------
Examples:
The following is an example of an AntaTest subclass implementation:
```python
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"]
commands = [AntaTemplate(template="ping vrf {vrf} {dst} source {src} repeat 2")]
@ -316,13 +206,14 @@ class AntaTest(ABC):
vrf: str = "default"
def render(self, template: AntaTemplate) -> list[AntaCommand]:
return [template.render(dst=host.dst, src=host.src, vrf=host.vrf) for host in self.inputs.hosts]
return [template.render({"dst": host.dst, "src": host.src, "vrf": host.vrf}) for host in self.inputs.hosts]
@AntaTest.anta_test
def test(self) -> None:
failures = []
for command in self.instance_commands:
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]:
failures.append((str(src), str(dst)))
if not failures:
@ -330,43 +221,28 @@ class AntaTest(ABC):
else:
self.result.is_failure(f"Connectivity test failed for the following source-destination pairs: {failures}")
```
Attributes
----------
device
AntaDevice instance on which this test is run.
inputs
AntaTest.Input instance carrying the test inputs.
instance_commands
List of AntaCommand instances of this test.
result
TestResult instance representing the result of this test.
logger
Python logger for this test instance.
Attributes:
device: AntaDevice instance on which this test is run
inputs: AntaTest.Input instance carrying the test inputs
instance_commands: List of AntaCommand instances of this test
result: TestResult instance representing the result of this test
logger: Python logger for this test instance
"""
# Optional class attributes
# Mandatory class attributes
# TODO - find a way to tell mypy these are mandatory for child classes - maybe Protocol
name: ClassVar[str]
description: ClassVar[str]
__removal_in_version: ClassVar[str]
"""Internal class variable set by the `deprecated_test_class` decorator."""
# Mandatory class attributes
# TODO: find a way to tell mypy these are mandatory for child classes
# follow this https://discuss.python.org/t/provide-a-canonical-way-to-declare-an-abstract-class-variable/69416
# for now only enforced at runtime with __init_subclass__
categories: ClassVar[list[str]]
commands: ClassVar[list[AntaTemplate | AntaCommand]]
commands: ClassVar[list[Union[AntaTemplate, AntaCommand]]]
# Class attributes to handle the progress bar of ANTA CLI
progress: Progress | None = None
nrfu_task: TaskID | None = None
progress: Optional[Progress] = None
nrfu_task: Optional[TaskID] = None
class Input(BaseModel):
"""Class defining inputs for a test in ANTA.
Examples
--------
Examples:
A valid test catalog will look like the following:
```yaml
<Python module>:
@ -377,94 +253,74 @@ class AntaTest(ABC):
description: "Test with overwritten description"
custom_field: "Test run by John Doe"
```
Attributes
----------
result_overwrite
Define fields to overwrite in the TestResult object.
Attributes:
result_overwrite: Define fields to overwrite in the TestResult object
"""
model_config = ConfigDict(extra="forbid")
result_overwrite: ResultOverwrite | None = None
filters: Filters | None = None
result_overwrite: Optional[ResultOverwrite] = None
filters: Optional[Filters] = None
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.
"""
return hash(self.model_dump_json())
class ResultOverwrite(BaseModel):
"""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.
"""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
"""
model_config = ConfigDict(extra="forbid")
description: str | None = None
categories: list[str] | None = None
custom_field: str | None = None
description: Optional[str] = None
categories: Optional[List[str]] = None
custom_field: Optional[str] = None
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
----------
tags
Tag of devices on which to run the test.
Attributes:
tags: List of device's tags for the test.
"""
model_config = ConfigDict(extra="forbid")
tags: set[str] | None = None
tags: Optional[List[str]] = None
def __init__(
self,
device: AntaDevice,
inputs: dict[str, Any] | AntaTest.Input | None = None,
eos_data: list[dict[Any, Any] | str] | None = None,
) -> None:
"""AntaTest Constructor.
):
"""AntaTest Constructor
Parameters
----------
device
AntaDevice instance on which the test will be run.
inputs
Dictionary of attributes used to instantiate the AntaTest.Input instance.
eos_data
Populate outputs of the test commands instead of collecting from devices.
Args:
device: AntaDevice instance on which the test will be run
inputs: dictionary of attributes used to instantiate the AntaTest.Input instance
eos_data: Populate outputs of the test commands instead of collecting from devices.
This list must have the same length and order than the `instance_commands` instance attribute.
"""
self.logger: logging.Logger = logging.getLogger(f"{self.module}.{self.__class__.__name__}")
self.logger: logging.Logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}")
self.device: AntaDevice = device
self.inputs: AntaTest.Input
self.instance_commands: list[AntaCommand] = []
self.result: TestResult = TestResult(
name=device.name,
test=self.name,
categories=self.categories,
description=self.description,
)
self.result: TestResult = TestResult(name=device.name, test=self.name, categories=self.categories, description=self.description)
self._init_inputs(inputs)
if self.result.result == AntaTestStatus.UNSET:
if self.result.result == "unset":
self._init_commands(eos_data)
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.
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:
if inputs is None:
self.inputs = self.Input()
@ -473,7 +329,7 @@ class AntaTest(ABC):
elif isinstance(inputs, dict):
self.inputs = self.Input(**inputs)
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.result.is_error(message=message)
return
@ -484,11 +340,10 @@ class AntaTest(ABC):
self.result.description = res_ow.description
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.
- 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 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:
for cmd in self.__class__.commands:
if isinstance(cmd, AntaCommand):
self.instance_commands.append(cmd.model_copy())
self.instance_commands.append(deepcopy(cmd))
elif isinstance(cmd, AntaTemplate):
try:
self.instance_commands.extend(self.render(cmd))
@ -506,21 +361,21 @@ class AntaTest(ABC):
except NotImplementedError as e:
self.result.is_error(message=e.args[0])
return
except Exception as e: # noqa: BLE001
except Exception as e: # pylint: disable=broad-exception-caught
# render() is user-defined code.
# We need to catch everything if we want the AntaTest object
# to live until the reporting
message = f"Exception in {self.module}.{self.__class__.__name__}.render()"
message = f"Exception in {self.__module__}.{self.__class__.__name__}.render()"
anta_log_exception(e, message, self.logger)
self.result.is_error(message=f"{message}: {exc_to_str(e)}")
return
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)
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):
self.result.is_error(message="Test initialization error: Trying to save more data than there are commands for the test")
return
@ -531,67 +386,50 @@ class AntaTest(ABC):
self.instance_commands[index].output = data
def __init_subclass__(cls) -> None:
"""Verify that the mandatory class attributes are defined and set name and description if not set."""
mandatory_attributes = ["categories", "commands"]
if missing_attrs := [attr for attr in mandatory_attributes if not hasattr(cls, attr)]:
msg = f"Class {cls.__module__}.{cls.__name__} is missing required class attribute(s): {', '.join(missing_attrs)}"
raise AttributeError(msg)
cls.name = getattr(cls, "name", cls.__name__)
if not hasattr(cls, "description"):
if not cls.__doc__ or cls.__doc__.strip() == "":
# No doctsring or empty doctsring - raise
msg = f"Cannot set the description for class {cls.name}, either set it in the class definition or add a docstring to the class."
raise AttributeError(msg)
cls.description = cls.__doc__.split(sep="\n", maxsplit=1)[0]
@property
def module(self) -> str:
"""Return the Python module in which this AntaTest class is defined."""
return self.__module__
"""Verify that the mandatory class attributes are defined"""
mandatory_attributes = ["name", "description", "categories", "commands"]
for attr in mandatory_attributes:
if not hasattr(cls, attr):
raise NotImplementedError(f"Class {cls.__module__}.{cls.__name__} is missing required class attribute {attr}")
@property
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)
@property
def failed_commands(self) -> list[AntaCommand]:
"""Return a list of all the commands that have failed."""
return [command for command in self.instance_commands if command.error]
"""Returns a list of all the commands that have failed."""
return [command for command in self.instance_commands if command.errors]
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
no AntaTemplate for this test.
"""
_ = template
msg = f"AntaTemplate are provided but render() method has not been implemented for {self.module}.{self.__class__.__name__}"
raise NotImplementedError(msg)
no AntaTemplate for this test."""
raise NotImplementedError(f"AntaTemplate are provided but render() method has not been implemented for {self.__module__}.{self.name}")
@property
def blocked(self) -> bool:
"""Check if CLI commands contain a blocked keyword."""
state = False
for command in self.instance_commands:
for pattern in REGEXP_EOS_BLACKLIST_CMDS:
for pattern in BLACKLIST_REGEX:
if re.match(pattern, command.command):
self.logger.error(
"Command <%s> is blocked for security reason matching %s",
command.command,
REGEXP_EOS_BLACKLIST_CMDS,
)
self.logger.error(f"Command <{command.command}> is blocked for security reason matching {BLACKLIST_REGEX}")
self.result.is_error(f"<{command.command}> is blocked for security reason")
state = True
return state
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:
if self.blocked is False:
await self.device.collect_commands(self.instance_commands, collection_id=self.name)
except Exception as e: # noqa: BLE001
await self.device.collect_commands(self.instance_commands)
except Exception as e: # pylint: disable=broad-exception-caught
# device._collect() is user-defined code.
# We need to catch everything if we want the AntaTest object
# to live until the reporting
@ -601,7 +439,8 @@ class AntaTest(ABC):
@staticmethod
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):
@ -615,50 +454,50 @@ class AntaTest(ABC):
async def wrapper(
self: AntaTest,
eos_data: list[dict[Any, Any] | str] | None = None,
**kwargs: dict[str, Any],
**kwargs: Any,
) -> 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":
return self.result
# Data
if eos_data is not None:
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 not self.collected:
await self.collect()
if self.result.result != "unset":
AntaTest.update_progress()
return self.result
if self.failed_commands:
self._handle_failed_commands()
AntaTest.update_progress()
if cmds := self.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)]
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
try:
function(self, **kwargs)
except Exception as e: # noqa: BLE001
except Exception as e: # pylint: disable=broad-exception-caught
# test() is user-defined code.
# We need to catch everything if we want the AntaTest object
# to live until the reporting
@ -666,49 +505,30 @@ class AntaTest(ABC):
anta_log_exception(e, message, self.logger)
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()
return self.result
return wrapper
def _handle_failed_commands(self) -> None:
"""Handle failed commands inside a test.
There can be 3 types:
* unsupported on hardware commands which set the test status to 'skipped'
* known EOS error which set the test status to 'failure'
* unknown failure which set the test status to 'error'
"""
cmds = self.failed_commands
unsupported_commands = [f"'{c.command}' is not supported on {self.device.hw_model}" for c in cmds if not c.supported]
if unsupported_commands:
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
def update_progress(cls: type[AntaTest]) -> None:
"""Update progress bar for all AntaTest objects if it exists."""
def update_progress(cls) -> None:
"""
Update progress bar for all AntaTest objects if it exists
"""
if cls.progress and (cls.nrfu_task is not None):
cls.progress.update(cls.nrfu_task, advance=1)
@abstractmethod
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.
It must set the correct status of the `result` instance attribute with the appropriate outcome of the test.
Examples
--------
Examples:
It must be implemented using the `AntaTest.anta_test` decorator:
```python
@AntaTest.anta_test
@ -716,7 +536,6 @@ class AntaTest(ABC):
self.result.is_success()
for command in self.instance_commands:
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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Report management for ANTA."""
"""
Report management for ANTA.
"""
# pylint: disable = too-few-public-methods
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import os.path
import pathlib
from typing import Any, Optional
from jinja2 import Template
from rich.table import Table
from anta import RICH_COLOR_PALETTE, RICH_COLOR_THEME
from anta.tools import convert_categories
if TYPE_CHECKING:
import pathlib
from anta.custom_types import TestStatus
from anta.result_manager import ResultManager
from anta.result_manager.models import AntaTestStatus, TestResult
logger = logging.getLogger(__name__)
@ -28,201 +25,185 @@ logger = logging.getLogger(__name__)
class ReportTable:
"""TableReport Generate a Table based on TestResult."""
@dataclass()
class Headers: # pylint: disable=too-many-instance-attributes
"""Headers for the table report."""
def _split_list_to_txt_list(self, usr_list: list[str], delimiter: Optional[str] = None) -> str:
"""
Split list to multi-lines string
device: str = "Device"
test_case: str = "Test Name"
number_of_success: str = "# of success"
number_of_failure: str = "# of failure"
number_of_skipped: str = "# of skipped"
number_of_errors: str = "# of errors"
list_of_error_nodes: str = "List of failed or error nodes"
list_of_error_tests: str = "List of failed or error test cases"
def _split_list_to_txt_list(self, usr_list: list[str], delimiter: str | None = None) -> str:
"""Split list to multi-lines string.
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.
Args:
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
"""
if delimiter is not None:
return "\n".join(f"{delimiter} {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:
"""Create headers for a table.
"""
Create headers for a table.
First key is considered as header and is colored using RICH_COLOR_PALETTE.HEADER
Parameters
----------
headers
List of headers.
table
A rich Table instance.
Returns
-------
Table
A rich `Table` instance with headers.
Args:
headers (list[str]): List of headers
table (Table): A rich Table instance
Returns:
Table: A rich Table instance with headers
"""
for idx, header in enumerate(headers):
if idx == 0:
table.add_column(header, justify="left", style=RICH_COLOR_PALETTE.HEADER, no_wrap=True)
elif header == "Test Name":
# We always want the full test name
table.add_column(header, justify="left", no_wrap=True)
else:
table.add_column(header, justify="left")
return table
def _color_result(self, status: AntaTestStatus) -> str:
"""Return a colored string based on an AntaTestStatus.
Parameters
----------
status
AntaTestStatus enum to color.
Returns
-------
str
The colored string.
def _color_result(self, status: TestStatus) -> str:
"""
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)
def report_all(self, manager: ResultManager, title: str = "All tests results") -> Table:
"""Create a table report with all tests for one or all devices.
def report_all(
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
----------
manager
A ResultManager instance.
title
Title for the report. Defaults to 'All tests results'.
Args:
result_manager (ResultManager): A manager with a list of tests.
host (str, optional): IP Address of a host to search for. Defaults to None.
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`.
Returns:
Table: A fully populated rich Table
"""
table = Table(title=title, show_lines=True)
headers = ["Device", "Test Name", "Test Status", "Message(s)", "Test description", "Test category"]
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)
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)
for result in manager.results:
add_line(result)
return table
def report_summary_tests(
self,
manager: ResultManager,
tests: list[str] | None = None,
title: str = "Summary per test",
result_manager: ResultManager,
testcase: Optional[str] = None,
title: str = "Summary per test case",
) -> 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)
headers = [
self.Headers.test_case,
self.Headers.number_of_success,
self.Headers.number_of_skipped,
self.Headers.number_of_failure,
self.Headers.number_of_errors,
self.Headers.list_of_error_nodes,
"Test Case",
"# of success",
"# of skipped",
"# of failure",
"# of errors",
"List of failed or error nodes",
]
table = self._build_headers(headers=headers, table=table)
for test, stats in sorted(manager.test_stats.items()):
if tests is None or test in tests:
for testcase_read in result_manager.get_testcases():
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(
test,
str(stats.devices_success_count),
str(stats.devices_skipped_count),
str(stats.devices_failure_count),
str(stats.devices_error_count),
", ".join(stats.devices_failure),
testcase_read,
str(nb_success),
str(nb_skipped),
str(nb_failure),
str(nb_error),
str(list_failure),
)
return table
def report_summary_devices(
def report_summary_hosts(
self,
manager: ResultManager,
devices: list[str] | None = None,
title: str = "Summary per device",
result_manager: ResultManager,
host: Optional[str] = None,
title: str = "Summary per host",
) -> 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
----------
manager
A ResultManager instance.
devices
List of device names to include. None to select all devices.
title
Title of the report.
Args:
result_manager (ResultManager): A manager with a list of tests.
host (str, optional): IP Address of a host 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`.
Returns:
Table: A fully populated rich Table
"""
table = Table(title=title, show_lines=True)
headers = [
self.Headers.device,
self.Headers.number_of_success,
self.Headers.number_of_skipped,
self.Headers.number_of_failure,
self.Headers.number_of_errors,
self.Headers.list_of_error_tests,
"Device",
"# of success",
"# of skipped",
"# of failure",
"# of errors",
"List of failed or error test cases",
]
table = self._build_headers(headers=headers, table=table)
for device, stats in sorted(manager.device_stats.items()):
if devices is None or device in devices:
for host_read in result_manager.get_hosts():
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(
device,
str(stats.tests_success_count),
str(stats.tests_skipped_count),
str(stats.tests_failure_count),
str(stats.tests_error_count),
", ".join(stats.tests_failure),
str(host_read),
str(nb_success),
str(nb_skipped),
str(nb_failure),
str(nb_error),
str(list_failure),
)
return table
@ -231,23 +212,20 @@ class ReportJinja:
"""Report builder based on a Jinja2 template."""
def __init__(self, template_path: pathlib.Path) -> None:
"""Create a ReportJinja instance."""
if not template_path.is_file():
msg = f"template file is not found: {template_path}"
raise FileNotFoundError(msg)
if os.path.isfile(template_path):
self.tempalte_path = template_path
else:
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:
"""Build a report based on a Jinja2 template.
def render(self, data: list[dict[str, Any]], trim_blocks: bool = True, lstrip_blocks: bool = True) -> str:
"""
Build a report based on a Jinja2 template
Report is built based on a J2 template provided by user.
Data structure sent to template is:
Example
-------
```
>>> print(ResultManager.json)
>>> data = ResultManager.get_json_results()
>>> print(data)
[
{
name: ...,
@ -258,24 +236,16 @@ class ReportJinja:
description: ...,
}
]
```
Parameters
----------
data
List of results from `ResultManager.results`.
trim_blocks
enable trim_blocks for J2 rendering.
lstrip_blocks
enable lstrip_blocks for J2 rendering.
Returns
-------
str
Rendered template
Args:
data (list[dict[str, Any]]): List of results from ResultManager.get_results
trim_blocks (bool, optional): enable trim_blocks for J2 rendering. Defaults to True.
lstrip_blocks (bool, optional): enable lstrip_blocks for J2 rendering. Defaults to True.
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)
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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Result Manager module for ANTA."""
"""
Result Manager Module for ANTA.
"""
from __future__ import annotations
import json
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__)
# pylint: disable=too-many-instance-attributes
class ResultManager:
"""Helper to manage Test Results and generate reports.
"""
Helper to manage Test Results and generate reports.
Examples:
Examples
--------
Create Inventory:
inventory_anta = AntaInventory.parse(
filename='examples/inventory.yml',
username='ansible',
password='ansible',
timeout=0.5
)
Create Result Manager:
@ -39,51 +38,36 @@ class ResultManager:
Run tests for all connected devices:
for device in inventory_anta.get_inventory().devices:
manager.add(
for device in inventory_anta.get_inventory():
manager.add_test_result(
VerifyNTP(device=device).test()
)
manager.add(
manager.add_test_result(
VerifyEOSVersion(device=device).test(version='4.28.3M')
)
Print result in native format:
manager.results
manager.get_results()
[
TestResult(
name="pf1",
test="VerifyZeroTouch",
categories=["configuration"],
description="Verifies ZeroTouch is disabled",
result="success",
messages=[],
custom_field=None,
host=IPv4Address('192.168.0.10'),
test='VerifyNTP',
result='failure',
message="device is not running NTP correctly"
),
TestResult(
name="pf1",
test='VerifyNTP',
categories=["software"],
categories=['system'],
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,
host=IPv4Address('192.168.0.10'),
test='VerifyEOSVersion',
result='success',
message=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:
"""Class constructor.
"""
Class constructor.
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
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.status: AntaTestStatus = AntaTestStatus.UNSET
# Initialize status
self.status: TestStatus = "unset"
self.error_status = False
# Initialize the statistics attributes
self._reset_stats()
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)
@property
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.
def _update_status(self, test_status: TestStatus) -> None:
"""
Update ResultManager status based on the table above.
"""
ResultValidator = TypeAdapter(TestStatus)
ResultValidator.validate_python(test_status)
if test_status == "error":
self.error_status = True
return
if self.status == "unset" or (self.status == "skipped" and test_status in {"success", "failure"}):
if self.status == "unset":
self.status = test_status
elif self.status == "skipped" and test_status in {"success", "failure"}:
self.status = test_status
elif self.status == "success" and test_status == "failure":
self.status = AntaTestStatus.FAILURE
self.status = "failure"
def _reset_stats(self) -> None:
"""Create or reset the statistics attributes."""
self._device_stats = defaultdict(DeviceStats)
self._category_stats = defaultdict(CategoryStats)
self._test_stats = defaultdict(TestStats)
self._stats_in_sync = False
def add_test_result(self, entry: TestResult) -> None:
"""Add a result to the list
def _update_stats(self, result: TestResult) -> None:
"""Update the statistics based on the test result.
Parameters
----------
result
TestResult to update the statistics.
Args:
entry (TestResult): TestResult data to add to the report
"""
count_attr = f"tests_{result.result}_count"
logger.debug(entry)
self._result_entries.append(entry)
self._update_status(entry.result)
# Update device stats
device_stats: DeviceStats = self._device_stats[result.name]
setattr(device_stats, count_attr, getattr(device_stats, count_attr) + 1)
if result.result in ("failure", "error"):
device_stats.tests_failure.add(result.test)
device_stats.categories_failed.update(result.categories)
elif result.result == "skipped":
device_stats.categories_skipped.update(result.categories)
def add_test_results(self, entries: list[TestResult]) -> None:
"""Add a list of results to the list
# Update category stats
for category in result.categories:
category_stats: CategoryStats = self._category_stats[category]
setattr(category_stats, count_attr, getattr(category_stats, count_attr) + 1)
# Update test stats
count_attr = f"devices_{result.result}_count"
test_stats: TestStats = self._test_stats[result.test]
setattr(test_stats, count_attr, getattr(test_stats, count_attr) + 1)
if result.result in ("failure", "error"):
test_stats.devices_failure.add(result.name)
def _compute_stats(self) -> None:
"""Compute all statistics from the current results."""
logger.info("Computing statistics for all results.")
# Reset all stats
self._reset_stats()
# Recompute stats for all results
for result in self._result_entries:
self._update_stats(result)
self._stats_in_sync = True
def _ensure_stats_in_sync(self) -> None:
"""Ensure statistics are in sync with current results."""
if not self._stats_in_sync:
self._compute_stats()
def add(self, result: TestResult) -> None:
"""Add a result to the ResultManager instance.
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.
Args:
entries (list[TestResult]): List of TestResult data to add to the report
"""
self._result_entries.append(result)
self._update_status(result.result)
self._stats_in_sync = False
for e in entries:
self.add_test_result(e)
# Every time a new result is added, we need to clear the cached property
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.
def get_status(self, ignore_error: bool = False) -> str:
"""
# Return all results if no status is provided, otherwise return results for multiple statuses
results = self._result_entries if status is None else list(chain.from_iterable(self.results_by_status.get(status, []) for status in status))
if sort_by:
accepted_fields = TestResult.model_fields.keys()
if not set(sort_by).issubset(set(accepted_fields)):
msg = f"Invalid sort_by fields: {sort_by}. Accepted fields are: {list(accepted_fields)}"
raise ValueError(msg)
results = sorted(results, key=lambda result: [getattr(result, field) 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.
Returns the current status including error_status if ignore_error is False
"""
if status is None:
# Return the total number of results
return sum(len(results) for results in self.results_by_status.values())
# Return the total number of results for multiple statuses
return sum(len(self.results_by_status.get(status, [])) for status in status)
def get_status(self, *, ignore_error: bool = False) -> str:
"""Return the current status including error_status if ignore_error is False."""
return "error" if self.error_status and not ignore_error else self.status
def filter(self, hide: set[AntaTestStatus]) -> ResultManager:
"""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`.
def get_results(self) -> list[TestResult]:
"""
possible_statuses = set(AntaTestStatus)
manager = ResultManager()
manager.results = self.get_results(possible_statuses - hide)
return manager
Expose list of all test results in different format
def filter_by_tests(self, tests: set[str]) -> ResultManager:
"""Get a filtered ResultManager that only contains specific tests.
Parameters
----------
tests
Set of test names to filter the results.
Returns
-------
ResultManager
A filtered `ResultManager`.
Returns:
any: List of results.
"""
manager = ResultManager()
manager.results = [result for result in self._result_entries if result.test in tests]
return manager
return self._result_entries
def filter_by_devices(self, devices: set[str]) -> ResultManager:
"""Get a filtered ResultManager that only contains specific devices.
Parameters
----------
devices
Set of device names to filter the results.
Returns
-------
ResultManager
A filtered `ResultManager`.
def get_json_results(self) -> str:
"""
manager = ResultManager()
manager.results = [result for result in self._result_entries if result.name in devices]
return manager
Expose list of all test results in JSON
def get_tests(self) -> set[str]:
"""Get the set of all the test names.
Returns
-------
set[str]
Set of test names.
Returns:
str: JSON dumps of the list of results
"""
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]:
"""Get the set of all the device names.
Returns
-------
set[str]
Set of device names.
def get_result_by_test(self, test_name: str) -> list[TestResult]:
"""
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
# that can be found in the LICENSE file.
"""Models related to anta.result_manager module."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
# Need to keep List for pydantic in 3.8
from typing import List, Optional
from pydantic import BaseModel
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
from anta.custom_types import TestStatus
class TestResult(BaseModel):
"""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.
"""
Describe the result of a test from a single device.
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
test: str
categories: list[str]
categories: List[str]
description: str
result: AntaTestStatus = AntaTestStatus.UNSET
messages: list[str] = []
custom_field: str | None = None
result: TestStatus = "unset"
messages: List[str] = []
custom_field: Optional[str] = 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:
"""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:
"""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:
"""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:
"""Set status and insert optional message.
Parameters
----------
status
Status of the test.
message
Optional message.
def _set_status(self, status: TestStatus, message: str | None = None) -> None:
"""
Set status and insert optional message
Args:
status: status of the test
message: optional message
"""
self.result = status
if message is not None:
self.messages.append(message)
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}"
# 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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""ANTA runner function."""
# pylint: disable=too-many-branches
"""
ANTA runner function
"""
from __future__ import annotations
import asyncio
import logging
import os
import sys
from collections import defaultdict
from typing import TYPE_CHECKING, Any
from typing import Tuple
from anta import GITHUB_SUGGESTION
from anta.logger import anta_log_exception, exc_to_str
from anta.models import AntaTest
from anta.tools import Catchtime, cprofile
if TYPE_CHECKING:
from collections.abc import Coroutine
from anta.catalog import AntaCatalog, AntaTestDefinition
from anta.device import AntaDevice
from anta.inventory import AntaInventory
from anta.logger import anta_log_exception
from anta.models import AntaTest
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__)
AntaTestRunner = Tuple[AntaTestDefinition, AntaDevice]
def log_cache_statistics(devices: list[AntaDevice]) -> None:
"""Log cache statistics for each device in the inventory.
Parameters
----------
devices
List of devices in the inventory.
async def main(manager: ResultManager, inventory: AntaInventory, catalog: AntaCatalog, tags: list[str] | None = None, established_only: bool = True) -> None:
"""
for device in devices:
if device.cache_statistics is not None:
msg = (
f"Cache statistics for '{device.name}': "
f"{device.cache_statistics['cache_hits']} hits / {device.cache_statistics['total_commands_sent']} "
f"command(s) ({device.cache_statistics['cache_hit_ratio']})"
)
logger.info(msg)
else:
logger.info("Caching is not enabled on %s", device.name)
Main coroutine to run ANTA.
Use this as an entrypoint to the test framwork in your script.
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:
"""Set up the inventory for the ANTA run.
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.
Returns:
any: ResultManager object gets updated with the test results.
"""
if not catalog.tests:
logger.info("The list of tests is empty, exiting")
return
with Catchtime(logger=logger, message="Preparing ANTA NRFU Run"):
# 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:
if len(inventory) == 0:
logger.info("The inventory is empty, exiting")
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"):
selected_tests = prepare_tests(selected_inventory, catalog, tests, tags)
if selected_tests is None:
return
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 not devices:
logger.info(
f"No device in the established state '{established_only}' "
f"{f'matching the tags {tags} ' if tags else ''}was found. There is no device to run tests against, exiting"
)
if os.name == "posix":
# Adjust the maximum number of open file descriptors for the ANTA process
limits = adjust_rlimit_nofile()
run_info += f"Maximum number of open file descriptors for the current ANTA process: {limits[0]}\n"
return
coros = []
# Using a set to avoid inserting duplicate tests
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:
# Running on non-Posix system, cannot manage the resource.
limits = (sys.maxsize, sys.maxsize)
run_info += "Running on a non-POSIX system, cannot adjust the maximum number of file descriptors.\n"
# If there is no CLI tags, execute all tests without filters
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 += "---------------------------------"
# 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]:
logger.warning(
"The number of concurrent tests is higher than the open file descriptors limit for this ANTA process.\n"
"Errors may occur while running the tests.\n"
"Please consult the ANTA FAQ."
)
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()
if not tests:
logger.info(f"There is no tests{f' matching the tags {tags} ' if tags else ' '}to run on current inventory. " "Exiting...")
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:
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"):
results = await asyncio.gather(*coroutines)
for result in results:
manager.add(result)
log_cache_statistics(selected_inventory.devices)
logger.info("Running ANTA tests...")
test_results = await asyncio.gather(*coros)
for r in test_results:
manager.add_test_result(r)
for device in 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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to all ANTA tests."""

View file

@ -1,54 +1,44 @@
# 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 EOS various AAA tests."""
"""
Test functions related to the EOS various AAA 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 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.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyTacacsSourceIntf(AntaTest):
"""Verifies TACACS source-interface for a specified VRF.
"""
Verifies TACACS source-interface for a specified VRF.
Expected Results
----------------
* 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.
Examples
--------
```yaml
anta.tests.aaa:
- VerifyTacacsSourceIntf:
intf: Management0
vrf: MGMT
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["aaa"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifyTacacsSourceIntf test."""
name = "VerifyTacacsSourceIntf"
description = "Verifies TACACS source-interface for a specified VRF."
categories = ["aaa"]
commands = [AntaCommand(command="show tacacs")]
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
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"
"""The name of the VRF to transport TACACS messages. Defaults to `default`."""
"""The name of the VRF to transport TACACS messages"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyTacacsSourceIntf."""
command_output = self.instance_commands[0].json_output
try:
if command_output["srcIntf"][self.inputs.vrf] == self.inputs.intf:
@ -60,39 +50,27 @@ class VerifyTacacsSourceIntf(AntaTest):
class VerifyTacacsServers(AntaTest):
"""Verifies TACACS servers are configured for a specified VRF.
"""
Verifies TACACS servers are configured for a specified VRF.
Expected Results
----------------
* 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.
Examples
--------
```yaml
anta.tests.aaa:
- VerifyTacacsServers:
servers:
- 10.10.10.21
- 10.10.10.22
vrf: MGMT
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["aaa"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)]
name = "VerifyTacacsServers"
description = "Verifies TACACS servers are configured for a specified VRF."
categories = ["aaa"]
commands = [AntaCommand(command="show tacacs")]
class Input(AntaTest.Input):
"""Input model for the VerifyTacacsServers test."""
servers: list[IPv4Address]
"""List of TACACS servers."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
servers: List[IPv4Address]
"""List of TACACS servers"""
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
def test(self) -> None:
"""Main test function for VerifyTacacsServers."""
command_output = self.instance_commands[0].json_output
tacacs_servers = command_output["tacacsServers"]
if not tacacs_servers:
@ -112,36 +90,25 @@ class VerifyTacacsServers(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
----------------
* 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.
Examples
--------
```yaml
anta.tests.aaa:
- VerifyTacacsServerGroups:
groups:
- TACACS-GROUP1
- TACACS-GROUP2
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["aaa"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)]
name = "VerifyTacacsServerGroups"
description = "Verifies if the provided TACACS server group(s) are configured."
categories = ["aaa"]
commands = [AntaCommand(command="show tacacs")]
class Input(AntaTest.Input):
"""Input model for the VerifyTacacsServerGroups test."""
groups: list[str]
"""List of TACACS server groups."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
groups: List[str]
"""List of TACACS server group"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyTacacsServerGroups."""
command_output = self.instance_commands[0].json_output
tacacs_groups = command_output["groups"]
if not tacacs_groups:
@ -155,45 +122,29 @@ class VerifyTacacsServerGroups(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
----------------
* 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.
Examples
--------
```yaml
anta.tests.aaa:
- VerifyAuthenMethods:
methods:
- local
- none
- logging
types:
- login
- enable
- dot1x
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["aaa"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authentication", revision=1)]
name = "VerifyAuthenMethods"
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):
"""Input model for the VerifyAuthenMethods test."""
methods: list[AAAAuthMethod]
"""List of AAA authentication methods. Methods should be in the right order."""
types: set[Literal["login", "enable", "dot1x"]]
"""List of authentication types to verify."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
methods: List[AAAAuthMethod]
"""List of AAA authentication methods. Methods should be in the right order"""
types: Set[Literal["login", "enable", "dot1x"]]
"""List of authentication types to verify"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyAuthenMethods."""
command_output = self.instance_commands[0].json_output
not_matching: list[str] = []
not_matching = []
for k, v in command_output.items():
auth_type = k.replace("AuthenMethods", "")
if auth_type not in self.inputs.types:
@ -206,8 +157,9 @@ class VerifyAuthenMethods(AntaTest):
if v["login"]["methods"] != self.inputs.methods:
self.result.is_failure(f"AAA authentication methods {self.inputs.methods} are not matching for login console")
return
not_matching.extend(auth_type for methods in v.values() if methods["methods"] != self.inputs.methods)
for methods in v.values():
if methods["methods"] != self.inputs.methods:
not_matching.append(auth_type)
if not not_matching:
self.result.is_success()
else:
@ -215,51 +167,37 @@ class VerifyAuthenMethods(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
----------------
* 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.
Examples
--------
```yaml
anta.tests.aaa:
- VerifyAuthzMethods:
methods:
- local
- none
- logging
types:
- commands
- exec
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["aaa"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authorization", revision=1)]
name = "VerifyAuthzMethods"
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):
"""Input model for the VerifyAuthzMethods test."""
methods: list[AAAAuthMethod]
"""List of AAA authorization methods. Methods should be in the right order."""
types: set[Literal["commands", "exec"]]
"""List of authorization types to verify."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
methods: List[AAAAuthMethod]
"""List of AAA authorization methods. Methods should be in the right order"""
types: Set[Literal["commands", "exec"]]
"""List of authorization types to verify"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyAuthzMethods."""
command_output = self.instance_commands[0].json_output
not_matching: list[str] = []
not_matching = []
for k, v in command_output.items():
authz_type = k.replace("AuthzMethods", "")
if authz_type not in self.inputs.types:
# We do not need to verify this accounting type
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:
self.result.is_success()
else:
@ -267,44 +205,27 @@ class VerifyAuthzMethods(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
----------------
* 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.
Examples
--------
```yaml
anta.tests.aaa:
- VerifyAcctDefaultMethods:
methods:
- local
- none
- logging
types:
- system
- exec
- commands
- dot1x
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["aaa"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)]
name = "VerifyAcctDefaultMethods"
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):
"""Input model for the VerifyAcctDefaultMethods test."""
methods: list[AAAAuthMethod]
"""List of AAA accounting methods. Methods should be in the right order."""
types: set[Literal["commands", "exec", "system", "dot1x"]]
"""List of accounting types to verify."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
methods: List[AAAAuthMethod]
"""List of AAA accounting methods. Methods should be in the right order"""
types: Set[Literal["commands", "exec", "system", "dot1x"]]
"""List of accounting types to verify"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyAcctDefaultMethods."""
command_output = self.instance_commands[0].json_output
not_matching = []
not_configured = []
@ -328,44 +249,27 @@ class VerifyAcctDefaultMethods(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
----------------
* 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.
Examples
--------
```yaml
anta.tests.aaa:
- VerifyAcctConsoleMethods:
methods:
- local
- none
- logging
types:
- system
- exec
- commands
- dot1x
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["aaa"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)]
name = "VerifyAcctConsoleMethods"
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):
"""Input model for the VerifyAcctConsoleMethods test."""
methods: list[AAAAuthMethod]
"""List of AAA accounting console methods. Methods should be in the right order."""
types: set[Literal["commands", "exec", "system", "dot1x"]]
"""List of accounting console types to verify."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
methods: List[AAAAuthMethod]
"""List of AAA accounting console methods. Methods should be in the right order"""
types: Set[Literal["commands", "exec", "system", "dot1x"]]
"""List of accounting console types to verify"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyAcctConsoleMethods."""
command_output = self.instance_commands[0].json_output
not_matching = []
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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to BFD tests."""
"""
BFD test functions
"""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING, ClassVar
from datetime import datetime
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.tools import get_value
if TYPE_CHECKING:
from anta.models import AntaTemplate
from anta.tools.get_value import get_value
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:
1. Confirms that the specified VRF is configured.
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
```
Expected results:
* success: The test will pass if IPv4 BFD peers are up and remote disc is non-zero in the specified VRF.
* failure: The test will fail if IPv4 BFD peers are not found, the status is not UP or remote disc is zero in the specified VRF.
"""
categories: ClassVar[list[str]] = ["bfd"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers", revision=1)]
name = "VerifyBFDSpecificPeers"
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):
"""Input model for the VerifyBFDSpecificPeers test."""
"""
This class defines the input parameters of the test case.
"""
bfd_peers: list[BFDPeer]
"""List of IPv4 BFD"""
BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer
"""To maintain backward compatibility."""
bfd_peers: List[BFDPeers]
"""List of IPv4 BFD peers"""
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
def test(self) -> None:
"""Main test function for VerifyBFDSpecificPeers."""
self.result.is_success()
failures: dict[Any, Any] = {}
# Iterating over BFD peers
for bfd_peer in self.inputs.bfd_peers:
peer = str(bfd_peer.peer_address)
vrf = bfd_peer.vrf
bfd_output = get_value(
self.instance_commands[0].json_output,
f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..",
separator="..",
)
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")
failures[peer] = {vrf: "Not Configured"}
continue
# Check BFD peer status and remote disc
state = 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 (bfd_output.get("status") == "up" and bfd_output.get("remoteDisc") != 0):
failures[peer] = {vrf: {"status": bfd_output.get("status"), "remote_disc": bfd_output.get("remoteDisc")}}
if not failures:
self.result.is_success()
else:
self.result.is_failure(f"Following BFD peers are not configured, status is not up or remote disc is zero:\n{failures}")
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:
1. Confirms that the specified VRF is configured.
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
```
Expected results:
* success: The test will pass if the timers of the IPv4 BFD peers are correct in the specified VRF.
* failure: The test will fail if the IPv4 BFD peers are not found or their timers are incorrect in the specified VRF.
"""
categories: ClassVar[list[str]] = ["bfd"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=1)]
name = "VerifyBFDPeersIntervals"
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):
"""Input model for the VerifyBFDPeersIntervals test."""
"""
This class defines the input parameters of the test case.
"""
bfd_peers: list[BFDPeer]
"""List of IPv4 BFD"""
BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer
"""To maintain backward compatibility"""
bfd_peers: List[BFDPeers]
"""List of BFD peers"""
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
def test(self) -> None:
"""Main test function for VerifyBFDPeersIntervals."""
self.result.is_success()
failures: dict[Any, Any] = {}
# Iterating over BFD peers
for bfd_peer in self.inputs.bfd_peers:
peer = str(bfd_peer.peer_address)
vrf = bfd_peer.vrf
tx_interval = bfd_peer.tx_interval
rx_interval = bfd_peer.rx_interval
multiplier = bfd_peer.multiplier
for bfd_peers in self.inputs.bfd_peers:
peer = str(bfd_peers.peer_address)
vrf = bfd_peers.vrf
# Converting milliseconds intervals into actual value
tx_interval = bfd_peers.tx_interval * 1000
rx_interval = bfd_peers.rx_interval * 1000
multiplier = bfd_peers.multiplier
bfd_output = get_value(self.instance_commands[0].json_output, f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..", separator="..")
# Check if BFD peer configured
bfd_output = get_value(
self.instance_commands[0].json_output,
f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..",
separator="..",
)
if not bfd_output:
self.result.is_failure(f"{bfd_peer} - Not found")
failures[peer] = {vrf: "Not Configured"}
continue
# Convert interval timer(s) into milliseconds to be consistent with the inputs.
bfd_details = bfd_output.get("peerStatsDetail", {})
op_tx_interval = bfd_details.get("operTxInterval") // 1000
op_rx_interval = bfd_details.get("operRxInterval") // 1000
detect_multiplier = bfd_details.get("detectMult")
intervals_ok = (
bfd_details.get("operTxInterval") == tx_interval and bfd_details.get("operRxInterval") == rx_interval and bfd_details.get("detectMult") == multiplier
)
if op_tx_interval != tx_interval:
self.result.is_failure(f"{bfd_peer} - Incorrect Transmit interval - Expected: {tx_interval} Actual: {op_tx_interval}")
# Check timers of BFD peer
if not intervals_ok:
failures[peer] = {
vrf: {
"tx_interval": bfd_details.get("operTxInterval"),
"rx_interval": bfd_details.get("operRxInterval"),
"multiplier": bfd_details.get("detectMult"),
}
}
if op_rx_interval != rx_interval:
self.result.is_failure(f"{bfd_peer} - Incorrect Receive interval - Expected: {rx_interval} Actual: {op_rx_interval}")
if detect_multiplier != multiplier:
self.result.is_failure(f"{bfd_peer} - Incorrect Multiplier - Expected: {multiplier} Actual: {detect_multiplier}")
# Check if any failures
if not failures:
self.result.is_success()
else:
self.result.is_failure(f"Following BFD peers are not configured or timers are not correct:\n{failures}")
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`.
2. Confirms that the remote discriminator identifier (disc) is non-zero.
3. Optionally verifies that the peer have not been down before a specified threshold of hours.
Expected Results
----------------
* 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
```
Expected results:
* Success: The test will pass if all IPv4 BFD peers are up, the discriminator value of each remote system is non-zero,
and the last downtime of each peer is above the defined threshold.
* Failure: The test will fail if any IPv4 BFD peer is down, the discriminator value of any remote system is zero,
or the last downtime of any peer is below the defined threshold.
"""
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
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaCommand(command="show bfd peers", revision=1),
AntaCommand(command="show clock", revision=1),
]
commands = [AntaCommand(command="show bfd peers", revision=1), AntaCommand(command="show clock")]
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."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyBFDPeersHealth."""
self.result.is_success()
# Initialize failure strings
down_failures = []
up_failures = []
# Extract the current timestamp and command output
clock_output = self.instance_commands[1].json_output
current_timestamp = clock_output["utcTime"]
bfd_output = self.instance_commands[0].json_output
# set the initial result
self.result.is_success()
# Check if any IPv4 BFD peer is configured
ipv4_neighbors_exist = any(vrf_data["ipv4Neighbors"] for vrf_data in bfd_output["vrfs"].values())
if not ipv4_neighbors_exist:
@ -240,88 +210,26 @@ class VerifyBFDPeersHealth(AntaTest):
for peer_data in neighbor_data["peerStats"].values():
peer_status = peer_data["status"]
remote_disc = peer_data["remoteDisc"]
remote_disc_info = f" with remote disc {remote_disc}" if remote_disc == 0 else ""
last_down = peer_data["lastDown"]
hours_difference = (
datetime.fromtimestamp(current_timestamp, tz=timezone.utc) - datetime.fromtimestamp(last_down, tz=timezone.utc)
).total_seconds() / 3600
hours_difference = (datetime.fromtimestamp(current_timestamp) - datetime.fromtimestamp(last_down)).total_seconds() / 3600
if not (peer_status == "up" and remote_disc != 0):
self.result.is_failure(
f"Peer: {peer} VRF: {vrf} - Session not properly established - State: {peer_status} Remote Discriminator: {remote_disc}"
)
# Check if peer status is not up
if peer_status != "up":
down_failures.append(f"{peer} is {peer_status} in {vrf} VRF{remote_disc_info}.")
# Check if the last down is within the threshold
if self.inputs.down_threshold and hours_difference < self.inputs.down_threshold:
self.result.is_failure(
f"Peer: {peer} VRF: {vrf} - Session failure detected within the expected uptime threshold ({round(hours_difference)} hours ago)"
)
elif self.inputs.down_threshold and hours_difference < self.inputs.down_threshold:
up_failures.append(f"{peer} in {vrf} VRF was down {round(hours_difference)} hours ago{remote_disc_info}.")
# Check if remote disc is 0
elif remote_disc == 0:
up_failures.append(f"{peer} in {vrf} VRF has remote disc {remote_disc}.")
class VerifyBFDPeersRegProtocols(AntaTest):
"""Verifies the registered routing protocol of IPv4 BFD peer sessions.
This test performs the following checks for each specified peer:
1. Confirms that the specified VRF is configured.
2. Verifies that the peer exists in the BFD configuration.
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")
# Check if there are any failures
if down_failures:
down_failures_str = "\n".join(down_failures)
self.result.is_failure(f"Following BFD peers are not up:\n{down_failures_str}")
if up_failures:
up_failures_str = "\n".join(up_failures)
self.result.is_failure(f"\nFollowing BFD peers were down:\n{up_failures_str}")

View file

@ -1,45 +1,30 @@
# 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 device configuration tests."""
"""
Test functions related to the device configuration
"""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
from __future__ import annotations
import re
from typing import TYPE_CHECKING, ClassVar
from anta.custom_types import RegexString
from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyZeroTouch(AntaTest):
"""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:
```
"""
Verifies ZeroTouch is disabled
"""
categories: ClassVar[list[str]] = ["configuration"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show zerotouch", revision=1)]
name = "VerifyZeroTouch"
description = "Verifies ZeroTouch is disabled"
categories = ["configuration"]
commands = [AntaCommand(command="show zerotouch")]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyZeroTouch."""
command_output = self.instance_commands[0].json_output
command_output = self.instance_commands[0].output
assert isinstance(command_output, dict)
if command_output["mode"] == "disabled":
self.result.is_success()
else:
@ -47,82 +32,20 @@ class VerifyZeroTouch(AntaTest):
class VerifyRunningConfigDiffs(AntaTest):
"""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:
```
"""
Verifies there is no difference between the running-config and the startup-config
"""
categories: ClassVar[list[str]] = ["configuration"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config diffs", ofmt="text")]
name = "VerifyRunningConfigDiffs"
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
def test(self) -> None:
"""Main test function for VerifyRunningConfigDiffs."""
command_output = self.instance_commands[0].text_output
if command_output == "":
command_output = self.instance_commands[0].output
if command_output is None or command_output == "":
self.result.is_success()
else:
self.result.is_failure(command_output)
class VerifyRunningConfigLines(AntaTest):
"""Verifies the given regular expression patterns are present in the running-config.
!!! warning
Since this uses regular expression searches on the whole running-config, it can
drastically impact performance and should only be used if no other test is available.
If possible, try using another ANTA test that is more specific.
Expected Results
----------------
* Success: The test will pass if all the patterns are found in the running-config.
* Failure: The test will fail if any of the patterns are NOT found in the running-config.
Examples
--------
```yaml
anta.tests.configuration:
- VerifyRunningConfigLines:
regex_patterns:
- "^enable password.*$"
- "bla bla"
```
"""
description = "Search the Running-Config for the given RegEx patterns."
categories: ClassVar[list[str]] = ["configuration"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config", ofmt="text")]
class Input(AntaTest.Input):
"""Input model for the VerifyRunningConfigLines test."""
regex_patterns: list[RegexString]
"""List of regular expressions."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyRunningConfigLines."""
failure_msgs = []
command_output = self.instance_commands[0].text_output
for pattern in self.inputs.regex_patterns:
re_search = re.compile(pattern, flags=re.MULTILINE)
if not re_search.search(command_output):
failure_msgs.append(f"'{pattern}'")
if not failure_msgs:
self.result.is_success()
else:
self.result.is_failure("Following patterns were not found: " + ",".join(failure_msgs))
self.result.is_failure()
self.result.is_failure(str(command_output))

View file

@ -1,140 +1,125 @@
# 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 various connectivity tests."""
"""
Test functions related to various connectivity checks
"""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
from __future__ import annotations
from typing import ClassVar
from ipaddress import IPv4Address
from anta.input_models.connectivity import Host, LLDPNeighbor, Neighbor
from anta.models import AntaCommand, AntaTemplate, AntaTest
# Need to keep List for pydantic in python 3.8
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):
"""Test network reachability to one or many destination IP(s).
"""
Test network reachability to one or many destination IP(s).
Expected Results
----------------
* 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.
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
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["connectivity"]
# Template uses '{size}{df_bit}' without space since df_bit includes leading space when enabled
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaTemplate(template="ping vrf {vrf} {destination} source {source} size {size}{df_bit} repeat {repeat}", revision=1)
]
name = "VerifyReachability"
description = "Test the network reachability to one or many destination IP(s)."
categories = ["connectivity"]
commands = [AntaTemplate(template="ping vrf {vrf} {destination} source {source} repeat {repeat}")]
class Input(AntaTest.Input):
"""Input model for the VerifyReachability test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
hosts: List[Host]
"""List of hosts to ping"""
hosts: list[Host]
"""List of host to ping."""
Host: ClassVar[type[Host]] = Host
"""To maintain backward compatibility."""
class Host(BaseModel):
"""Remote host to ping"""
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]:
"""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, size=host.size, df_bit=" df-bit" if host.df_bit else ""
)
for host in self.inputs.hosts
]
return [template.render(destination=host.destination, source=host.source, vrf=host.vrf, repeat=host.repeat) for host in self.inputs.hosts]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyReachability."""
self.result.is_success()
failures = []
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 f"{host.repeat} received" not in command.json_output["messages"][0]:
self.result.is_failure(f"{host} - Unreachable")
if any(elem is None for elem in (src, dst, repeat)):
raise AntaMissingParamException(f"A parameter is missing to execute the test for command {command}")
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):
"""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:
1. Confirming matching ports on both local and neighboring devices.
2. Ensuring compatibility of device names and interface identifiers.
3. Verifying neighbor configurations match expected values per interface; extra neighbors are ignored.
Expected Results
----------------
* Success: The test will pass if 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
```
Expected Results:
* success: The test will pass if each of the provided LLDP neighbors is present and connected to the specified port and device.
* failure: The test will fail if any of the following conditions are met:
- The provided LLDP neighbor is not found.
- The system name or port of the LLDP neighbor does not match the provided information.
"""
categories: ClassVar[list[str]] = ["connectivity"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lldp neighbors detail", revision=1)]
name = "VerifyLLDPNeighbors"
description = "Verifies that the provided LLDP neighbors are connected properly."
categories = ["connectivity"]
commands = [AntaCommand(command="show lldp neighbors detail")]
class Input(AntaTest.Input):
"""Input model for the VerifyLLDPNeighbors test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
neighbors: List[Neighbor]
"""List of LLDP neighbors"""
neighbors: list[LLDPNeighbor]
"""List of LLDP neighbors."""
Neighbor: ClassVar[type[Neighbor]] = Neighbor
"""To maintain backward compatibility."""
class Neighbor(BaseModel):
"""LLDP neighbor"""
port: Interface
"""LLDP port"""
neighbor_device: str
"""LLDP neighbor device"""
neighbor_port: Interface
"""LLDP neighbor port"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyLLDPNeighbors."""
self.result.is_success()
command_output = self.instance_commands[0].json_output
failures: dict[str, list[str]] = {}
output = self.instance_commands[0].json_output["lldpNeighbors"]
for neighbor in self.inputs.neighbors:
if neighbor.port not in output:
self.result.is_failure(f"{neighbor} - Port not found")
continue
if neighbor.port not in command_output["lldpNeighbors"]:
failures.setdefault("port_not_configured", []).append(neighbor.port)
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:
self.result.is_failure(f"{neighbor} - No LLDP neighbors")
continue
# 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)}")
if not failures:
self.result.is_success()
else:
self.result.is_failure(f"The following port(s) have issues: {failures}")

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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to field notices tests."""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
"""
Test functions to flag field notices
"""
from anta.decorators import skip_on_platforms
from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
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.
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:
```
https://www.arista.com/en/support/advisories-notices/field-notice/8756-field-notice-44
"""
description = "Verifies that the device is using the correct Aboot version per FN0044."
categories: ClassVar[list[str]] = ["field notices"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)]
name = "VerifyFieldNotice44Resolution"
description = (
"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
def test(self) -> None:
"""Main test function for VerifyFieldNotice44Resolution."""
command_output = self.instance_commands[0].json_output
devices = [
@ -93,6 +79,7 @@ class VerifyFieldNotice44Resolution(AntaTest):
variants = ["-SSD-F", "-SSD-R", "-M-F", "-M-R", "-F", "-R"]
model = command_output["modelName"]
# TODO this list could be a regex
for variant in variants:
model = model.replace(variant, "")
if model not in devices:
@ -102,50 +89,33 @@ class VerifyFieldNotice44Resolution(AntaTest):
for component in command_output["details"]["components"]:
if component["name"] == "Aboot":
aboot_version = component["version"].split("-")[2]
break
else:
self.result.is_failure("Aboot component not found")
return
self.result.is_success()
incorrect_aboot_version = (
(aboot_version.startswith("4.0.") and int(aboot_version.split(".")[2]) < 7)
or (aboot_version.startswith("4.1.") and int(aboot_version.split(".")[2]) < 1)
or (
(aboot_version.startswith("6.0.") and int(aboot_version.split(".")[2]) < 9)
or (aboot_version.startswith("6.1.") and int(aboot_version.split(".")[2]) < 7)
)
)
if incorrect_aboot_version:
if 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})")
elif aboot_version.startswith("4.1.") and int(aboot_version.split(".")[2]) < 1:
self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
elif aboot_version.startswith("6.0.") and int(aboot_version.split(".")[2]) < 9:
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:
self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
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
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:
```
https://www.arista.com/en/support/advisories-notices/field-notice/17410-field-notice-0072
"""
description = "Verifies if the device is exposed to FN0072, and if the issue has been mitigated."
categories: ClassVar[list[str]] = ["field notices"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)]
name = "VerifyFieldNotice72Resolution"
description = "Verifies if the device has exposeure to FN72, and if the issue has been mitigated"
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
def test(self) -> None:
"""Main test function for VerifyFieldNotice72Resolution."""
command_output = self.instance_commands[0].json_output
devices = ["DCS-7280SR3-48YC8", "DCS-7280SR3K-48YC8"]
@ -181,7 +151,8 @@ class VerifyFieldNotice72Resolution(AntaTest):
self.result.is_skipped("Device not exposed")
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"]:
if entry["name"] == "FixedSystemvrm1":
if int(entry["version"]) < 7:
@ -190,4 +161,5 @@ class VerifyFieldNotice72Resolution(AntaTest):
self.result.is_success("FN72 is mitigated")
return
# 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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to GreenT (Postcard Telemetry) tests."""
"""
Test functions related to GreenT (Postcard Telemetry) in EOS
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyGreenTCounters(AntaTest):
"""Verifies if the GreenT (GRE Encapsulated Telemetry) counters are incremented.
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:
```
"""
Verifies whether GRE packets are sent.
Expected Results:
* success: if >0 gre packets are sent
* failure: if no gre packets are sent
"""
description = "Verifies if the GreenT counters are incremented."
categories: ClassVar[list[str]] = ["greent"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard counters", revision=1)]
name = "VerifyGreenTCounters"
description = "Verifies if the greent counters are incremented."
categories = ["greent"]
commands = [AntaCommand(command="show monitor telemetry postcard counters")]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyGreenTCounters."""
command_output = self.instance_commands[0].json_output
if command_output["grePktSent"] > 0:
self.result.is_success()
else:
self.result.is_failure("GreenT counters are not incremented")
self.result.is_failure("GRE packets are not sent")
class VerifyGreenT(AntaTest):
"""Verifies if a GreenT (GRE Encapsulated Telemetry) policy other than the default 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:
```
"""
Verifies whether GreenT policy is created.
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."
categories: ClassVar[list[str]] = ["greent"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard policy profile", revision=1)]
name = "VerifyGreenT"
description = "Verifies whether greent policy is created."
categories = ["greent"]
commands = [AntaCommand(command="show monitor telemetry postcard policy profile")]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyGreenT."""
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:
self.result.is_success()
if len(out) > 0:
for i in out:
self.result.is_success(f"{i} policy is created")
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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to the hardware or environment tests."""
"""
Test functions related to the hardware or environment
"""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
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.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
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
----------------
* Success: The test will pass if all transceivers are from approved 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.
```
Expected Results:
* success: The test will pass if all transceivers are from approved manufacturers.
* failure: The test will fail if some transceivers are from unapproved manufacturers.
"""
categories: ClassVar[list[str]] = ["hardware"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show inventory", revision=2)]
name = "VerifyTransceiversManufacturers"
description = "Verifies if all transceivers come from approved manufacturers."
categories = ["hardware"]
commands = [AntaCommand(command="show inventory", ofmt="json")]
class Input(AntaTest.Input):
"""Input model for the VerifyTransceiversManufacturers test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
manufacturers: List[str]
"""List of approved transceivers manufacturers"""
manufacturers: list[str]
"""List of approved transceivers manufacturers."""
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyTransceiversManufacturers."""
command_output = self.instance_commands[0].json_output
wrong_manufacturers = {
interface: value["mfgName"] for interface, value in command_output["xcvrSlots"].items() if value["mfgName"] not in self.inputs.manufacturers
@ -60,30 +47,24 @@ class VerifyTransceiversManufacturers(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
----------------
* 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.
Examples
--------
```yaml
anta.tests.hardware:
- VerifyTemperature:
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["hardware"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature", revision=1)]
name = "VerifyTemperature"
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
def test(self) -> None:
"""Main test function for VerifyTemperature."""
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":
self.result.is_success()
else:
@ -91,30 +72,24 @@ class VerifyTemperature(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
----------------
* Success: The test will pass if all transceivers status are OK: 'ok'.
* Failure: The test will fail if some transceivers are NOT OK.
Examples
--------
```yaml
anta.tests.hardware:
- VerifyTransceiversTemperature:
```
Expected Results:
* success: The test will pass if all transceivers status are OK: 'ok'.
* failure: The test will fail if some transceivers are NOT OK.
"""
categories: ClassVar[list[str]] = ["hardware"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature transceiver", revision=1)]
name = "VerifyTransceiversTemperature"
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
def test(self) -> None:
"""Main test function for VerifyTransceiversTemperature."""
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 = {
sensor["name"]: {
"hwStatus": sensor["hwStatus"],
@ -130,68 +105,50 @@ class VerifyTransceiversTemperature(AntaTest):
class VerifyEnvironmentSystemCooling(AntaTest):
"""Verifies the device's system cooling status.
"""
This test verifies the device's system cooling.
Expected Results
----------------
* 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.
Examples
--------
```yaml
anta.tests.hardware:
- VerifyEnvironmentSystemCooling:
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["hardware"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", revision=1)]
name = "VerifyEnvironmentSystemCooling"
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
def test(self) -> None:
"""Main test function for VerifyEnvironmentSystemCooling."""
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()
if sys_status != "coolingOk":
self.result.is_failure(f"Device system cooling is not OK: '{sys_status}'")
class VerifyEnvironmentCooling(AntaTest):
"""Verifies the status of power supply fans and all fan trays.
"""
This test verifies the fans status.
Expected Results
----------------
* 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.
Examples
--------
```yaml
anta.tests.hardware:
- VerifyEnvironmentCooling:
states:
- ok
```
Expected Results:
* 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.
"""
name = "VerifyEnvironmentCooling"
description = "Verifies the status of power supply fans and all fan trays."
categories: ClassVar[list[str]] = ["hardware"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", revision=1)]
categories = ["hardware"]
commands = [AntaCommand(command="show system environment cooling", ofmt="json")]
class Input(AntaTest.Input):
"""Input model for the VerifyEnvironmentCooling test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
states: List[str]
"""Accepted states list for fan status"""
states: list[str]
"""List of accepted states of fan status."""
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyEnvironmentCooling."""
command_output = self.instance_commands[0].json_output
self.result.is_success()
# First go through power supplies fans
@ -207,38 +164,28 @@ class VerifyEnvironmentCooling(AntaTest):
class VerifyEnvironmentPower(AntaTest):
"""Verifies the power supplies status.
"""
This test verifies the power supplies status.
Expected Results
----------------
* Success: The test will pass if the power supplies status are within the accepted states list.
* Failure: The test will fail if some power supplies status is not within the accepted states list.
Examples
--------
```yaml
anta.tests.hardware:
- VerifyEnvironmentPower:
states:
- ok
```
Expected Results:
* success: The test will pass if the power supplies status are within the accepted states list.
* failure: The test will fail if some power supplies status is not within the accepted states list.
"""
categories: ClassVar[list[str]] = ["hardware"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment power", revision=1)]
name = "VerifyEnvironmentPower"
description = "Verifies the power supplies status."
categories = ["hardware"]
commands = [AntaCommand(command="show system environment power", ofmt="json")]
class Input(AntaTest.Input):
"""Input model for the VerifyEnvironmentPower test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
states: List[str]
"""Accepted states list for power supplies status"""
states: list[str]
"""List of accepted states list of power supplies status."""
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyEnvironmentPower."""
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 = {
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):
"""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
----------------
* Success: The test will pass if there are no adverse drops.
* Failure: The test will fail if there are adverse drops.
Examples
--------
```yaml
anta.tests.hardware:
- VerifyAdverseDrops:
```
Expected Results:
* success: The test will pass if there are no adverse drops.
* failure: The test will fail if there are adverse drops.
"""
description = "Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches."
categories: ClassVar[list[str]] = ["hardware"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware counter drop", revision=1)]
name = "VerifyAdverseDrops"
description = "Verifies there are no adverse drops on DCS7280E and DCS7500E"
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
def test(self) -> None:
"""Main test function for VerifyAdverseDrops."""
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:
self.result.is_success()
else:

File diff suppressed because it is too large Load diff

View file

@ -1,46 +1,34 @@
# 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 LANZ tests."""
"""
Test functions related to LANZ
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from anta.decorators import skip_on_platforms
from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyLANZ(AntaTest):
"""Verifies if LANZ (Latency Analyzer) is enabled.
"""
Verifies if LANZ is enabled
Expected Results
----------------
* Success: The test will pass if LANZ is enabled.
* Failure: The test will fail if LANZ is disabled.
Examples
--------
```yaml
anta.tests.lanz:
- VerifyLANZ:
```
Expected results:
* success: the test will pass if lanz is enabled
* failure: the test will fail if lanz is disabled
"""
name = "VerifyLANZ"
description = "Verifies if LANZ is enabled."
categories: ClassVar[list[str]] = ["lanz"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show queue-monitor length status", revision=1)]
categories = ["lanz"]
commands = [AntaCommand(command="show queue-monitor length status")]
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyLANZ."""
command_output = self.instance_commands[0].json_output
if command_output["lanzEnabled"] is not True:
self.result.is_failure("LANZ is not enabled")
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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to the EOS various logging tests.
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: disable-error-code=attr-defined
from __future__ import annotations
import logging
import re
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
if TYPE_CHECKING:
import logging
from anta.models import AntaTemplate
def _get_logging_states(logger: logging.Logger, command_output: str) -> str:
"""Parse `show logging` output and gets operational logging states used in the tests in this module.
Parameters
----------
logger
The logger object.
command_output
The `show logging` output.
Returns
-------
str
The operational logging states.
"""
Parse "show logging" output and gets operational logging states used
in the tests in this module.
Args:
command_output: The 'show logging' output
"""
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
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
----------------
* 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.
Examples
--------
```yaml
anta.tests.logging:
- VerifyLoggingPersistent:
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
name = "VerifyLoggingPersistent"
description = "Verifies if logging persistent is enabled and logs are saved in flash."
categories = ["logging"]
commands = [
AntaCommand(command="show logging", ofmt="text"),
AntaCommand(command="dir flash:/persist/messages", ofmt="text"),
]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyLoggingPersistent."""
self.result.is_success()
log_output = self.instance_commands[0].text_output
dir_flash_output = self.instance_commands[1].text_output
@ -81,37 +65,27 @@ class VerifyLoggingPersistent(AntaTest):
class VerifyLoggingSourceIntf(AntaTest):
"""Verifies logging source-interface for a specified VRF.
"""
Verifies logging source-interface for a specified VRF.
Expected Results
----------------
* 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.
Examples
--------
```yaml
anta.tests.logging:
- VerifyLoggingSourceIntf:
interface: Management0
vrf: default
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")]
class Input(AntaTest.Input):
"""Input model for the VerifyLoggingSourceIntf test."""
name = "VerifyLoggingSourceInt"
description = "Verifies logging source-interface for a specified VRF."
categories = ["logging"]
commands = [AntaCommand(command="show logging", ofmt="text")]
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
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"
"""The name of the VRF to transport log messages. Defaults to `default`."""
"""The name of the VRF to transport log messages"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyLoggingSourceIntf."""
output = self.instance_commands[0].text_output
pattern = rf"Logging source-interface '{self.inputs.interface}'.*VRF {self.inputs.vrf}"
if re.search(pattern, _get_logging_states(self.logger, output)):
@ -121,43 +95,31 @@ class VerifyLoggingSourceIntf(AntaTest):
class VerifyLoggingHosts(AntaTest):
"""Verifies logging hosts (syslog servers) for a specified VRF.
"""
Verifies logging hosts (syslog servers) for a specified VRF.
Expected Results
----------------
* 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.
Examples
--------
```yaml
anta.tests.logging:
- VerifyLoggingHosts:
hosts:
- 1.1.1.1
- 2.2.2.2
vrf: default
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")]
name = "VerifyLoggingHosts"
description = "Verifies logging hosts (syslog servers) for a specified VRF."
categories = ["logging"]
commands = [AntaCommand(command="show logging", ofmt="text")]
class Input(AntaTest.Input):
"""Input model for the VerifyLoggingHosts test."""
hosts: list[IPv4Address]
"""List of hosts (syslog servers) IP addresses."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
hosts: List[IPv4Address]
"""List of hosts (syslog servers) IP addresses"""
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
def test(self) -> None:
"""Main test function for VerifyLoggingHosts."""
output = self.instance_commands[0].text_output
not_configured = []
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)):
not_configured.append(str(host))
@ -168,42 +130,24 @@ class VerifyLoggingHosts(AntaTest):
class VerifyLoggingLogsGeneration(AntaTest):
"""Verifies if logs are generated.
"""
Verifies if logs are generated.
This test performs the following checks:
1. Sends a test log message at the **informational** level
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:
```
Expected Results:
* success: The test will pass if logs are generated.
* failure: The test will fail if logs are NOT generated.
"""
categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaCommand(command="send log level informational message ANTA VerifyLoggingLogsGeneration validation", ofmt="text"),
name = "VerifyLoggingLogsGeneration"
description = "Verifies if logs are generated."
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),
]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyLoggingLogsGeneration."""
log_pattern = r"ANTA VerifyLoggingLogsGeneration validation"
output = self.instance_commands[1].text_output
lines = output.strip().split("\n")[::-1]
@ -215,44 +159,25 @@ class VerifyLoggingLogsGeneration(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:
1. Retrieves the device's configured 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:
```
Expected Results:
* success: The test will pass if logs are generated with the device FQDN.
* failure: The test will fail if logs are NOT generated with the device FQDN.
"""
categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaCommand(command="show hostname", revision=1),
AntaCommand(command="send log level informational message ANTA VerifyLoggingHostname validation", ofmt="text"),
name = "VerifyLoggingHostname"
description = "Verifies if logs are generated with the device FQDN."
categories = ["logging"]
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),
]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyLoggingHostname."""
output_hostname = self.instance_commands[0].json_output
output_logging = self.instance_commands[2].text_output
fqdn = output_hostname["fqdn"]
@ -270,46 +195,26 @@ class VerifyLoggingHostname(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:
1. Sends a test log message at the **informational** level
2. Retrieves the most recent logs (last 30 seconds)
3. Verifies that the test message is present with a high-resolution RFC3339 timestamp format
- Example format: `2024-01-25T15:30:45.123456+00:00`
- Includes microsecond precision
- Contains timezone offset
!!! 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:
```
Expected Results:
* success: The test will pass if logs are generated with the appropriated timestamp.
* failure: The test will fail if logs are NOT generated with the appropriated timestamp.
"""
categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation", ofmt="text"),
name = "VerifyLoggingTimestamp"
description = "Verifies if logs are generated with the appropriate timestamp."
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),
]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyLoggingTimestamp."""
log_pattern = r"ANTA VerifyLoggingTimestamp validation"
timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}[+-]\d{2}:\d{2}"
timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}-\d{2}:\d{2}"
output = self.instance_commands[1].text_output
lines = output.strip().split("\n")[::-1]
last_line_with_pattern = ""
@ -324,27 +229,21 @@ class VerifyLoggingTimestamp(AntaTest):
class VerifyLoggingAccounting(AntaTest):
"""Verifies if AAA accounting logs are generated.
"""
Verifies if AAA accounting logs are generated.
Expected Results
----------------
* Success: The test will pass if AAA accounting logs are generated.
* Failure: The test will fail if AAA accounting logs are NOT generated.
Examples
--------
```yaml
anta.tests.logging:
- VerifyLoggingAccounting:
```
Expected Results:
* success: The test will pass if AAA accounting logs are generated.
* failure: The test will fail if AAA accounting logs are NOT generated.
"""
categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")]
name = "VerifyLoggingAccounting"
description = "Verifies if AAA accounting logs are generated."
categories = ["logging"]
commands = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyLoggingAccounting."""
pattern = r"cmd=show aaa accounting logs"
output = self.instance_commands[0].text_output
if re.search(pattern, output):
@ -354,27 +253,24 @@ class VerifyLoggingAccounting(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
----------------
* 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.
Examples
--------
```yaml
anta.tests.logging:
- VerifyLoggingErrors:
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging threshold errors", ofmt="text")]
name = "VerifyLoggingWarning"
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
def test(self) -> None:
"""Main test function for VerifyLoggingErrors."""
"""
Run VerifyLoggingWarning validation
"""
command_output = self.instance_commands[0].text_output
if len(command_output) == 0:

View file

@ -1,47 +1,39 @@
# 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 Multi-chassis Link Aggregation (MLAG) tests."""
"""
Test functions related to Multi-chassis Link Aggregation (MLAG)
"""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
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.tools import get_value
if TYPE_CHECKING:
from anta.models import AntaTemplate
from anta.tools.get_value import get_value
class VerifyMlagStatus(AntaTest):
"""Verifies the health status of the MLAG configuration.
"""
This test verifies the health status of the MLAG configuration.
Expected Results
----------------
* Success: The test will pass if the MLAG state is 'active', negotiation status is 'connected',
Expected Results:
* success: The test will pass if the MLAG state is 'active', negotiation status is 'connected',
peer-link status and local interface status are 'up'.
* Failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected',
* failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected',
peer-link status or local interface status are not 'up'.
* Skipped: The test will be skipped if MLAG is 'disabled'.
Examples
--------
```yaml
anta.tests.mlag:
- VerifyMlagStatus:
```
* skipped: The test will be skipped if MLAG is 'disabled'.
"""
categories: ClassVar[list[str]] = ["mlag"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)]
name = "VerifyMlagStatus"
description = "Verifies the health status of the MLAG configuration."
categories = ["mlag"]
commands = [AntaCommand(command="show mlag", ofmt="json")]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyMlagStatus."""
command_output = self.instance_commands[0].json_output
if command_output["state"] == "disabled":
self.result.is_skipped("MLAG is disabled")
@ -60,28 +52,22 @@ class VerifyMlagStatus(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
----------------
* 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.
* Skipped: The test will be skipped if MLAG is 'disabled'.
Examples
--------
```yaml
anta.tests.mlag:
- VerifyMlagInterfaces:
```
Expected Results:
* 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.
* skipped: The test will be skipped if MLAG is 'disabled'.
"""
categories: ClassVar[list[str]] = ["mlag"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)]
name = "VerifyMlagInterfaces"
description = "Verifies there are no inactive or active-partial MLAG ports."
categories = ["mlag"]
commands = [AntaCommand(command="show mlag", ofmt="json")]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyMlagInterfaces."""
command_output = self.instance_commands[0].json_output
if command_output["state"] == "disabled":
self.result.is_skipped("MLAG is disabled")
@ -93,31 +79,28 @@ class VerifyMlagInterfaces(AntaTest):
class VerifyMlagConfigSanity(AntaTest):
"""Verifies there are no MLAG config-sanity inconsistencies.
"""
This test verifies there are no MLAG config-sanity inconsistencies.
Expected Results
----------------
* 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.
* 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.
Examples
--------
```yaml
anta.tests.mlag:
- VerifyMlagConfigSanity:
```
Expected Results:
* 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.
* 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.
"""
categories: ClassVar[list[str]] = ["mlag"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag config-sanity", revision=1)]
name = "VerifyMlagConfigSanity"
description = "Verifies there are no MLAG config-sanity inconsistencies."
categories = ["mlag"]
commands = [AntaCommand(command="show mlag config-sanity", ofmt="json")]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyMlagConfigSanity."""
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")
return
keys_to_verify = ["globalConfiguration", "interfaceConfiguration"]
@ -129,38 +112,28 @@ class VerifyMlagConfigSanity(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
----------------
* 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.
* Skipped: The test will be skipped if MLAG is 'disabled'.
Examples
--------
```yaml
anta.tests.mlag:
- VerifyMlagReloadDelay:
reload_delay: 300
reload_delay_non_mlag: 330
```
Expected Results:
* 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.
* skipped: The test will be skipped if MLAG is 'disabled'.
"""
categories: ClassVar[list[str]] = ["mlag"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)]
name = "VerifyMlagReloadDelay"
description = "Verifies the MLAG reload-delay parameters."
categories = ["mlag"]
commands = [AntaCommand(command="show mlag", ofmt="json")]
class Input(AntaTest.Input):
"""Input model for the VerifyMlagReloadDelay test."""
reload_delay: PositiveInteger
"""Delay (seconds) after reboot until non peer-link ports that are 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."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
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_non_mlag: conint(ge=0) # type: ignore
"""Delay (seconds) after reboot until ports that are not part of an MLAG are enabled"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyMlagReloadDelay."""
command_output = self.instance_commands[0].json_output
if command_output["state"] == "disabled":
self.result.is_skipped("MLAG is disabled")
@ -175,45 +148,32 @@ class VerifyMlagReloadDelay(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
----------------
* 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.
* 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
```
Expected Results:
* 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.
* skipped: The test will be skipped if MLAG is 'disabled'.
"""
name = "VerifyMlagDualPrimary"
description = "Verifies the MLAG dual-primary detection parameters."
categories: ClassVar[list[str]] = ["mlag"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)]
categories = ["mlag"]
commands = [AntaCommand(command="show mlag detail", ofmt="json")]
class Input(AntaTest.Input):
"""Input model for the VerifyMlagDualPrimary test."""
detection_delay: PositiveInteger
"""Delay detection (seconds)."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
detection_delay: conint(ge=0) # type: ignore
"""Delay detection (seconds)"""
errdisabled: bool = False
"""Errdisabled all interfaces when dual-primary is detected."""
recovery_delay: PositiveInteger
"""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
"""Delay (seconds) after dual-primary detection resolves until ports that are not part of an MLAG are enabled."""
"""Errdisabled all interfaces when dual-primary is detected"""
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"""
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"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyMlagDualPrimary."""
errdisabled_action = "errdisableAllInterfaces" if self.inputs.errdisabled else "none"
command_output = self.instance_commands[0].json_output
if command_output["state"] == "disabled":
@ -236,36 +196,28 @@ class VerifyMlagDualPrimary(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.
* 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'.
Examples
--------
```yaml
anta.tests.mlag:
- VerifyMlagPrimaryPriority:
primary_priority: 3276
```
"""
name = "VerifyMlagPrimaryPriority"
description = "Verifies the configuration of the MLAG primary priority."
categories: ClassVar[list[str]] = ["mlag"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)]
categories = ["mlag"]
commands = [AntaCommand(command="show mlag detail")]
class Input(AntaTest.Input):
"""Input model for the VerifyMlagPrimaryPriority test."""
"""Inputs for the VerifyMlagPrimaryPriority test."""
primary_priority: MlagPriority
"""The expected MLAG primary priority."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyMlagPrimaryPriority."""
command_output = self.instance_commands[0].json_output
self.result.is_success()
# Skip the test if MLAG is disabled
@ -283,5 +235,5 @@ class VerifyMlagPrimaryPriority(AntaTest):
# Check primary priority
if primary_priority != self.inputs.primary_priority:
self.result.is_failure(
f"The primary priority does not match expected. Expected `{self.inputs.primary_priority}`, but found `{primary_priority}` instead.",
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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to multicast and IGMP tests."""
"""
Test functions related to multicast
"""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
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.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyIGMPSnoopingVlans(AntaTest):
"""Verifies the IGMP snooping status for the provided 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
```
"""
Verifies the IGMP snooping configuration for some VLANs.
"""
categories: ClassVar[list[str]] = ["multicast"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)]
name = "VerifyIGMPSnoopingVlans"
description = "Verifies the IGMP snooping configuration for some VLANs."
categories = ["multicast", "igmp"]
commands = [AntaCommand(command="show ip igmp snooping")]
class Input(AntaTest.Input):
"""Input model for the VerifyIGMPSnoopingVlans test."""
vlans: dict[Vlan, bool]
"""Dictionary with VLAN ID and whether IGMP snooping must be enabled (True) or disabled (False)."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
vlans: Dict[Vlan, bool]
"""Dictionary of VLANs with associated IGMP configuration status (True=enabled, False=disabled)"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyIGMPSnoopingVlans."""
command_output = self.instance_commands[0].json_output
self.result.is_success()
for vlan, enabled in self.inputs.vlans.items():
@ -60,34 +44,21 @@ class VerifyIGMPSnoopingVlans(AntaTest):
class VerifyIGMPSnoopingGlobal(AntaTest):
"""Verifies the IGMP snooping global status.
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
```
"""
Verifies the IGMP snooping global configuration.
"""
categories: ClassVar[list[str]] = ["multicast"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifyIGMPSnoopingGlobal test."""
name = "VerifyIGMPSnoopingGlobal"
description = "Verifies the IGMP snooping global configuration."
categories = ["multicast", "igmp"]
commands = [AntaCommand(command="show ip igmp snooping")]
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
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
def test(self) -> None:
"""Main test function for VerifyIGMPSnoopingGlobal."""
command_output = self.instance_commands[0].json_output
self.result.is_success()
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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to ASIC profile tests."""
"""
Test functions related to ASIC profiles
"""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar, Literal
from typing import Literal
from anta.decorators import skip_on_platforms
from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyUnifiedForwardingTableMode(AntaTest):
"""Verifies the device is using the expected UFT (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
```
"""
Verifies the device is using the expected Unified Forwarding Table mode.
"""
description = "Verifies the device is using the expected UFT mode."
categories: ClassVar[list[str]] = ["profiles"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show platform trident forwarding-table partition", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifyUnifiedForwardingTableMode test."""
name = "VerifyUnifiedForwardingTableMode"
description = ""
categories = ["profiles"]
commands = [AntaCommand(command="show platform trident forwarding-table partition", ofmt="json")]
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
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
def test(self) -> None:
"""Main test function for VerifyUnifiedForwardingTableMode."""
command_output = self.instance_commands[0].json_output
if command_output["uftMode"] == str(self.inputs.mode):
self.result.is_success()
@ -55,36 +39,22 @@ class VerifyUnifiedForwardingTableMode(AntaTest):
class VerifyTcamProfile(AntaTest):
"""Verifies that the device is using the provided Ternary Content-Addressable Memory (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
```
"""
Verifies the device is using the configured TCAM profile.
"""
description = "Verifies the device TCAM profile."
categories: ClassVar[list[str]] = ["profiles"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware tcam profile", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifyTcamProfile test."""
name = "VerifyTcamProfile"
description = "Verify that the assigned TCAM profile is actually running on the device"
categories = ["profiles"]
commands = [AntaCommand(command="show hardware tcam profile", ofmt="json")]
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
profile: str
"""Expected TCAM profile."""
"""Expected TCAM profile"""
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyTcamProfile."""
command_output = self.instance_commands[0].json_output
if command_output["pmfProfiles"]["FixedSystem"]["status"] == command_output["pmfProfiles"]["FixedSystem"]["config"] == self.inputs.profile:
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
# that can be found in the LICENSE file.
"""Module related to PTP tests."""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
"""
Test functions related to PTP (Precision Time Protocol) in EOS
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from anta.decorators import skip_on_platforms
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):
"""Verifies that the device is configured as a Precision Time Protocol (PTP) Boundary Clock (BC).
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:
```
Expected Results:
* success: The test will pass if the PTP agent is enabled globally.
* failure: The test will fail if the PTP agent is enabled globally.
"""
description = "Verifies that the device is configured as a PTP Boundary Clock."
categories: ClassVar[list[str]] = ["ptp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)]
name = "VerifyPtpStatus"
description = "Verifies if the PTP agent is enabled."
categories = ["ptp"]
commands = [AntaCommand(command="show ptp")]
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyPtpModeStatus."""
command_output = self.instance_commands[0].json_output
if (ptp_mode := command_output.get("ptpMode")) is None:
self.result.is_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:
if "ptpMode" in command_output.keys():
self.result.is_success()
else:
self.result.is_failure(f"The following interface(s) are not in a valid PTP state: '{invalid_interfaces}'")
self.result.is_failure("PTP agent disabled")

View file

@ -1,4 +1,3 @@
# 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."""

File diff suppressed because it is too large Load diff

View file

@ -1,62 +1,41 @@
# 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 generic routing tests."""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
"""
Generic routing test functions
"""
from __future__ import annotations
from functools import cache
from ipaddress import IPv4Address, IPv4Interface
from typing import TYPE_CHECKING, ClassVar, Literal
from ipaddress import IPv4Address, ip_interface
# Need to keep List for pydantic in python 3.8
from typing import List, Literal
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.tools import get_value
if TYPE_CHECKING:
import sys
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
class VerifyRoutingProtocolModel(AntaTest):
"""Verifies the configured routing protocol model.
Expected Results
----------------
* 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
```
"""
Verifies the configured routing protocol model is the one we expect.
And if there is no mismatch between the configured and operating routing protocol model.
"""
categories: ClassVar[list[str]] = ["routing"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)]
class Input(AntaTest.Input):
"""Input model for the VerifyRoutingProtocolModel test."""
name = "VerifyRoutingProtocolModel"
description = "Verifies the configured routing protocol model."
categories = ["routing"]
commands = [AntaCommand(command="show ip route summary", revision=3)]
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
model: Literal["multi-agent", "ribd"] = "multi-agent"
"""Expected routing protocol model. Defaults to `multi-agent`."""
"""Expected routing protocol model"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyRoutingProtocolModel."""
command_output = self.instance_commands[0].json_output
configured_model = command_output["protoModelStatus"]["configuredProtoModel"]
operating_model = command_output["protoModelStatus"]["operatingProtoModel"]
@ -67,46 +46,31 @@ class VerifyRoutingProtocolModel(AntaTest):
class VerifyRoutingTableSize(AntaTest):
"""Verifies the size of the IP routing table of the default VRF.
Expected Results
----------------
* 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
```
"""
Verifies the size of the IP routing table (default VRF).
Should be between the two provided thresholds.
"""
categories: ClassVar[list[str]] = ["routing"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)]
name = "VerifyRoutingTableSize"
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):
"""Input model for the VerifyRoutingTableSize test."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
minimum: int
"""Expected minimum routing table (default VRF) size"""
maximum: int
"""Expected maximum routing table (default VRF) size"""
minimum: PositiveInteger
"""Expected minimum routing table size."""
maximum: PositiveInteger
"""Expected maximum routing table size."""
@model_validator(mode="after")
def check_min_max(self) -> Self:
"""Validate that maximum is greater than minimum."""
@model_validator(mode="after") # type: ignore
def check_min_max(self) -> AntaTest.Input:
"""Validate that maximum is greater than minimum"""
if self.minimum > self.maximum:
msg = f"Minimum {self.minimum} is greater than maximum {self.maximum}"
raise ValueError(msg)
raise ValueError(f"Minimum {self.minimum} is greater than maximum {self.maximum}")
return self
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyRoutingTableSize."""
command_output = self.instance_commands[0].json_output
total_routes = int(command_output["vrfs"]["default"]["totalRoutes"])
if self.inputs.minimum <= total_routes <= self.inputs.maximum:
@ -116,143 +80,39 @@ class VerifyRoutingTableSize(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
----------------
* 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.
Examples
--------
```yaml
anta.tests.routing:
generic:
- VerifyRoutingTableEntry:
vrf: default
routes:
- 10.1.0.1
- 10.1.0.2
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["routing"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaTemplate(template="show ip route vrf {vrf} {route}", revision=4),
AntaTemplate(template="show ip route vrf {vrf}", revision=4),
]
class Input(AntaTest.Input):
"""Input model for the VerifyRoutingTableEntry test."""
name = "VerifyRoutingTableEntry"
description = "Verifies that the provided routes are present in the routing table of a specified VRF."
categories = ["routing"]
commands = [AntaTemplate(template="show ip route vrf {vrf} {route}")]
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
vrf: str = "default"
"""VRF context. Defaults to `default` VRF."""
routes: list[IPv4Address]
"""List of routes to verify."""
collect: Literal["one", "all"] = "one"
"""Route collect behavior: one=one route per command, all=all routes in vrf per command. Defaults to `one`"""
"""VRF context"""
routes: List[IPv4Address]
"""Routes to verify"""
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]
if template == VerifyRoutingTableEntry.commands[1] and self.inputs.collect == "all":
return [template.render(vrf=self.inputs.vrf)]
return []
@staticmethod
@cache
def ip_interface_ip(route: str) -> IPv4Address:
"""Return the IP address of the provided ip route with mask."""
return IPv4Interface(route).ip
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyRoutingTableEntry."""
commands_output_route_ips = set()
missing_routes = []
for command in self.instance_commands:
command_output_vrf = command.json_output["vrfs"][self.inputs.vrf]
commands_output_route_ips |= {self.ip_interface_ip(route) for route in command_output_vrf["routes"]}
missing_routes = [str(route) for route in self.inputs.routes if route not in commands_output_route_ips]
if "vrf" in command.params and "route" in command.params:
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.append(str(route))
if not missing_routes:
self.result.is_success()
else:
self.result.is_failure(f"The following route(s) are missing from the routing table of VRF {self.inputs.vrf}: {missing_routes}")
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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to OSPF tests."""
"""
OSPF test functions
"""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
from typing import Any
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:
"""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 the number of OSPF neighbors
"""
count = 0
for vrf_data in ospf_neighbor_json["vrfs"].values():
for instance_data in vrf_data["instList"].values():
for _, vrf_data in ospf_neighbor_json["vrfs"].items():
for _, instance_data in vrf_data["instList"].items():
count += len(instance_data.get("ospfNeighborEntries", []))
return count
def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]:
"""Return the OSPF neighbors whose adjacency state is not `full`.
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,
"instance": instance,
"neighbor": neighbor_data["routerId"],
"state": state,
}
for vrf, vrf_data in ospf_neighbor_json["vrfs"].items()
for instance, instance_data in vrf_data["instList"].items()
for neighbor_data in instance_data.get("ospfNeighborEntries", [])
if (state := neighbor_data["adjacencyState"]) != "full"
]
def _get_ospf_max_lsa_info(ospf_process_json: dict[str, Any]) -> list[dict[str, Any]]:
"""Return information about OSPF instances and their LSAs.
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()
]
)
return not_full_neighbors
class VerifyOSPFNeighborState(AntaTest):
"""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:
```
"""
Verifies all OSPF neighbors are in FULL state.
"""
categories: ClassVar[list[str]] = ["ospf"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)]
name = "VerifyOSPFNeighborState"
description = "Verifies all OSPF neighbors are in FULL state."
categories = ["ospf"]
commands = [AntaCommand(command="show ip ospf neighbor")]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyOSPFNeighborState."""
command_output = self.instance_commands[0].json_output
if _count_ospf_neighbor(command_output) == 0:
self.result.is_skipped("no OSPF neighbor found")
@ -126,36 +67,21 @@ class VerifyOSPFNeighborState(AntaTest):
class VerifyOSPFNeighborCount(AntaTest):
"""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
```
"""
Verifies the number of OSPF neighbors in FULL state is the one we expect.
"""
categories: ClassVar[list[str]] = ["ospf"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifyOSPFNeighborCount test."""
name = "VerifyOSPFNeighborCount"
description = "Verifies the number of OSPF neighbors in FULL state is the one we expect."
categories = ["ospf"]
commands = [AntaCommand(command="show ip ospf neighbor")]
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
number: int
"""The expected number of OSPF neighbors in FULL state."""
"""The expected number of OSPF neighbors in FULL state"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyOSPFNeighborCount."""
command_output = self.instance_commands[0].json_output
if (neighbor_count := _count_ospf_neighbor(command_output)) == 0:
self.result.is_skipped("no OSPF neighbor found")
@ -164,45 +90,6 @@ class VerifyOSPFNeighborCount(AntaTest):
if neighbor_count != self.inputs.number:
self.result.is_failure(f"device has {neighbor_count} neighbors (expected {self.inputs.number})")
not_full_neighbors = _get_not_full_ospf_neighbors(command_output)
print(not_full_neighbors)
if 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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to the EOS various security tests."""
"""
Test functions related to the EOS various security settings
"""
from __future__ import annotations
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
from datetime import datetime, timezone
from typing import TYPE_CHECKING, ClassVar, get_args
from datetime import datetime
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.input_models.security import IPSecPeer, IPSecPeers
from anta.custom_types import EcdsaKeySize, EncryptionAlgorithm, RsaKeySize
from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_failed_logs, get_item, get_value
if TYPE_CHECKING:
import sys
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
from anta.tools.get_item import get_item
from anta.tools.get_value import get_value
from anta.tools.utils import get_failed_logs
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
----------------
* 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.
Examples
--------
```yaml
anta.tests.security:
- VerifySSHStatus:
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh", ofmt="text")]
name = "VerifySSHStatus"
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
def test(self) -> None:
"""Main test function for VerifySSHStatus."""
command_output = self.instance_commands[0].text_output
try:
line = next(line for line in command_output.split("\n") if line.startswith("SSHD status"))
except StopIteration:
self.result.is_failure("Could not find SSH status in returned output.")
return
status = line.split()[-1]
line = [line for line in command_output.split("\n") if line.startswith("SSHD status")][0]
status = line.split("is ")[1]
if status == "disabled":
self.result.is_success()
@ -64,123 +48,97 @@ class VerifySSHStatus(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
----------------
* 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.
Examples
--------
```yaml
anta.tests.security:
- VerifySSHIPv4Acl:
number: 3
vrf: default
```
Expected results:
* 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.
"""
name = "VerifySSHIPv4Acl"
description = "Verifies if the SSHD agent has IPv4 ACL(s) configured."
categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ip access-list summary", revision=1)]
categories = ["security"]
commands = [AntaCommand(command="show management ssh ip access-list summary")]
class Input(AntaTest.Input):
"""Input model for the VerifySSHIPv4Acl test."""
number: PositiveInteger
"""The number of expected IPv4 ACL(s)."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
number: conint(ge=0) # type:ignore
"""The number of expected IPv4 ACL(s)"""
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
def test(self) -> None:
"""Main test function for VerifySSHIPv4Acl."""
command_output = self.instance_commands[0].json_output
ipv4_acl_list = command_output["ipAclList"]["aclList"]
ipv4_acl_number = len(ipv4_acl_list)
not_configured_acl_list = []
if ipv4_acl_number != self.inputs.number:
self.result.is_failure(f"Expected {self.inputs.number} SSH IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
return
not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
if not_configured_acl:
self.result.is_failure(f"SSH IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
for ipv4_acl in ipv4_acl_list:
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_list:
self.result.is_failure(f"SSH IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
else:
self.result.is_success()
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
----------------
* 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.
Examples
--------
```yaml
anta.tests.security:
- VerifySSHIPv6Acl:
number: 3
vrf: default
```
Expected results:
* 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.
"""
name = "VerifySSHIPv6Acl"
description = "Verifies if the SSHD agent has IPv6 ACL(s) configured."
categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ipv6 access-list summary", revision=1)]
categories = ["security"]
commands = [AntaCommand(command="show management ssh ipv6 access-list summary")]
class Input(AntaTest.Input):
"""Input model for the VerifySSHIPv6Acl test."""
number: PositiveInteger
"""The number of expected IPv6 ACL(s)."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
number: conint(ge=0) # type:ignore
"""The number of expected IPv6 ACL(s)"""
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
def test(self) -> None:
"""Main test function for VerifySSHIPv6Acl."""
command_output = self.instance_commands[0].json_output
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
ipv6_acl_number = len(ipv6_acl_list)
not_configured_acl_list = []
if ipv6_acl_number != self.inputs.number:
self.result.is_failure(f"Expected {self.inputs.number} SSH IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
return
not_configured_acl = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
if not_configured_acl:
self.result.is_failure(f"SSH IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
for ipv6_acl in ipv6_acl_list:
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_list:
self.result.is_failure(f"SSH IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
else:
self.result.is_success()
class VerifyTelnetStatus(AntaTest):
"""Verifies if Telnet is disabled in the default VRF.
"""
Verifies if Telnet is disabled in the default VRF.
Expected Results
----------------
* 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.
Examples
--------
```yaml
anta.tests.security:
- VerifyTelnetStatus:
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management telnet", revision=1)]
name = "VerifyTelnetStatus"
description = "Verifies if Telnet is disabled in the default VRF."
categories = ["security"]
commands = [AntaCommand(command="show management telnet")]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyTelnetStatus."""
command_output = self.instance_commands[0].json_output
if command_output["serverState"] == "disabled":
self.result.is_success()
@ -189,27 +147,21 @@ class VerifyTelnetStatus(AntaTest):
class VerifyAPIHttpStatus(AntaTest):
"""Verifies if eAPI HTTP server is disabled globally.
"""
Verifies if eAPI HTTP server is disabled globally.
Expected Results
----------------
* 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.
Examples
--------
```yaml
anta.tests.security:
- VerifyAPIHttpStatus:
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)]
name = "VerifyAPIHttpStatus"
description = "Verifies if eAPI HTTP server is disabled globally."
categories = ["security"]
commands = [AntaCommand(command="show management api http-commands")]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyAPIHttpStatus."""
command_output = self.instance_commands[0].json_output
if command_output["enabled"] and not command_output["httpServer"]["running"]:
self.result.is_success()
@ -218,35 +170,25 @@ class VerifyAPIHttpStatus(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
----------------
* 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.
Examples
--------
```yaml
anta.tests.security:
- VerifyAPIHttpsSSL:
profile: default
```
Expected results:
* 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.
"""
name = "VerifyAPIHttpsSSL"
description = "Verifies if the eAPI has a valid SSL profile."
categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifyAPIHttpsSSL test."""
categories = ["security"]
commands = [AntaCommand(command="show management api http-commands")]
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
profile: str
"""SSL profile to verify."""
"""SSL profile to verify"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyAPIHttpsSSL."""
command_output = self.instance_commands[0].json_output
try:
if command_output["sslProfile"]["name"] == self.inputs.profile and command_output["sslProfile"]["state"] == "valid":
@ -259,143 +201,110 @@ class VerifyAPIHttpsSSL(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
----------------
* 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.
Examples
--------
```yaml
anta.tests.security:
- VerifyAPIIPv4Acl:
number: 3
vrf: default
```
Expected results:
* 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.
"""
categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ip access-list summary", revision=1)]
name = "VerifyAPIIPv4Acl"
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):
"""Input parameters for the VerifyAPIIPv4Acl test."""
number: PositiveInteger
"""The number of expected IPv4 ACL(s)."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
number: conint(ge=0) # type:ignore
"""The number of expected IPv4 ACL(s)"""
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
def test(self) -> None:
"""Main test function for VerifyAPIIPv4Acl."""
command_output = self.instance_commands[0].json_output
ipv4_acl_list = command_output["ipAclList"]["aclList"]
ipv4_acl_number = len(ipv4_acl_list)
not_configured_acl_list = []
if ipv4_acl_number != self.inputs.number:
self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
return
not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
if not_configured_acl:
self.result.is_failure(f"eAPI IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
for ipv4_acl in ipv4_acl_list:
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_list:
self.result.is_failure(f"eAPI IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
else:
self.result.is_success()
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
----------------
* 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.
* 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
```
Expected results:
* 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.
* skipped: The test will be skipped if the number of IPv6 ACL(s) or VRF parameter is not provided.
"""
categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ipv6 access-list summary", revision=1)]
name = "VerifyAPIIPv6Acl"
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):
"""Input parameters for the VerifyAPIIPv6Acl test."""
number: PositiveInteger
"""The number of expected IPv6 ACL(s)."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
number: conint(ge=0) # type:ignore
"""The number of expected IPv6 ACL(s)"""
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
def test(self) -> None:
"""Main test function for VerifyAPIIPv6Acl."""
command_output = self.instance_commands[0].json_output
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
ipv6_acl_number = len(ipv6_acl_list)
not_configured_acl_list = []
if ipv6_acl_number != self.inputs.number:
self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
return
not_configured_acl = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
if not_configured_acl:
self.result.is_failure(f"eAPI IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
for ipv6_acl in ipv6_acl_list:
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_list:
self.result.is_failure(f"eAPI IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
else:
self.result.is_success()
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
----------------
* Success: The test will pass if the certificate's expiry date is greater than the threshold,
Expected Results:
* success: The test will pass if the certificate's expiry date is greater than the threshold,
and the certificate has the correct name, encryption algorithm, and key size.
* Failure: The test will fail if the certificate is expired or is going to expire,
* 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.
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"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaCommand(command="show management security ssl certificate", revision=1),
AntaCommand(command="show clock", revision=1),
]
name = "VerifyAPISSLCertificate"
description = "Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size."
categories = ["security"]
commands = [AntaCommand(command="show management security ssl certificate"), AntaCommand(command="show clock")]
class Input(AntaTest.Input):
"""Input parameters for the VerifyAPISSLCertificate test."""
"""
Input parameters for the VerifyAPISSLCertificate test.
"""
certificates: list[APISSLCertificate]
"""List of API SSL certificates."""
certificates: List[APISSLCertificates]
"""List of API SSL certificates"""
class APISSLCertificate(BaseModel):
"""Model for an API SSL certificate."""
class APISSLCertificates(BaseModel):
"""
This class defines the details of an API SSL certificate.
"""
certificate_name: str
"""The name of the certificate to be verified."""
@ -405,30 +314,31 @@ class VerifyAPISSLCertificate(AntaTest):
"""The common subject name of the certificate."""
encryption_algorithm: EncryptionAlgorithm
"""The encryption algorithm of the certificate."""
key_size: RsaKeySize | EcdsaKeySize
key_size: Union[RsaKeySize, EcdsaKeySize]
"""The encryption algorithm key size of the certificate."""
@model_validator(mode="after")
def validate_inputs(self) -> Self:
"""Validate the key size provided to the APISSLCertificates class.
def validate_inputs(self: BaseModel) -> BaseModel:
"""
Validate the key size provided to the APISSLCertificates class.
If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}.
If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}.
"""
if self.encryption_algorithm == "RSA" and self.key_size not in get_args(RsaKeySize):
msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {get_args(RsaKeySize)}."
raise ValueError(msg)
if self.encryption_algorithm == "ECDSA" and self.key_size not in get_args(EcdsaKeySize):
msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {get_args(EcdsaKeySize)}."
raise ValueError(msg)
if self.encryption_algorithm == "RSA" and self.key_size not in RsaKeySize.__args__:
raise ValueError(f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {RsaKeySize.__args__}.")
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
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyAPISSLCertificate."""
# Mark the result as success by default
self.result.is_success()
@ -446,7 +356,7 @@ class VerifyAPISSLCertificate(AntaTest):
continue
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
if 0 < day_difference < certificate.expiry_threshold:
@ -471,37 +381,27 @@ class VerifyAPISSLCertificate(AntaTest):
class VerifyBannerLogin(AntaTest):
"""Verifies the login banner of a device.
"""
Verifies the login banner of a device.
Expected Results
----------------
* 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.
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.
```
Expected results:
* 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.
"""
categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner login", revision=1)]
name = "VerifyBannerLogin"
description = "Verifies the login banner of a device."
categories = ["security"]
commands = [AntaCommand(command="show banner login")]
class Input(AntaTest.Input):
"""Input model for the VerifyBannerLogin test."""
"""Defines the input parameters for this test case."""
login_banner: str
"""Expected login banner of the device."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyBannerLogin."""
login_banner = self.instance_commands[0].json_output["loginBanner"]
# Remove leading and trailing whitespaces from each line
@ -513,37 +413,27 @@ class VerifyBannerLogin(AntaTest):
class VerifyBannerMotd(AntaTest):
"""Verifies the motd banner of a device.
"""
Verifies the motd banner of a device.
Expected Results
----------------
* 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.
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.
```
Expected results:
* 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.
"""
categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner motd", revision=1)]
name = "VerifyBannerMotd"
description = "Verifies the motd banner of a device."
categories = ["security"]
commands = [AntaCommand(command="show banner motd")]
class Input(AntaTest.Input):
"""Input model for the VerifyBannerMotd test."""
"""Defines the input parameters for this test case."""
motd_banner: str
"""Expected motd banner of the device."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyBannerMotd."""
motd_banner = self.instance_commands[0].json_output["motd"]
# Remove leading and trailing whitespaces from each line
@ -555,75 +445,52 @@ class VerifyBannerMotd(AntaTest):
class VerifyIPv4ACL(AntaTest):
"""Verifies the configuration of IPv4 ACLs.
"""
Verifies the configuration of IPv4 ACLs.
Expected Results
----------------
* Success: The test will pass if an IPv4 ACL is configured with the correct sequence entries.
* Failure: The test will fail if an IPv4 ACL is not configured or entries are not in sequence.
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
```
Expected results:
* success: The test will pass if an IPv4 ACL is configured with the correct sequence entries.
* failure: The test will fail if an IPv4 ACL is not configured or entries are not in sequence.
"""
categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip access-lists {acl}", revision=1)]
name = "VerifyIPv4ACL"
description = "Verifies the configuration of IPv4 ACLs."
categories = ["security"]
commands = [AntaTemplate(template="show ip access-lists {acl}")]
class Input(AntaTest.Input):
"""Input model for the VerifyIPv4ACL test."""
"""Inputs for the VerifyIPv4ACL test."""
ipv4_access_lists: list[IPv4ACL]
"""List of IPv4 ACLs to verify."""
ipv4_access_lists: List[IPv4ACL]
"""List of IPv4 ACLs to verify"""
class IPv4ACL(BaseModel):
"""Model for an IPv4 ACL."""
"""Detail of IPv4 ACL"""
name: str
"""Name of IPv4 ACL."""
"""Name of IPv4 ACL"""
entries: list[IPv4ACLEntry]
"""List of IPv4 ACL entries."""
entries: List[IPv4ACLEntries]
"""List of IPv4 ACL entries"""
class IPv4ACLEntry(BaseModel):
"""Model for an IPv4 ACL entry."""
class IPv4ACLEntries(BaseModel):
"""IPv4 ACL entries details"""
sequence: int = Field(ge=1, le=4294967295)
"""Sequence number of an ACL entry."""
"""Sequence number of an ACL entry"""
action: str
"""Action of an ACL entry."""
"""Action of an ACL entry"""
def render(self, template: AntaTemplate) -> list[AntaCommand]:
"""Render the template for each input ACL."""
return [template.render(acl=acl.name) for acl in self.inputs.ipv4_access_lists]
return [template.render(acl=acl.name, entries=acl.entries) for acl in self.inputs.ipv4_access_lists]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyIPv4ACL."""
self.result.is_success()
for command_output, acl in zip(self.instance_commands, self.inputs.ipv4_access_lists):
for command_output in self.instance_commands:
# Collecting input ACL details
acl_name = command_output.params.acl
# Retrieve the expected entries from the inputs
acl_entries = acl.entries
acl_name = command_output.params["acl"]
acl_entries = command_output.params["entries"]
# Check if ACL is configured
ipv4_acl_list = command_output.json_output["aclList"]
@ -645,173 +512,3 @@ class VerifyIPv4ACL(AntaTest):
if failed_log != f"{acl_name}:\n":
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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to the EOS various services tests."""
"""
Test functions related to the EOS various services settings
"""
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: 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):
"""Verifies the hostname of a device.
"""
Verifies the hostname of a device.
Expected Results
----------------
* 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.
Examples
--------
```yaml
anta.tests.services:
- VerifyHostname:
hostname: s1-spine1
```
Expected results:
* 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.
"""
categories: ClassVar[list[str]] = ["services"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hostname", revision=1)]
name = "VerifyHostname"
description = "Verifies the hostname of a device."
categories = ["services"]
commands = [AntaCommand(command="show hostname")]
class Input(AntaTest.Input):
"""Input model for the VerifyHostname test."""
"""Defines the input parameters for this test case."""
hostname: str
"""Expected hostname of the device."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyHostname."""
hostname = self.instance_commands[0].json_output["hostname"]
if hostname != self.inputs.hostname:
@ -55,47 +52,35 @@ class VerifyHostname(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
----------------
* 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.
* 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
```
Expected Results:
* 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.
* error: This test will error out if a domain name is invalid.
"""
name = "VerifyDNSLookup"
description = "Verifies the DNS name to IP address resolution."
categories: ClassVar[list[str]] = ["services"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="bash timeout 10 nslookup {domain}", revision=1)]
categories = ["services"]
commands = [AntaTemplate(template="bash timeout 10 nslookup {domain}")]
class Input(AntaTest.Input):
"""Input model for the VerifyDNSLookup test."""
"""Inputs for the VerifyDNSLookup test."""
domain_names: list[str]
"""List of domain names."""
domain_names: List[str]
"""List of domain names"""
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]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyDNSLookup."""
self.result.is_success()
failed_domains = []
for command in self.instance_commands:
domain = command.params.domain
domain = command.params["domain"]
output = command.json_output["messages"][0]
if f"Can't find {domain}: No answer" in output:
failed_domains.append(domain)
@ -104,109 +89,87 @@ class VerifyDNSLookup(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:
1. Confirming correctly registered with a valid IPv4 or IPv6 address with the designated VRF.
2. Ensuring an appropriate priority level.
Expected Results
----------------
* Success: The test will pass if the DNS server specified in the input is configured with the correct VRF and priority.
* Failure: The test will fail if 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
```
Expected Results:
* success: The test will pass if the DNS server specified in the input is configured with the correct VRF and priority.
* failure: The test will fail if the DNS server is not configured or if the VRF and priority of the DNS server do not match the input.
"""
categories: ClassVar[list[str]] = ["services"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip name-server", revision=1)]
name = "VerifyDNSServers"
description = "Verifies if the DNS servers are correctly configured."
categories = ["services"]
commands = [AntaCommand(command="show ip name-server")]
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."""
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
def test(self) -> None:
"""Main test function for VerifyDNSServers."""
self.result.is_success()
command_output = self.instance_commands[0].json_output["nameServerConfigs"]
self.result.is_success()
for server in self.inputs.dns_servers:
address = str(server.server_address)
vrf = server.vrf
priority = server.priority
input_dict = {"ipAddr": address, "vrf": vrf}
# Check if the DNS server is configured with specified VRF.
if (output := get_dict_superset(command_output, input_dict)) is None:
self.result.is_failure(f"{server} - Not configured")
if get_item(command_output, "ipAddr", address) is None:
self.result.is_failure(f"DNS server `{address}` is not configured with any VRF.")
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
# Check if the DNS server priority matches with expected.
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):
"""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.
* 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"]
# NOTE: Only `text` output format is supported for this command
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show errdisable recovery", ofmt="text")]
name = "VerifyErrdisableRecovery"
description = "Verifies the errdisable recovery reason, status, and interval."
categories = ["services"]
commands = [AntaCommand(command="show errdisable recovery", ofmt="text")] # Command does not support JSON output hence using text output
class Input(AntaTest.Input):
"""Input model for the VerifyErrdisableRecovery test."""
"""Inputs for the VerifyErrdisableRecovery test."""
reasons: list[ErrDisableReason]
"""List of errdisable reasons."""
reasons: List[ErrDisableReason]
"""List of errdisable reasons"""
class ErrDisableReason(BaseModel):
"""Model for an errdisable reason."""
"""Details of an errdisable reason"""
reason: ErrDisableReasons
"""Type or name of the errdisable reason."""
"""Type or name of the errdisable reason"""
interval: ErrDisableInterval
"""Interval of the reason in seconds."""
"""Interval of the reason in seconds"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyErrdisableRecovery."""
command_output = self.instance_commands[0].text_output
self.result.is_success()
for error_reason in self.inputs.reasons:

View file

@ -1,52 +1,38 @@
# 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 EOS various SNMP tests."""
"""
Test functions related to the EOS various SNMP settings
"""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
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.tools import get_value
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifySnmpStatus(AntaTest):
"""Verifies whether the SNMP agent is enabled in a specified VRF.
"""
Verifies whether the SNMP agent is enabled in a specified VRF.
Expected Results
----------------
* 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.
Examples
--------
```yaml
anta.tests.snmp:
- VerifySnmpStatus:
vrf: default
```
Expected Results:
* 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.
"""
name = "VerifySnmpStatus"
description = "Verifies if the SNMP agent is enabled."
categories: ClassVar[list[str]] = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifySnmpStatus test."""
categories = ["snmp"]
commands = [AntaCommand(command="show snmp")]
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
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
def test(self) -> None:
"""Main test function for VerifySnmpStatus."""
command_output = self.instance_commands[0].json_output
if command_output["enabled"] and self.inputs.vrf in command_output["vrfs"]["snmpVrfs"]:
self.result.is_success()
@ -55,136 +41,105 @@ class VerifySnmpStatus(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
----------------
* 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.
Examples
--------
```yaml
anta.tests.snmp:
- VerifySnmpIPv4Acl:
number: 3
vrf: default
```
Expected results:
* 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.
"""
name = "VerifySnmpIPv4Acl"
description = "Verifies if the SNMP agent has IPv4 ACL(s) configured."
categories: ClassVar[list[str]] = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv4 access-list summary", revision=1)]
categories = ["snmp"]
commands = [AntaCommand(command="show snmp ipv4 access-list summary")]
class Input(AntaTest.Input):
"""Input model for the VerifySnmpIPv4Acl test."""
number: PositiveInteger
"""The number of expected IPv4 ACL(s)."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
number: conint(ge=0) # type:ignore
"""The number of expected IPv4 ACL(s)"""
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
def test(self) -> None:
"""Main test function for VerifySnmpIPv4Acl."""
command_output = self.instance_commands[0].json_output
ipv4_acl_list = command_output["ipAclList"]["aclList"]
ipv4_acl_number = len(ipv4_acl_list)
not_configured_acl_list = []
if ipv4_acl_number != self.inputs.number:
self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
return
not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
if not_configured_acl:
self.result.is_failure(f"SNMP IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
for ipv4_acl in ipv4_acl_list:
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_list:
self.result.is_failure(f"SNMP IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
else:
self.result.is_success()
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
----------------
* 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.
Examples
--------
```yaml
anta.tests.snmp:
- VerifySnmpIPv6Acl:
number: 3
vrf: default
```
Expected results:
* 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.
"""
name = "VerifySnmpIPv6Acl"
description = "Verifies if the SNMP agent has IPv6 ACL(s) configured."
categories: ClassVar[list[str]] = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv6 access-list summary", revision=1)]
categories = ["snmp"]
commands = [AntaCommand(command="show snmp ipv6 access-list summary")]
class Input(AntaTest.Input):
"""Input model for the VerifySnmpIPv6Acl test."""
number: PositiveInteger
"""The number of expected IPv6 ACL(s)."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
number: conint(ge=0) # type:ignore
"""The number of expected IPv6 ACL(s)"""
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
def test(self) -> None:
"""Main test function for VerifySnmpIPv6Acl."""
command_output = self.instance_commands[0].json_output
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
ipv6_acl_number = len(ipv6_acl_list)
not_configured_acl_list = []
if ipv6_acl_number != self.inputs.number:
self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
return
acl_not_configured = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
if acl_not_configured:
self.result.is_failure(f"SNMP IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {acl_not_configured}")
for ipv6_acl in ipv6_acl_list:
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_list:
self.result.is_failure(f"SNMP IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
else:
self.result.is_success()
class VerifySnmpLocation(AntaTest):
"""Verifies the SNMP location of a device.
"""
This class verifies the SNMP location of a device.
Expected Results
----------------
* 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.
Examples
--------
```yaml
anta.tests.snmp:
- VerifySnmpLocation:
location: New York
```
Expected results:
* 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.
"""
categories: ClassVar[list[str]] = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
name = "VerifySnmpLocation"
description = "Verifies the SNMP location of a device."
categories = ["snmp"]
commands = [AntaCommand(command="show snmp")]
class Input(AntaTest.Input):
"""Input model for the VerifySnmpLocation test."""
"""Defines the input parameters for this test case."""
location: str
"""Expected SNMP location of the device."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySnmpLocation."""
# 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
location = self.instance_commands[0].json_output["location"]["location"]
# Verifies the expected SNMP location.
if location != self.inputs.location:
self.result.is_failure(f"Expected `{self.inputs.location}` as the location, but found `{location}` instead.")
else:
@ -192,150 +147,30 @@ class VerifySnmpLocation(AntaTest):
class VerifySnmpContact(AntaTest):
"""Verifies the SNMP contact of a device.
"""
This class verifies the SNMP contact of a device.
Expected Results
----------------
* 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.
Examples
--------
```yaml
anta.tests.snmp:
- VerifySnmpContact:
contact: Jon@example.com
```
Expected results:
* 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.
"""
categories: ClassVar[list[str]] = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
name = "VerifySnmpContact"
description = "Verifies the SNMP contact of a device."
categories = ["snmp"]
commands = [AntaCommand(command="show snmp")]
class Input(AntaTest.Input):
"""Input model for the VerifySnmpContact test."""
"""Defines the input parameters for this test case."""
contact: str
"""Expected SNMP contact details of the device."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySnmpContact."""
# 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
contact = self.instance_commands[0].json_output["contact"]["contact"]
# Verifies the expected SNMP contact.
if contact != self.inputs.contact:
self.result.is_failure(f"Expected `{self.inputs.contact}` as the contact, but found `{contact}` instead.")
else:
self.result.is_success()
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.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to the EOS software tests."""
"""
Test functions related to the EOS software
"""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
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
if TYPE_CHECKING:
from anta.models import AntaTemplate
class VerifyEOSVersion(AntaTest):
"""Verifies that 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
```
"""
Verifies the device is running one of the allowed EOS version.
"""
description = "Verifies the EOS version of the device."
categories: ClassVar[list[str]] = ["software"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)]
name = "VerifyEOSVersion"
description = "Verifies the device is running one of the allowed EOS version."
categories = ["software"]
commands = [AntaCommand(command="show version")]
class Input(AntaTest.Input):
"""Input model for the VerifyEOSVersion test."""
versions: list[str]
"""List of allowed EOS versions."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
versions: List[str]
"""List of allowed EOS versions"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyEOSVersion."""
command_output = self.instance_commands[0].json_output
if command_output["version"] in self.inputs.versions:
self.result.is_success()
@ -55,37 +38,21 @@ class VerifyEOSVersion(AntaTest):
class VerifyTerminAttrVersion(AntaTest):
"""Verifies that he 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
```
"""
Verifies the device is running one of the allowed TerminAttr version.
"""
description = "Verifies the TerminAttr version of the device."
categories: ClassVar[list[str]] = ["software"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)]
name = "VerifyTerminAttrVersion"
description = "Verifies the device is running one of the allowed TerminAttr version."
categories = ["software"]
commands = [AntaCommand(command="show version detail")]
class Input(AntaTest.Input):
"""Input model for the VerifyTerminAttrVersion test."""
versions: list[str]
"""List of allowed TerminAttr versions."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
versions: List[str]
"""List of allowed TerminAttr versions"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyTerminAttrVersion."""
command_output = self.instance_commands[0].json_output
command_output_data = command_output["details"]["packages"]["TerminAttr-core"]["version"]
if command_output_data in self.inputs.versions:
@ -95,30 +62,17 @@ class VerifyTerminAttrVersion(AntaTest):
class VerifyEOSExtensions(AntaTest):
"""Verifies that 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:
```
"""
Verifies all EOS extensions installed on the device are enabled for boot persistence.
"""
categories: ClassVar[list[str]] = ["software"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaCommand(command="show extensions", revision=2),
AntaCommand(command="show boot-extensions", revision=1),
]
name = "VerifyEOSExtensions"
description = "Verifies all EOS extensions installed on the device are enabled for boot persistence."
categories = ["software"]
commands = [AntaCommand(command="show extensions"), AntaCommand(command="show boot-extensions")]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyEOSExtensions."""
boot_extensions = []
show_extensions_command_output = self.instance_commands[0].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"
]
for extension in show_boot_extensions_command_output["extensions"]:
formatted_extension = extension.strip("\n")
if formatted_extension != "":
boot_extensions.append(formatted_extension)
extension = extension.strip("\n")
if extension != "":
boot_extensions.append(extension)
installed_extensions.sort()
boot_extensions.sort()
if installed_extensions == boot_extensions:

View file

@ -1,69 +1,52 @@
# 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 various Spanning Tree Protocol (STP) tests."""
"""
Test functions related to various Spanning Tree Protocol (STP) settings
"""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
from __future__ import annotations
from typing import Any, ClassVar, Literal
from pydantic import Field
# Need to keep List for pydantic in python 3.8
from typing import List, Literal
from anta.custom_types import Vlan
from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_value
from anta.tools.get_value import get_value
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
----------------
* 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).
Examples
--------
```yaml
anta.tests.stp:
- VerifySTPMode:
mode: rapidPvst
vlans:
- 10
- 20
```
Expected Results:
* 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).
"""
categories: ClassVar[list[str]] = ["stp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree vlan {vlan}", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifySTPMode test."""
name = "VerifySTPMode"
description = "Verifies the configured STP mode for a provided list of VLAN(s)."
categories = ["stp"]
commands = [AntaTemplate(template="show spanning-tree vlan {vlan}")]
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
mode: Literal["mstp", "rstp", "rapidPvst"] = "mstp"
"""STP mode to verify. Supported values: mstp, rstp, rapidPvst. Defaults to mstp."""
vlans: list[Vlan]
"""List of VLAN on which to verify STP mode."""
"""STP mode to verify"""
vlans: List[Vlan]
"""List of VLAN on which to verify STP mode"""
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]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySTPMode."""
not_configured = []
wrong_stp_mode = []
for command in self.instance_commands:
vlan_id = command.params.vlan
if not (
stp_mode := get_value(
command.json_output,
f"spanningTreeVlanInstances.{vlan_id}.spanningTreeVlanInstance.protocol",
)
):
if "vlan" in command.params:
vlan_id = command.params["vlan"]
if not (stp_mode := get_value(command.json_output, f"spanningTreeVlanInstances.{vlan_id}.spanningTreeVlanInstance.protocol")):
not_configured.append(vlan_id)
elif stp_mode != self.inputs.mode:
wrong_stp_mode.append(vlan_id)
@ -76,27 +59,21 @@ class VerifySTPMode(AntaTest):
class VerifySTPBlockedPorts(AntaTest):
"""Verifies there is no STP blocked ports.
"""
Verifies there is no STP blocked ports.
Expected Results
----------------
* 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.
Examples
--------
```yaml
anta.tests.stp:
- VerifySTPBlockedPorts:
```
Expected Results:
* 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.
"""
categories: ClassVar[list[str]] = ["stp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree blockedports", revision=1)]
name = "VerifySTPBlockedPorts"
description = "Verifies there is no STP blocked ports."
categories = ["stp"]
commands = [AntaCommand(command="show spanning-tree blockedports")]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySTPBlockedPorts."""
command_output = self.instance_commands[0].json_output
if not (stp_instances := command_output["spanningTreeInstances"]):
self.result.is_success()
@ -107,27 +84,21 @@ class VerifySTPBlockedPorts(AntaTest):
class VerifySTPCounters(AntaTest):
"""Verifies there is no errors in STP BPDU packets.
"""
Verifies there is no errors in STP BPDU packets.
Expected Results
----------------
* 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).
Examples
--------
```yaml
anta.tests.stp:
- VerifySTPCounters:
```
Expected Results:
* 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).
"""
categories: ClassVar[list[str]] = ["stp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree counters", revision=1)]
name = "VerifySTPCounters"
description = "Verifies there is no errors in STP BPDU packets."
categories = ["stp"]
commands = [AntaCommand(command="show spanning-tree counters")]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySTPCounters."""
command_output = self.instance_commands[0].json_output
interfaces_with_errors = [
interface for interface, counters in command_output["interfaces"].items() if counters["bpduTaggedError"] or counters["bpduOtherError"] != 0
@ -139,102 +110,77 @@ class VerifySTPCounters(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
----------------
* 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).
Examples
--------
```yaml
anta.tests.stp:
- VerifySTPForwardingPorts:
vlans:
- 10
- 20
```
Expected Results:
* 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).
"""
name = "VerifySTPForwardingPorts"
description = "Verifies that all interfaces are forwarding for a provided list of VLAN(s)."
categories: ClassVar[list[str]] = ["stp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree topology vlan {vlan} status", revision=1)]
categories = ["stp"]
commands = [AntaTemplate(template="show spanning-tree topology vlan {vlan} status")]
class Input(AntaTest.Input):
"""Input model for the VerifySTPForwardingPorts test."""
vlans: list[Vlan]
"""List of VLAN on which to verify forwarding states."""
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
vlans: List[Vlan]
"""List of VLAN on which to verify forwarding states"""
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]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySTPForwardingPorts."""
not_configured = []
not_forwarding = []
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")):
not_configured.append(vlan_id)
else:
interfaces_not_forwarding = []
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"]
if interfaces_not_forwarding:
not_forwarding.append({f"VLAN {vlan_id}": interfaces_not_forwarding})
if not_configured:
self.result.is_failure(f"STP instance is not configured for the following VLAN(s): {not_configured}")
if not_forwarding:
self.result.is_failure(f"The following VLAN(s) have interface(s) that are not in a forwarding state: {not_forwarding}")
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:
self.result.is_success()
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
----------------
* 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).
Examples
--------
```yaml
anta.tests.stp:
- VerifySTPRootPriority:
priority: 32768
instances:
- 10
- 20
```
Expected Results:
* 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).
"""
categories: ClassVar[list[str]] = ["stp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree root detail", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifySTPRootPriority test."""
name = "VerifySTPRootPriority"
description = "Verifies the STP root priority for a provided list of VLAN or MST instance ID(s)."
categories = ["stp"]
commands = [AntaCommand(command="show spanning-tree root detail")]
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
priority: int
"""STP root priority to verify."""
instances: list[Vlan] = Field(default=[])
"""STP root priority to verify"""
instances: List[Vlan] = []
"""List of VLAN or MST instance ID(s). If empty, ALL VLAN or MST instance ID(s) will be verified."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySTPRootPriority."""
command_output = self.instance_commands[0].json_output
if not (stp_instances := command_output["instances"]):
self.result.is_failure("No STP instances configured")
return
# 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"):
prefix = "MST"
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}")
else:
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