Compare commits

..

10 commits

Author SHA1 Message Date
acc044718f
Releasing debian version 1.2.0-1.
Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-02-05 11:55:23 +01:00
6277bef8ef
Updating source url in copyright.
Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-02-05 11:55:22 +01:00
afeccccd6a
Merging upstream version 1.2.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-02-05 11:55:22 +01:00
ae7b7df396
Releasing debian version 1.1.0-1.
Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-02-05 11:54:57 +01:00
7e78d93566
Updating github urls to new project home.
Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-02-05 11:54:56 +01:00
6252b03e63
Updating homepage field.
Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-02-05 11:54:56 +01:00
2044ea6182
Merging upstream version 1.1.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-02-05 11:54:55 +01:00
50f8dbf7e8
Releasing debian version 1.0.0-1.
Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-02-05 11:54:06 +01:00
3ccac88507
Merging upstream version 1.0.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-02-05 11:54:06 +01:00
256a120fdd
Releasing debian version 0.15.0-1.
Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-02-05 11:51:33 +01:00
269 changed files with 18497 additions and 8003 deletions

View file

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

@ -21,7 +21,17 @@
"ms-python.pylint", "ms-python.pylint",
"LittleFoxTeam.vscode-python-test-adapter", "LittleFoxTeam.vscode-python-test-adapter",
"njqdev.vscode-python-typehint", "njqdev.vscode-python-typehint",
"hbenl.vscode-test-explorer" "hbenl.vscode-test-explorer",
"codezombiech.gitignore",
"ms-python.isort",
"eriklynd.json-tools",
"ms-python.vscode-pylance",
"tuxtina.json2yaml",
"christian-kohler.path-intellisense",
"ms-python.vscode-pylance",
"njqdev.vscode-python-typehint",
"LittleFoxTeam.vscode-python-test-adapter",
"donjayamanne.python-environment-manager"
] ]
} }
}, },

View file

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

View file

@ -30,7 +30,7 @@ class SafeDumper(yaml.SafeDumper):
https://github.com/yaml/pyyaml/issues/234#issuecomment-765894586. https://github.com/yaml/pyyaml/issues/234#issuecomment-765894586.
""" """
# pylint: disable=R0901,W0613,W1113 # pylint: disable=R0901
def increase_indent(self, flow=False, *args, **kwargs): def increase_indent(self, flow=False, *args, **kwargs):
return super().increase_indent(flow=flow, indentless=False) return super().increase_indent(flow=flow, indentless=False)

98
.github/markdownlint.yaml vendored Normal file
View file

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

0
.github/markdownlintignore vendored Normal file
View file

2
.github/release.md vendored
View file

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

View file

@ -46,7 +46,7 @@ jobs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
strategy: strategy:
matrix: matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"] python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
needs: file-changes needs: file-changes
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -59,30 +59,19 @@ jobs:
pip install . pip install .
- name: install dev requirements - name: install dev requirements
run: pip install .[dev] run: pip install .[dev]
missing-documentation: # @gmuloc: commenting this out for now
name: "Warning documentation is missing" #missing-documentation:
runs-on: ubuntu-20.04 # name: "Warning documentation is missing"
needs: [file-changes] # runs-on: ubuntu-20.04
if: needs.file-changes.outputs.cli == 'true' && needs.file-changes.outputs.docs == 'false' # needs: [file-changes]
steps: # if: needs.file-changes.outputs.cli == 'true' && needs.file-changes.outputs.docs == 'false'
- name: Documentation is missing # steps:
uses: GrantBirki/comment@v2.0.10 # - name: Documentation is missing
with: # uses: GrantBirki/comment@v2.0.10
body: | # with:
Please consider that documentation is missing under `docs/` folder. # body: |
You should update documentation to reflect your change, or maybe not :) # Please consider that documentation is missing under `docs/` folder.
lint-yaml: # You should update documentation to reflect your change, or maybe not :)
name: Run linting for yaml files
runs-on: ubuntu-20.04
needs: [file-changes, check-requirements]
if: needs.file-changes.outputs.code == 'true'
steps:
- uses: actions/checkout@v4
- name: yaml-lint
uses: ibiqlik/action-yamllint@v3
with:
config_file: .yamllint.yml
file_or_dir: .
lint-python: lint-python:
name: Check the code style name: Check the code style
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
@ -119,7 +108,7 @@ jobs:
needs: [lint-python, type-python] needs: [lint-python, type-python]
strategy: strategy:
matrix: matrix:
python: ["3.9", "3.10", "3.11", "3.12"] python: ["3.9", "3.10", "3.11", "3.12", "3.13"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Python - name: Setup Python
@ -130,10 +119,27 @@ jobs:
run: pip install tox tox-gh-actions run: pip install tox tox-gh-actions
- name: "Run pytest via tox for ${{ matrix.python }}" - name: "Run pytest via tox for ${{ matrix.python }}"
run: tox run: tox
test-python-windows:
name: Pytest on 3.12 for windows
runs-on: windows-2022
needs: [lint-python, type-python]
env:
# Required to prevent asyncssh to fail.
USERNAME: WindowsUser
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: 3.12
- name: Install dependencies
run: pip install tox tox-gh-actions
- name: Run pytest via tox for 3.12 on Windows
run: tox
test-documentation: test-documentation:
name: Build offline documentation for testing name: Build offline documentation for testing
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [lint-python, type-python, test-python] needs: [test-python]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Python - name: Setup Python
@ -144,3 +150,20 @@ jobs:
run: pip install .[doc] run: pip install .[doc]
- name: "Build mkdocs documentation offline" - name: "Build mkdocs documentation offline"
run: mkdocs build run: mkdocs build
benchmarks:
name: Benchmark ANTA for Python 3.12
runs-on: ubuntu-latest
needs: [test-python]
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install .[dev]
- name: Run benchmarks
uses: CodSpeedHQ/action@v3
with:
token: ${{ secrets.CODSPEED_TOKEN }}
run: pytest --codspeed --no-cov --log-cli-level INFO tests/benchmark

22
.github/workflows/codspeed.yml vendored Normal file
View file

@ -0,0 +1,22 @@
---
name: Run benchmarks manually
on:
workflow_dispatch:
jobs:
benchmarks:
name: Benchmark ANTA for Python 3.12
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install .[dev]
- name: Run benchmarks
uses: CodSpeedHQ/action@v3
with:
token: ${{ secrets.CODSPEED_TOKEN }}
run: pytest --codspeed --no-cov --log-cli-level INFO tests/benchmark

View file

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

View file

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

View file

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

15
.github/workflows/secret-scanner.yml vendored Normal file
View file

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

44
.github/workflows/sonar.yml vendored Normal file
View file

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

20
.gitignore vendored
View file

@ -1,8 +1,10 @@
__pycache__ __pycache__
*.pyc *.pyc
.pages .pages
.coverage
.pytest_cache .pytest_cache
.mypy_cache
.ruff_cache
.cache
build build
dist dist
*.egg-info *.egg-info
@ -46,14 +48,13 @@ htmlcov/
.tox/ .tox/
.nox/ .nox/
.coverage .coverage
coverage_html_report
.coverage.* .coverage.*
.cache
nosetests.xml nosetests.xml
coverage.xml coverage.xml
*.cover *.cover
*.py,cover *.py,cover
.hypothesis/ .hypothesis/
.pytest_cache/
cover/ cover/
report.html report.html
@ -98,16 +99,3 @@ venv.bak/
# VScode settings # VScode settings
.vscode .vscode
test.env
tech-support/
tech-support/*
2*
**/report.html
.*report.html
# direnv file
.envrc
clab-atd-anta/*
clab-atd-anta/

View file

@ -1,11 +1,14 @@
--- ---
# See https://pre-commit.com for more information # See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks # See https://pre-commit.com/hooks.html for more hooks
ci:
autoupdate_commit_msg: "ci: pre-commit autoupdate"
files: ^(anta|docs|scripts|tests|asynceapi)/ files: ^(anta|docs|scripts|tests|asynceapi)/
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0 rev: v5.0.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
exclude: docs/.*.svg exclude: docs/.*.svg
@ -15,7 +18,7 @@ repos:
- id: check-merge-conflict - id: check-merge-conflict
- repo: https://github.com/Lucas-C/pre-commit-hooks - repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.5.4 rev: v1.5.5
hooks: hooks:
- name: Check and insert license on Python files - name: Check and insert license on Python files
id: insert-license id: insert-license
@ -32,7 +35,7 @@ repos:
- name: Check and insert license on Markdown files - name: Check and insert license on Markdown files
id: insert-license id: insert-license
files: .*\.md$ files: .*\.md$
# exclude: exclude: ^tests/data/.*\.md$
args: args:
- --license-filepath - --license-filepath
- .github/license-short.txt - .github/license-short.txt
@ -43,7 +46,7 @@ repos:
- '<!--| ~| -->' - '<!--| ~| -->'
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.2 rev: v0.8.4
hooks: hooks:
- id: ruff - id: ruff
name: Run Ruff linter name: Run Ruff linter
@ -51,11 +54,10 @@ repos:
- id: ruff-format - id: ruff-format
name: Run Ruff formatter name: Run Ruff formatter
- repo: local # as per https://pylint.pycqa.org/en/latest/user_guide/installation/pre-commit-integration.html - repo: https://github.com/pycqa/pylint
rev: "v3.3.2"
hooks: hooks:
- id: pylint - id: pylint
entry: pylint
language: python
name: Check code style with pylint name: Check code style with pylint
description: This hook runs pylint. description: This hook runs pylint.
types: [python] types: [python]
@ -63,9 +65,18 @@ repos:
- -rn # Only display messages - -rn # Only display messages
- -sn # Don't display the score - -sn # Don't display the score
- --rcfile=pyproject.toml # Link to config file - --rcfile=pyproject.toml # Link to config file
additional_dependencies:
- anta[cli]
- types-PyYAML
- types-requests
- types-pyOpenSSL
- pylint_pydantic
- pytest
- pytest-codspeed
- respx
- repo: https://github.com/codespell-project/codespell - repo: https://github.com/codespell-project/codespell
rev: v2.2.6 rev: v2.3.0
hooks: hooks:
- id: codespell - id: codespell
name: Checks for common misspellings in text files. name: Checks for common misspellings in text files.
@ -74,7 +85,7 @@ repos:
types: [text] types: [text]
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0 rev: v1.14.0
hooks: hooks:
- id: mypy - id: mypy
name: Check typing with mypy name: Check typing with mypy
@ -87,3 +98,29 @@ repos:
- types-pyOpenSSL - types-pyOpenSSL
- pytest - pytest
files: ^(anta|tests)/ files: ^(anta|tests)/
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.43.0
hooks:
- id: markdownlint
name: Check Markdown files style.
args:
- --config=.github/markdownlint.yaml
- --ignore-path=.github/markdownlintignore
- --fix
- repo: local
hooks:
- id: examples-test
name: Generate examples/tests.yaml
entry: >-
sh -c "docs/scripts/generate_examples_tests.py"
language: python
types: [python]
files: anta/
verbose: true
pass_filenames: false
additional_dependencies:
- anta[cli]
# TODO: next can go once we have it added to anta properly
- numpydoc

20
.vscode/settings.json vendored
View file

@ -1,20 +1,14 @@
{ {
"ruff.enable": true, "ruff.enable": true,
"python.testing.unittestEnabled": false, "ruff.configuration": "pyproject.toml",
"python.testing.pytestEnabled": true, "python.testing.pytestEnabled": true,
"pylint.importStrategy": "fromEnvironment",
"mypy-type-checker.importStrategy": "fromEnvironment",
"mypy-type-checker.args": [
"--config-file=pyproject.toml"
],
"pylint.severity": {
"refactor": "Warning"
},
"pylint.args": [
"--load-plugins", "pylint_pydantic",
"--rcfile=pylintrc"
],
"python.testing.pytestArgs": [ "python.testing.pytestArgs": [
"tests" "tests"
], ],
"githubIssues.issueBranchTitle": "issues/${issueNumber}-${issueTitle}",
"pylint.importStrategy": "fromEnvironment",
"pylint.args": [
"--rcfile=pyproject.toml"
],
} }

View file

@ -37,11 +37,11 @@ RUN adduser --system anta
LABEL "org.opencontainers.image.title"="anta" \ LABEL "org.opencontainers.image.title"="anta" \
"org.opencontainers.artifact.description"="network-test-automation in a Python package and Python scripts to test Arista devices." \ "org.opencontainers.artifact.description"="network-test-automation in a Python package and Python scripts to test Arista devices." \
"org.opencontainers.image.description"="network-test-automation in a Python package and Python scripts to test Arista devices." \ "org.opencontainers.image.description"="network-test-automation in a Python package and Python scripts to test Arista devices." \
"org.opencontainers.image.source"="https://github.com/arista-netdevops-community/anta" \ "org.opencontainers.image.source"="https://github.com/aristanetworks/anta" \
"org.opencontainers.image.url"="https://www.anta.ninja" \ "org.opencontainers.image.url"="https://www.anta.ninja" \
"org.opencontainers.image.documentation"="https://www.anta.ninja" \ "org.opencontainers.image.documentation"="https://anta.arista.com" \
"org.opencontainers.image.licenses"="Apache-2.0" \ "org.opencontainers.image.licenses"="Apache-2.0" \
"org.opencontainers.image.vendor"="The anta contributors." \ "org.opencontainers.image.vendor"="Arista Networks" \
"org.opencontainers.image.authors"="Khelil Sator, Angélique Phillipps, Colin MacGiollaEáin, Matthieu Tache, Onur Gashi, Paul Lavelle, Guillaume Mulocher, Thomas Grimonet" \ "org.opencontainers.image.authors"="Khelil Sator, Angélique Phillipps, Colin MacGiollaEáin, Matthieu Tache, Onur Gashi, Paul Lavelle, Guillaume Mulocher, Thomas Grimonet" \
"org.opencontainers.image.base.name"="python" \ "org.opencontainers.image.base.name"="python" \
"org.opencontainers.image.revision"="dev" \ "org.opencontainers.image.revision"="dev" \

View file

@ -20,7 +20,10 @@ __credits__ = [
__copyright__ = "Copyright 2022-2024, Arista Networks, Inc." __copyright__ = "Copyright 2022-2024, Arista Networks, Inc."
# ANTA Debug Mode environment variable # ANTA Debug Mode environment variable
__DEBUG__ = bool(os.environ.get("ANTA_DEBUG", "").lower() == "true") __DEBUG__ = os.environ.get("ANTA_DEBUG", "").lower() == "true"
if __DEBUG__:
# enable asyncio DEBUG mode when __DEBUG__ is enabled
os.environ["PYTHONASYNCIODEBUG"] = "1"
# Source: https://rich.readthedocs.io/en/stable/appendix/colors.html # Source: https://rich.readthedocs.io/en/stable/appendix/colors.html
@ -45,4 +48,4 @@ RICH_COLOR_THEME = {
"unset": RICH_COLOR_PALETTE.UNSET, "unset": RICH_COLOR_PALETTE.UNSET,
} }
GITHUB_SUGGESTION = "Please reach out to the maintainer team or open an issue on Github: https://github.com/arista-netdevops-community/anta." GITHUB_SUGGESTION = "Please reach out to the maintainer team or open an issue on Github: https://github.com/aristanetworks/anta."

View file

@ -10,21 +10,29 @@ import logging
import math import math
from collections import defaultdict from collections import defaultdict
from inspect import isclass from inspect import isclass
from itertools import chain
from json import load as json_load
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Union from typing import TYPE_CHECKING, Any, Literal, Optional, Union
from warnings import warn
import yaml
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_serializer, model_validator
from pydantic.types import ImportString from pydantic.types import ImportString
from pydantic_core import PydanticCustomError from pydantic_core import PydanticCustomError
from yaml import YAMLError, safe_load from yaml import YAMLError, safe_dump, safe_load
from anta.logger import anta_log_exception from anta.logger import anta_log_exception
from anta.models import AntaTest from anta.models import AntaTest
if TYPE_CHECKING: if TYPE_CHECKING:
import sys
from types import ModuleType from types import ModuleType
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# { <module_name> : [ { <test_class_name>: <input_as_dict_or_None> }, ... ] } # { <module_name> : [ { <test_class_name>: <input_as_dict_or_None> }, ... ] }
@ -37,8 +45,12 @@ ListAntaTestTuples = list[tuple[type[AntaTest], Optional[Union[AntaTest.Input, d
class AntaTestDefinition(BaseModel): class AntaTestDefinition(BaseModel):
"""Define a test with its associated inputs. """Define a test with its associated inputs.
test: An AntaTest concrete subclass Attributes
inputs: The associated AntaTest.Input subclass instance ----------
test
An AntaTest concrete subclass.
inputs
The associated AntaTest.Input subclass instance.
""" """
model_config = ConfigDict(frozen=True) model_config = ConfigDict(frozen=True)
@ -58,6 +70,7 @@ class AntaTestDefinition(BaseModel):
Returns Returns
------- -------
dict
A dictionary representing the model. A dictionary representing the model.
""" """
return {self.test.__name__: self.inputs} return {self.test.__name__: self.inputs}
@ -116,7 +129,7 @@ class AntaTestDefinition(BaseModel):
raise ValueError(msg) raise ValueError(msg)
@model_validator(mode="after") @model_validator(mode="after")
def check_inputs(self) -> AntaTestDefinition: def check_inputs(self) -> Self:
"""Check the `inputs` field typing. """Check the `inputs` field typing.
The `inputs` class attribute needs to be an instance of the AntaTest.Input subclass defined in the class `test`. The `inputs` class attribute needs to be an instance of the AntaTest.Input subclass defined in the class `test`.
@ -130,7 +143,7 @@ class AntaTestDefinition(BaseModel):
class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods
"""Represents an ANTA Test Catalog File. """Represents an ANTA Test Catalog File.
Example: Example
------- -------
A valid test catalog file must have the following structure: A valid test catalog file must have the following structure:
``` ```
@ -147,7 +160,7 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[ModuleType, list[Any]]: def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[ModuleType, list[Any]]:
"""Allow the user to provide a data structure with nested Python modules. """Allow the user to provide a data structure with nested Python modules.
Example: Example
------- -------
``` ```
anta.tests.routing: anta.tests.routing:
@ -166,7 +179,7 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
module_name = f".{module_name}" # noqa: PLW2901 module_name = f".{module_name}" # noqa: PLW2901
try: try:
module: ModuleType = importlib.import_module(name=module_name, package=package) module: ModuleType = importlib.import_module(name=module_name, package=package)
except Exception as e: # pylint: disable=broad-exception-caught except Exception as e:
# A test module is potentially user-defined code. # A test module is potentially user-defined code.
# We need to catch everything if we want to have meaningful logs # We need to catch everything if we want to have meaningful logs
module_str = f"{module_name[1:] if module_name.startswith('.') else module_name}{f' from package {package}' if package else ''}" module_str = f"{module_name[1:] if module_name.startswith('.') else module_name}{f' from package {package}' if package else ''}"
@ -232,13 +245,24 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
Returns Returns
------- -------
str
The YAML representation string of this model. The YAML representation string of this model.
""" """
# TODO: Pydantic and YAML serialization/deserialization is not supported natively. # TODO: Pydantic and YAML serialization/deserialization is not supported natively.
# This could be improved. # This could be improved.
# https://github.com/pydantic/pydantic/issues/1043 # https://github.com/pydantic/pydantic/issues/1043
# Explore if this worth using this: https://github.com/NowanIlfideme/pydantic-yaml # 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) return safe_dump(safe_load(self.model_dump_json(serialize_as_any=True, exclude_unset=True)), indent=2, width=math.inf)
def to_json(self) -> str:
"""Return a JSON representation string of this model.
Returns
-------
str
The JSON representation string of this model.
"""
return self.model_dump_json(serialize_as_any=True, exclude_unset=True, indent=2)
class AntaCatalog: class AntaCatalog:
@ -254,10 +278,12 @@ class AntaCatalog:
) -> None: ) -> None:
"""Instantiate an AntaCatalog instance. """Instantiate an AntaCatalog instance.
Args: Parameters
---- ----------
tests: A list of AntaTestDefinition instances. tests
filename: The path from which the catalog is loaded. A list of AntaTestDefinition instances.
filename
The path from which the catalog is loaded.
""" """
self._tests: list[AntaTestDefinition] = [] self._tests: list[AntaTestDefinition] = []
@ -270,11 +296,14 @@ class AntaCatalog:
else: else:
self._filename = Path(filename) self._filename = Path(filename)
# Default indexes for faster access self.indexes_built: bool
self.tag_to_tests: defaultdict[str | None, set[AntaTestDefinition]] = defaultdict(set) self.tag_to_tests: defaultdict[str | None, set[AntaTestDefinition]]
self.tests_without_tags: set[AntaTestDefinition] = set() self._init_indexes()
self.indexes_built: bool = False
self.final_tests_count: int = 0 def _init_indexes(self) -> None:
"""Init indexes related variables."""
self.tag_to_tests = defaultdict(set)
self.indexes_built = False
@property @property
def filename(self) -> Path | None: def filename(self) -> Path | None:
@ -298,19 +327,30 @@ class AntaCatalog:
self._tests = value self._tests = value
@staticmethod @staticmethod
def parse(filename: str | Path) -> AntaCatalog: def parse(filename: str | Path, file_format: Literal["yaml", "json"] = "yaml") -> AntaCatalog:
"""Create an AntaCatalog instance from a test catalog file. """Create an AntaCatalog instance from a test catalog file.
Args: Parameters
---- ----------
filename: Path to test catalog YAML file filename
Path to test catalog YAML or JSON file.
file_format
Format of the file, either 'yaml' or 'json'.
Returns
-------
AntaCatalog
An AntaCatalog populated with the file content.
""" """
if file_format not in ["yaml", "json"]:
message = f"'{file_format}' is not a valid format for an AntaCatalog file. Only 'yaml' and 'json' are supported."
raise ValueError(message)
try: try:
file: Path = filename if isinstance(filename, Path) else Path(filename) file: Path = filename if isinstance(filename, Path) else Path(filename)
with file.open(encoding="UTF-8") as f: with file.open(encoding="UTF-8") as f:
data = safe_load(f) data = safe_load(f) if file_format == "yaml" else json_load(f)
except (TypeError, YAMLError, OSError) as e: except (TypeError, YAMLError, OSError, ValueError) as e:
message = f"Unable to parse ANTA Test Catalog file '{filename}'" message = f"Unable to parse ANTA Test Catalog file '{filename}'"
anta_log_exception(e, message, logger) anta_log_exception(e, message, logger)
raise raise
@ -325,11 +365,17 @@ class AntaCatalog:
It is the data structure returned by `yaml.load()` function of a valid It is the data structure returned by `yaml.load()` function of a valid
YAML Test Catalog file. YAML Test Catalog file.
Args: Parameters
---- ----------
data: Python dictionary used to instantiate the AntaCatalog instance data
filename: value to be set as AntaCatalog instance attribute Python dictionary used to instantiate the AntaCatalog instance.
filename
value to be set as AntaCatalog instance attribute
Returns
-------
AntaCatalog
An AntaCatalog populated with the 'data' dictionary content.
""" """
tests: list[AntaTestDefinition] = [] tests: list[AntaTestDefinition] = []
if data is None: if data is None:
@ -359,10 +405,15 @@ class AntaCatalog:
See ListAntaTestTuples type alias for details. See ListAntaTestTuples type alias for details.
Args: Parameters
---- ----------
data: Python list used to instantiate the AntaCatalog instance data
Python list used to instantiate the AntaCatalog instance.
Returns
-------
AntaCatalog
An AntaCatalog populated with the 'data' list content.
""" """
tests: list[AntaTestDefinition] = [] tests: list[AntaTestDefinition] = []
try: try:
@ -372,24 +423,54 @@ class AntaCatalog:
raise raise
return AntaCatalog(tests) return AntaCatalog(tests)
def merge(self, catalog: AntaCatalog) -> AntaCatalog: @classmethod
"""Merge two AntaCatalog instances. def merge_catalogs(cls, catalogs: list[AntaCatalog]) -> AntaCatalog:
"""Merge multiple AntaCatalog instances.
Args: Parameters
---- ----------
catalog: AntaCatalog instance to merge to this instance. catalogs
A list of AntaCatalog instances to merge.
Returns Returns
------- -------
AntaCatalog
A new AntaCatalog instance containing the tests of all the input catalogs.
"""
combined_tests = list(chain(*(catalog.tests for catalog in catalogs)))
return cls(tests=combined_tests)
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. A new AntaCatalog instance containing the tests of the two instances.
""" """
return AntaCatalog(tests=self.tests + catalog.tests) # 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: def dump(self) -> AntaCatalogFile:
"""Return an AntaCatalogFile instance from this AntaCatalog instance. """Return an AntaCatalogFile instance from this AntaCatalog instance.
Returns Returns
------- -------
AntaCatalogFile
An AntaCatalogFile instance containing tests of this AntaCatalog instance. An AntaCatalogFile instance containing tests of this AntaCatalog instance.
""" """
root: dict[ImportString[Any], list[AntaTestDefinition]] = {} root: dict[ImportString[Any], list[AntaTestDefinition]] = {}
@ -403,9 +484,7 @@ class AntaCatalog:
If a `filtered_tests` set is provided, only the tests in this set will be indexed. If a `filtered_tests` set is provided, only the tests in this set will be indexed.
This method populates two attributes: This method populates the tag_to_tests attribute, which is a dictionary mapping tags to sets of tests.
- tag_to_tests: A dictionary mapping each tag to a set of tests that contain it.
- tests_without_tags: A set of tests that do not have any tags.
Once the indexes are built, the `indexes_built` attribute is set to True. Once the indexes are built, the `indexes_built` attribute is set to True.
""" """
@ -419,27 +498,34 @@ class AntaCatalog:
for tag in test_tags: for tag in test_tags:
self.tag_to_tests[tag].add(test) self.tag_to_tests[tag].add(test)
else: else:
self.tests_without_tags.add(test) self.tag_to_tests[None].add(test)
self.tag_to_tests[None] = self.tests_without_tags
self.indexes_built = True 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]: 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. """Return all tests that match a given set of tags, according to the specified strictness.
Args: Parameters
---- ----------
tags: The tags to filter tests by. If empty, return all tests without tags. tags
strict: If True, returns only tests that contain all specified tags (intersection). 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). If False, returns tests that contain any of the specified tags (union).
Returns Returns
------- -------
set[AntaTestDefinition]: A set of tests that match the given tags. set[AntaTestDefinition]
A set of tests that match the given tags.
Raises Raises
------ ------
ValueError: If the indexes have not been built prior to method call. ValueError
If the indexes have not been built prior to method call.
""" """
if not self.indexes_built: if not self.indexes_built:
msg = "Indexes have not been built yet. Call build_indexes() first." msg = "Indexes have not been built yet. Call build_indexes() first."

View file

@ -35,7 +35,7 @@ except ImportError as exc:
cli = build_cli(exc) cli = build_cli(exc)
__all__ = ["cli", "anta"] __all__ = ["anta", "cli"]
if __name__ == "__main__": if __name__ == "__main__":
cli() cli()

View file

@ -25,7 +25,8 @@ logger = logging.getLogger(__name__)
@click.group(cls=AliasedGroup) @click.group(cls=AliasedGroup)
@click.pass_context @click.pass_context
@click.version_option(__version__) @click.help_option(allow_from_autoenv=False)
@click.version_option(__version__, allow_from_autoenv=False)
@click.option( @click.option(
"--log-file", "--log-file",
help="Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout.", help="Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout.",
@ -61,7 +62,7 @@ def cli() -> None:
"""Entrypoint for pyproject.toml.""" """Entrypoint for pyproject.toml."""
try: try:
anta(obj={}, auto_envvar_prefix="ANTA") anta(obj={}, auto_envvar_prefix="ANTA")
except Exception as exc: # pylint: disable=broad-exception-caught except Exception as exc: # noqa: BLE001
anta_log_exception( anta_log_exception(
exc, exc,
f"Uncaught Exception when running ANTA CLI\n{GITHUB_SUGGESTION}", f"Uncaught Exception when running ANTA CLI\n{GITHUB_SUGGESTION}",

View file

@ -35,7 +35,6 @@ def run_cmd(
version: Literal["1", "latest"], version: Literal["1", "latest"],
revision: int, revision: int,
) -> None: ) -> None:
# pylint: disable=too-many-arguments
"""Run arbitrary command to an ANTA device.""" """Run arbitrary command to an ANTA device."""
console.print(f"Run command [green]{command}[/green] on [red]{device.name}[/red]") console.print(f"Run command [green]{command}[/green] on [red]{device.name}[/red]")
# I do not assume the following line, but click make me do it # I do not assume the following line, but click make me do it
@ -71,12 +70,14 @@ def run_template(
version: Literal["1", "latest"], version: Literal["1", "latest"],
revision: int, revision: int,
) -> None: ) -> None:
# pylint: disable=too-many-arguments # Using \b for click
# ruff: noqa: D301
"""Run arbitrary templated command to an ANTA device. """Run arbitrary templated command to an ANTA device.
Takes a list of arguments (keys followed by a value) to build a dictionary used as template parameters. Takes a list of arguments (keys followed by a value) to build a dictionary used as template parameters.
Example: \b
Example
------- -------
anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1 anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1

View file

@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, Callable
import click import click
from anta.cli.utils import ExitCode, inventory_options from anta.cli.utils import ExitCode, core_options
if TYPE_CHECKING: if TYPE_CHECKING:
from anta.inventory import AntaInventory from anta.inventory import AntaInventory
@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
def debug_options(f: Callable[..., Any]) -> Callable[..., Any]: def debug_options(f: Callable[..., Any]) -> Callable[..., Any]:
"""Click common options required to execute a command on a specific device.""" """Click common options required to execute a command on a specific device."""
@inventory_options @core_options
@click.option( @click.option(
"--ofmt", "--ofmt",
type=click.Choice(["json", "text"]), type=click.Choice(["json", "text"]),
@ -44,12 +44,10 @@ def debug_options(f: Callable[..., Any]) -> Callable[..., Any]:
ctx: click.Context, ctx: click.Context,
*args: tuple[Any], *args: tuple[Any],
inventory: AntaInventory, inventory: AntaInventory,
tags: set[str] | None,
device: str, device: str,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
# TODO: @gmuloc - tags come from context https://github.com/arista-netdevops-community/anta/issues/584 # TODO: @gmuloc - tags come from context https://github.com/aristanetworks/anta/issues/584
# pylint: disable=unused-argument
# ruff: noqa: ARG001 # ruff: noqa: ARG001
if (d := inventory.get(device)) is None: if (d := inventory.get(device)) is None:
logger.error("Device '%s' does not exist in Inventory", device) logger.error("Device '%s' does not exist in Inventory", device)

View file

@ -9,7 +9,7 @@ from anta.cli.exec import commands
@click.group("exec") @click.group("exec")
def _exec() -> None: # pylint: disable=redefined-builtin def _exec() -> None:
"""Commands to execute various scripts on EOS devices.""" """Commands to execute various scripts on EOS devices."""

View file

@ -84,7 +84,10 @@ def snapshot(inventory: AntaInventory, tags: set[str] | None, commands_list: Pat
) )
@click.option( @click.option(
"--configure", "--configure",
help="Ensure devices have 'aaa authorization exec default local' configured (required for SCP on EOS). THIS WILL CHANGE THE CONFIGURATION OF YOUR NETWORK.", help=(
"[DEPRECATED] Ensure devices have 'aaa authorization exec default local' configured (required for SCP on EOS). "
"THIS WILL CHANGE THE CONFIGURATION OF YOUR NETWORK."
),
default=False, default=False,
is_flag=True, is_flag=True,
show_default=True, show_default=True,

View file

@ -10,16 +10,15 @@ import asyncio
import itertools import itertools
import json import json
import logging import logging
import re
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal
from click.exceptions import UsageError from click.exceptions import UsageError
from httpx import ConnectError, HTTPError from httpx import ConnectError, HTTPError
from anta.custom_types import REGEXP_PATH_MARKERS
from anta.device import AntaDevice, AsyncEOSDevice from anta.device import AntaDevice, AsyncEOSDevice
from anta.models import AntaCommand from anta.models import AntaCommand
from anta.tools import safe_command
from asynceapi import EapiCommandError from asynceapi import EapiCommandError
if TYPE_CHECKING: if TYPE_CHECKING:
@ -52,7 +51,7 @@ async def clear_counters(anta_inventory: AntaInventory, tags: set[str] | None =
async def collect_commands( async def collect_commands(
inv: AntaInventory, inv: AntaInventory,
commands: dict[str, str], commands: dict[str, list[str]],
root_dir: Path, root_dir: Path,
tags: set[str] | None = None, tags: set[str] | None = None,
) -> None: ) -> None:
@ -61,17 +60,16 @@ async def collect_commands(
async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "text"]) -> None: async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "text"]) -> None:
outdir = Path() / root_dir / dev.name / outformat outdir = Path() / root_dir / dev.name / outformat
outdir.mkdir(parents=True, exist_ok=True) outdir.mkdir(parents=True, exist_ok=True)
safe_command = re.sub(rf"{REGEXP_PATH_MARKERS}", "_", command)
c = AntaCommand(command=command, ofmt=outformat) c = AntaCommand(command=command, ofmt=outformat)
await dev.collect(c) await dev.collect(c)
if not c.collected: if not c.collected:
logger.error("Could not collect commands on device %s: %s", dev.name, c.errors) logger.error("Could not collect commands on device %s: %s", dev.name, c.errors)
return return
if c.ofmt == "json": if c.ofmt == "json":
outfile = outdir / f"{safe_command}.json" outfile = outdir / f"{safe_command(command)}.json"
content = json.dumps(c.json_output, indent=2) content = json.dumps(c.json_output, indent=2)
elif c.ofmt == "text": elif c.ofmt == "text":
outfile = outdir / f"{safe_command}.log" outfile = outdir / f"{safe_command(command)}.log"
content = c.text_output content = c.text_output
else: else:
logger.error("Command outformat is not in ['json', 'text'] for command '%s'", command) logger.error("Command outformat is not in ['json', 'text'] for command '%s'", command)
@ -83,6 +81,9 @@ async def collect_commands(
logger.info("Connecting to devices...") logger.info("Connecting to devices...")
await inv.connect_inventory() await inv.connect_inventory()
devices = inv.get_inventory(established_only=True, tags=tags).devices devices = inv.get_inventory(established_only=True, tags=tags).devices
if not devices:
logger.info("No online device found. Exiting")
return
logger.info("Collecting commands from remote devices") logger.info("Collecting commands from remote devices")
coros = [] coros = []
if "json_format" in commands: if "json_format" in commands:
@ -127,6 +128,13 @@ async def collect_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bo
logger.error("Unable to collect tech-support on %s: configuration 'aaa authorization exec default local' is not present", device.name) logger.error("Unable to collect tech-support on %s: configuration 'aaa authorization exec default local' is not present", device.name)
return 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)
commands = [] commands = []
# TODO: @mtache - add `config` field to `AntaCommand` object to handle this use case. # 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 # Otherwise mypy complains about enable as it is only implemented for AsyncEOSDevice
@ -134,8 +142,8 @@ async def collect_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bo
if not isinstance(device, AsyncEOSDevice): if not isinstance(device, AsyncEOSDevice):
msg = "anta exec collect-tech-support is only supported with AsyncEOSDevice for now." msg = "anta exec collect-tech-support is only supported with AsyncEOSDevice for now."
raise UsageError(msg) raise UsageError(msg)
if device.enable and device._enable_password is not None: # pylint: disable=protected-access if device.enable and device._enable_password is not None:
commands.append({"cmd": "enable", "input": device._enable_password}) # pylint: disable=protected-access commands.append({"cmd": "enable", "input": device._enable_password})
elif device.enable: elif device.enable:
commands.append({"cmd": "enable"}) commands.append({"cmd": "enable"})
commands.extend( commands.extend(
@ -146,7 +154,7 @@ async def collect_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bo
) )
logger.warning("Configuring 'aaa authorization exec default local' on device %s", device.name) logger.warning("Configuring 'aaa authorization exec default local' on device %s", device.name)
command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text") command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text")
await device._session.cli(commands=commands) # pylint: disable=protected-access await device._session.cli(commands=commands)
logger.info("Configured 'aaa authorization exec default local' on device %s", device.name) 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) logger.debug("'aaa authorization exec default local' is already configured on device %s", device.name)

View file

@ -17,3 +17,4 @@ get.add_command(commands.from_cvp)
get.add_command(commands.from_ansible) get.add_command(commands.from_ansible)
get.add_command(commands.inventory) get.add_command(commands.inventory)
get.add_command(commands.tags) get.add_command(commands.tags)
get.add_command(commands.tests)

View file

@ -13,6 +13,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import click import click
import requests
from cvprac.cvp_client import CvpClient from cvprac.cvp_client import CvpClient
from cvprac.cvp_client_errors import CvpApiError from cvprac.cvp_client_errors import CvpApiError
from rich.pretty import pretty_repr from rich.pretty import pretty_repr
@ -21,7 +22,7 @@ from anta.cli.console import console
from anta.cli.get.utils import inventory_output_options from anta.cli.get.utils import inventory_output_options
from anta.cli.utils import ExitCode, inventory_options from anta.cli.utils import ExitCode, inventory_options
from .utils import create_inventory_from_ansible, create_inventory_from_cvp, get_cv_token from .utils import create_inventory_from_ansible, create_inventory_from_cvp, explore_package, get_cv_token
if TYPE_CHECKING: if TYPE_CHECKING:
from anta.inventory import AntaInventory from anta.inventory import AntaInventory
@ -36,14 +37,26 @@ logger = logging.getLogger(__name__)
@click.option("--username", "-u", help="CloudVision username", type=str, required=True) @click.option("--username", "-u", help="CloudVision username", type=str, required=True)
@click.option("--password", "-p", help="CloudVision password", type=str, required=True) @click.option("--password", "-p", help="CloudVision password", type=str, required=True)
@click.option("--container", "-c", help="CloudVision container where devices are configured", type=str) @click.option("--container", "-c", help="CloudVision container where devices are configured", type=str)
def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str | None) -> None: @click.option(
# pylint: disable=too-many-arguments "--ignore-cert",
"""Build ANTA inventory from Cloudvision. help="Ignore verifying the SSL certificate when connecting to CloudVision",
show_envvar=True,
is_flag=True,
default=False,
)
def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str | None, *, ignore_cert: bool) -> None:
"""Build ANTA inventory from CloudVision.
TODO - handle get_inventory and get_devices_in_container failure NOTE: Only username/password authentication is supported for on-premises CloudVision instances.
Token authentication for both on-premises and CloudVision as a Service (CVaaS) is not supported.
""" """
# TODO: - Handle get_cv_token, get_inventory and get_devices_in_container failures.
logger.info("Getting authentication token for user '%s' from CloudVision instance '%s'", username, host) logger.info("Getting authentication token for user '%s' from CloudVision instance '%s'", username, host)
token = get_cv_token(cvp_ip=host, cvp_username=username, cvp_password=password) try:
token = get_cv_token(cvp_ip=host, cvp_username=username, cvp_password=password, verify_cert=not ignore_cert)
except requests.exceptions.SSLError as error:
logger.error("Authentication to CloudVison failed: %s.", error)
ctx.exit(ExitCode.USAGE_ERROR)
clnt = CvpClient() clnt = CvpClient()
try: try:
@ -62,7 +75,11 @@ def from_cvp(ctx: click.Context, output: Path, host: str, username: str, passwor
# Get devices under a container # Get devices under a container
logger.info("Getting inventory for container %s from CloudVision instance '%s'", container, host) logger.info("Getting inventory for container %s from CloudVision instance '%s'", container, host)
cvp_inventory = clnt.api.get_devices_in_container(container) cvp_inventory = clnt.api.get_devices_in_container(container)
try:
create_inventory_from_cvp(cvp_inventory, output) create_inventory_from_cvp(cvp_inventory, output)
except OSError as e:
logger.error(str(e))
ctx.exit(ExitCode.USAGE_ERROR)
@click.command @click.command
@ -88,7 +105,7 @@ def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_i
output=output, output=output,
ansible_group=ansible_group, ansible_group=ansible_group,
) )
except ValueError as e: except (ValueError, OSError) as e:
logger.error(str(e)) logger.error(str(e))
ctx.exit(ExitCode.USAGE_ERROR) ctx.exit(ExitCode.USAGE_ERROR)
@ -113,10 +130,31 @@ def inventory(inventory: AntaInventory, tags: set[str] | None, *, connected: boo
@click.command @click.command
@inventory_options @inventory_options
def tags(inventory: AntaInventory, **kwargs: Any) -> None: def tags(inventory: AntaInventory, **kwargs: Any) -> None:
# pylint: disable=unused-argument
"""Get list of configured tags in user inventory.""" """Get list of configured tags in user inventory."""
tags: set[str] = set() tags: set[str] = set()
for device in inventory.values(): for device in inventory.values():
tags.update(device.tags) tags.update(device.tags)
console.print("Tags found:") console.print("Tags found:")
console.print_json(json.dumps(sorted(tags), indent=2)) 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)

View file

@ -6,8 +6,14 @@
from __future__ import annotations from __future__ import annotations
import functools import functools
import importlib
import inspect
import json import json
import logging import logging
import pkgutil
import re
import sys
import textwrap
from pathlib import Path from pathlib import Path
from sys import stdin from sys import stdin
from typing import Any, Callable from typing import Any, Callable
@ -17,9 +23,11 @@ import requests
import urllib3 import urllib3
import yaml import yaml
from anta.cli.console import console
from anta.cli.utils import ExitCode from anta.cli.utils import ExitCode
from anta.inventory import AntaInventory from anta.inventory import AntaInventory
from anta.inventory.models import AntaInventoryHost, AntaInventoryInput from anta.inventory.models import AntaInventoryHost, AntaInventoryInput
from anta.models import AntaTest
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
@ -77,25 +85,65 @@ def inventory_output_options(f: Callable[..., Any]) -> Callable[..., Any]:
return wrapper return wrapper
def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str) -> str: def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str, *, verify_cert: bool) -> str:
"""Generate AUTH token from CVP using password.""" """Generate the authentication token from CloudVision using username and password.
# TODO: need to handle requests error
TODO: need to handle requests error
Parameters
----------
cvp_ip
IP address of CloudVision.
cvp_username
Username to connect to CloudVision.
cvp_password
Password to connect to CloudVision.
verify_cert
Enable or disable certificate verification when connecting to CloudVision.
Returns
-------
str
The token to use in further API calls to CloudVision.
Raises
------
requests.ssl.SSLError
If the certificate verification fails.
"""
# use CVP REST API to generate a token # use CVP REST API to generate a token
url = f"https://{cvp_ip}/cvpservice/login/authenticate.do" url = f"https://{cvp_ip}/cvpservice/login/authenticate.do"
payload = json.dumps({"userId": cvp_username, "password": cvp_password}) payload = json.dumps({"userId": cvp_username, "password": cvp_password})
headers = {"Content-Type": "application/json", "Accept": "application/json"} headers = {"Content-Type": "application/json", "Accept": "application/json"}
response = requests.request("POST", url, headers=headers, data=payload, verify=False, timeout=10) response = requests.request("POST", url, headers=headers, data=payload, verify=verify_cert, timeout=10)
return response.json()["sessionId"] return response.json()["sessionId"]
def write_inventory_to_file(hosts: list[AntaInventoryHost], output: Path) -> None: def write_inventory_to_file(hosts: list[AntaInventoryHost], output: Path) -> None:
"""Write a file inventory from pydantic models.""" """Write a file inventory from pydantic models.
Parameters
----------
hosts:
the list of AntaInventoryHost to write to an inventory file
output:
the Path where the inventory should be written.
Raises
------
OSError
When anything goes wrong while writing the file.
"""
i = AntaInventoryInput(hosts=hosts) i = AntaInventoryInput(hosts=hosts)
try:
with output.open(mode="w", encoding="UTF-8") as out_fd: with output.open(mode="w", encoding="UTF-8") as out_fd:
out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: i.model_dump(exclude_unset=True)})) out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: yaml.safe_load(i.yaml())}))
logger.info("ANTA inventory file has been created: '%s'", output) logger.info("ANTA inventory file has been created: '%s'", output)
except OSError as exc:
msg = f"Could not write inventory to path '{output}'."
raise OSError(msg) from exc
def create_inventory_from_cvp(inv: list[dict[str, Any]], output: Path) -> None: def create_inventory_from_cvp(inv: list[dict[str, Any]], output: Path) -> None:
@ -144,11 +192,14 @@ def deep_yaml_parsing(data: dict[str, Any], hosts: list[AntaInventoryHost] | Non
def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group: str = "all") -> None: def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group: str = "all") -> None:
"""Create an ANTA inventory from an Ansible inventory YAML file. """Create an ANTA inventory from an Ansible inventory YAML file.
Args: Parameters
---- ----------
inventory: Ansible Inventory file to read inventory
output: ANTA inventory file to generate. Ansible Inventory file to read.
ansible_group: Ansible group from where to extract data. output
ANTA inventory file to generate.
ansible_group
Ansible group from where to extract data.
""" """
try: try:
@ -178,3 +229,148 @@ def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group:
raise ValueError(msg) raise ValueError(msg)
ansible_hosts = deep_yaml_parsing(ansible_inventory) ansible_hosts = deep_yaml_parsing(ansible_inventory)
write_inventory_to_file(ansible_hosts, output) write_inventory_to_file(ansible_hosts, output)
def explore_package(module_name: str, test_name: str | None = None, *, short: bool = False, count: bool = False) -> int:
"""Parse ANTA test submodules recursively and print AntaTest examples.
Parameters
----------
module_name
Name of the module to explore (e.g., 'anta.tests.routing.bgp').
test_name
If provided, only show tests starting with this name.
short
If True, only print test names without their inputs.
count
If True, only count the tests.
Returns
-------
int:
The number of tests found.
"""
try:
module_spec = importlib.util.find_spec(module_name)
except ModuleNotFoundError:
# Relying on module_spec check below.
module_spec = None
except ImportError as e:
msg = "`anta get tests --module <module>` does not support relative imports"
raise ValueError(msg) from e
# Giving a second chance adding CWD to PYTHONPATH
if module_spec is None:
try:
logger.info("Could not find module `%s`, injecting CWD in PYTHONPATH and retrying...", module_name)
sys.path = [str(Path.cwd()), *sys.path]
module_spec = importlib.util.find_spec(module_name)
except ImportError:
module_spec = None
if module_spec is None or module_spec.origin is None:
msg = f"Module `{module_name}` was not found!"
raise ValueError(msg)
tests_found = 0
if module_spec.submodule_search_locations:
for _, sub_module_name, ispkg in pkgutil.walk_packages(module_spec.submodule_search_locations):
qname = f"{module_name}.{sub_module_name}"
if ispkg:
tests_found += explore_package(qname, test_name=test_name, short=short, count=count)
continue
tests_found += find_tests_examples(qname, test_name, short=short, count=count)
else:
tests_found += find_tests_examples(module_spec.name, test_name, short=short, count=count)
return tests_found
def find_tests_examples(qname: str, test_name: str | None, *, short: bool = False, count: bool = False) -> int:
"""Print tests from `qname`, filtered by `test_name` if provided.
Parameters
----------
qname
Name of the module to explore (e.g., 'anta.tests.routing.bgp').
test_name
If provided, only show tests starting with this name.
short
If True, only print test names without their inputs.
count
If True, only count the tests.
Returns
-------
int:
The number of tests found.
"""
try:
qname_module = importlib.import_module(qname)
except (AssertionError, ImportError) as e:
msg = f"Error when importing `{qname}` using importlib!"
raise ValueError(msg) from e
module_printed = False
tests_found = 0
for _name, obj in inspect.getmembers(qname_module):
# Only retrieves the subclasses of AntaTest
if not inspect.isclass(obj) or not issubclass(obj, AntaTest) or obj == AntaTest:
continue
if test_name and not obj.name.startswith(test_name):
continue
if not module_printed:
if not count:
console.print(f"{qname}:")
module_printed = True
tests_found += 1
if count:
continue
print_test(obj, short=short)
return tests_found
def print_test(test: type[AntaTest], *, short: bool = False) -> None:
"""Print a single test.
Parameters
----------
test
the representation of the AntaTest as returned by inspect.getmembers
short
If True, only print test names without their inputs.
"""
if not test.__doc__ or (example := extract_examples(test.__doc__)) is None:
msg = f"Test {test.name} in module {test.__module__} is missing an Example"
raise LookupError(msg)
# Picking up only the inputs in the examples
# Need to handle the fact that we nest the routing modules in Examples.
# This is a bit fragile.
inputs = example.split("\n")
try:
test_name_line = next((i for i, input_entry in enumerate(inputs) if test.name in input_entry))
except StopIteration as e:
msg = f"Could not find the name of the test '{test.name}' in the Example section in the docstring."
raise ValueError(msg) from e
# TODO: handle not found
console.print(f" {inputs[test_name_line].strip()}")
# Injecting the description
console.print(f" # {test.description}", soft_wrap=True)
if not short and len(inputs) > test_name_line + 2: # There are params
console.print(textwrap.indent(textwrap.dedent("\n".join(inputs[test_name_line + 1 : -1])), " " * 6))
def extract_examples(docstring: str) -> str | None:
"""Extract the content of the Example section in a Numpy docstring.
Returns
-------
str | None
The content of the section if present, None if the section is absent or empty.
"""
pattern = r"Examples\s*--------\s*(.*)(?:\n\s*\n|\Z)"
match = re.search(pattern, docstring, flags=re.DOTALL)
return match[1].strip() if match and match[1].strip() != "" else None

View file

@ -5,19 +5,14 @@
from __future__ import annotations from __future__ import annotations
import asyncio from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, get_args
import click import click
from anta.cli.nrfu import commands from anta.cli.nrfu import commands
from anta.cli.utils import AliasedGroup, catalog_options, inventory_options from anta.cli.utils import AliasedGroup, catalog_options, inventory_options
from anta.custom_types import TestStatus
from anta.models import AntaTest
from anta.result_manager import ResultManager from anta.result_manager import ResultManager
from anta.runner import main from anta.result_manager.models import AntaTestStatus
from .utils import anta_progress_bar, print_settings
if TYPE_CHECKING: if TYPE_CHECKING:
from anta.catalog import AntaCatalog from anta.catalog import AntaCatalog
@ -37,6 +32,7 @@ class IgnoreRequiredWithHelp(AliasedGroup):
"""Ignore MissingParameter exception when parsing arguments if `--help` is present for a subcommand.""" """Ignore MissingParameter exception when parsing arguments if `--help` is present for a subcommand."""
# Adding a flag for potential callbacks # Adding a flag for potential callbacks
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["args"] = args
if "--help" in args: if "--help" in args:
ctx.obj["_anta_help"] = True ctx.obj["_anta_help"] = True
@ -53,7 +49,7 @@ class IgnoreRequiredWithHelp(AliasedGroup):
return super().parse_args(ctx, args) return super().parse_args(ctx, args)
HIDE_STATUS: list[str] = list(get_args(TestStatus)) HIDE_STATUS: list[str] = list(AntaTestStatus)
HIDE_STATUS.remove("unset") HIDE_STATUS.remove("unset")
@ -96,7 +92,7 @@ HIDE_STATUS.remove("unset")
default=None, default=None,
type=click.Choice(HIDE_STATUS, case_sensitive=False), type=click.Choice(HIDE_STATUS, case_sensitive=False),
multiple=True, multiple=True,
help="Group result by test or device.", help="Hide results by type: success / failure / error / skipped'.",
required=False, required=False,
) )
@click.option( @click.option(
@ -107,7 +103,6 @@ HIDE_STATUS.remove("unset")
is_flag=True, is_flag=True,
default=False, default=False,
) )
# pylint: disable=too-many-arguments
def nrfu( def nrfu(
ctx: click.Context, ctx: click.Context,
inventory: AntaInventory, inventory: AntaInventory,
@ -120,38 +115,35 @@ def nrfu(
ignore_status: bool, ignore_status: bool,
ignore_error: bool, ignore_error: bool,
dry_run: bool, dry_run: bool,
catalog_format: str = "yaml",
) -> None: ) -> None:
"""Run ANTA tests on selected inventory devices.""" """Run ANTA tests on selected inventory devices."""
# If help is invoke somewhere, skip the command # If help is invoke somewhere, skip the command
if ctx.obj.get("_anta_help"): if ctx.obj.get("_anta_help"):
return return
# We use ctx.obj to pass stuff to the next Click functions # We use ctx.obj to pass stuff to the next Click functions
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["result_manager"] = ResultManager() ctx.obj["result_manager"] = ResultManager()
ctx.obj["ignore_status"] = ignore_status ctx.obj["ignore_status"] = ignore_status
ctx.obj["ignore_error"] = ignore_error ctx.obj["ignore_error"] = ignore_error
ctx.obj["hide"] = set(hide) if hide else None ctx.obj["hide"] = set(hide) if hide else None
print_settings(inventory, catalog) ctx.obj["catalog"] = catalog
with anta_progress_bar() as AntaTest.progress: ctx.obj["catalog_format"] = catalog_format
asyncio.run( ctx.obj["inventory"] = inventory
main( ctx.obj["tags"] = tags
ctx.obj["result_manager"], ctx.obj["device"] = device
inventory, ctx.obj["test"] = test
catalog, ctx.obj["dry_run"] = dry_run
tags=tags,
devices=set(device) if device else None,
tests=set(test) if test else None,
dry_run=dry_run,
)
)
if dry_run:
return
# Invoke `anta nrfu table` if no command is passed # Invoke `anta nrfu table` if no command is passed
if ctx.invoked_subcommand is None: if not ctx.invoked_subcommand:
ctx.invoke(commands.table) ctx.invoke(commands.table)
nrfu.add_command(commands.table) nrfu.add_command(commands.table)
nrfu.add_command(commands.csv)
nrfu.add_command(commands.json) nrfu.add_command(commands.json)
nrfu.add_command(commands.text) nrfu.add_command(commands.text)
nrfu.add_command(commands.tpl_report) nrfu.add_command(commands.tpl_report)
nrfu.add_command(commands.md_report)

View file

@ -13,7 +13,7 @@ import click
from anta.cli.utils import exit_with_code from anta.cli.utils import exit_with_code
from .utils import print_jinja, print_json, print_table, print_text from .utils import print_jinja, print_json, print_table, print_text, run_tests, save_markdown_report, save_to_csv
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -27,11 +27,9 @@ logger = logging.getLogger(__name__)
help="Group result by test or device.", help="Group result by test or device.",
required=False, required=False,
) )
def table( def table(ctx: click.Context, group_by: Literal["device", "test"] | None) -> None:
ctx: click.Context, """ANTA command to check network state with table results."""
group_by: Literal["device", "test"] | None, run_tests(ctx)
) -> None:
"""ANTA command to check network states with table result."""
print_table(ctx, group_by=group_by) print_table(ctx, group_by=group_by)
exit_with_code(ctx) exit_with_code(ctx)
@ -44,10 +42,11 @@ def table(
type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path), type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path),
show_envvar=True, show_envvar=True,
required=False, required=False,
help="Path to save report as a file", help="Path to save report as a JSON file",
) )
def json(ctx: click.Context, output: pathlib.Path | None) -> None: def json(ctx: click.Context, output: pathlib.Path | None) -> None:
"""ANTA command to check network state with JSON result.""" """ANTA command to check network state with JSON results."""
run_tests(ctx)
print_json(ctx, output=output) print_json(ctx, output=output)
exit_with_code(ctx) exit_with_code(ctx)
@ -55,11 +54,34 @@ def json(ctx: click.Context, output: pathlib.Path | None) -> None:
@click.command() @click.command()
@click.pass_context @click.pass_context
def text(ctx: click.Context) -> None: def text(ctx: click.Context) -> None:
"""ANTA command to check network states with text result.""" """ANTA command to check network state with text results."""
run_tests(ctx)
print_text(ctx) print_text(ctx)
exit_with_code(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)
exit_with_code(ctx)
@click.command() @click.command()
@click.pass_context @click.pass_context
@click.option( @click.option(
@ -80,5 +102,22 @@ def text(ctx: click.Context) -> None:
) )
def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path | None) -> None: def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path | None) -> None:
"""ANTA command to check network state with templated report.""" """ANTA command to check network state with templated report."""
run_tests(ctx)
print_jinja(results=ctx.obj["result_manager"], template=template, output=output) print_jinja(results=ctx.obj["result_manager"], template=template, output=output)
exit_with_code(ctx) exit_with_code(ctx)
@click.command()
@click.pass_context
@click.option(
"--md-output",
type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path),
show_envvar=True,
required=True,
help="Path to save the report as a Markdown file",
)
def md_report(ctx: click.Context, md_output: pathlib.Path) -> None:
"""ANTA command to check network state with Markdown report."""
run_tests(ctx)
save_markdown_report(ctx, md_output=md_output)
exit_with_code(ctx)

View file

@ -5,6 +5,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal
@ -14,7 +15,12 @@ from rich.panel import Panel
from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn
from anta.cli.console import console 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 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: if TYPE_CHECKING:
import pathlib import pathlib
@ -28,6 +34,37 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def run_tests(ctx: click.Context) -> None:
"""Run the tests."""
# Digging up the parameters from the parent context
if ctx.parent is None:
ctx.exit()
nrfu_ctx_params = ctx.parent.params
tags = nrfu_ctx_params["tags"]
device = nrfu_ctx_params["device"] or None
test = nrfu_ctx_params["test"] or None
dry_run = nrfu_ctx_params["dry_run"]
catalog = ctx.obj["catalog"]
inventory = ctx.obj["inventory"]
print_settings(inventory, catalog)
with anta_progress_bar() as AntaTest.progress:
asyncio.run(
main(
ctx.obj["result_manager"],
inventory,
catalog,
tags=tags,
devices=set(device) if device else None,
tests=set(test) if test else None,
dry_run=dry_run,
)
)
if dry_run:
ctx.exit()
def _get_result_manager(ctx: click.Context) -> ResultManager: def _get_result_manager(ctx: click.Context) -> ResultManager:
"""Get a ResultManager instance based on Click context.""" """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"] return ctx.obj["result_manager"].filter(ctx.obj.get("hide")) if ctx.obj.get("hide") is not None else ctx.obj["result_manager"]
@ -58,22 +95,33 @@ def print_table(ctx: click.Context, group_by: Literal["device", "test"] | None =
def print_json(ctx: click.Context, output: pathlib.Path | None = None) -> None: def print_json(ctx: click.Context, output: pathlib.Path | None = None) -> None:
"""Print result in a json format.""" """Print results as JSON. If output is provided, save to file instead."""
results = _get_result_manager(ctx) results = _get_result_manager(ctx)
if output is None:
console.print() console.print()
console.print(Panel("JSON results", style="cyan")) console.print(Panel("JSON results", style="cyan"))
rich.print_json(results.json) rich.print_json(results.json)
if output is not None: else:
with output.open(mode="w", encoding="utf-8") as fout: try:
fout.write(results.json) with output.open(mode="w", encoding="utf-8") as file:
file.write(results.json)
console.print(f"JSON results saved to {output}", style="cyan")
except OSError:
console.print(f"Failed to save JSON results to {output}", style="cyan")
ctx.exit(ExitCode.USAGE_ERROR)
def print_text(ctx: click.Context) -> None: def print_text(ctx: click.Context) -> None:
"""Print results as simple text.""" """Print results as simple text."""
console.print() console.print()
for test in _get_result_manager(ctx).results: for test in _get_result_manager(ctx).results:
message = f" ({test.messages[0]!s})" if len(test.messages) > 0 else "" if len(test.messages) <= 1:
console.print(f"{test.name} :: {test.test} :: [{test.result}]{test.result.upper()}[/{test.result}]{message}", highlight=False) message = test.messages[0] if len(test.messages) == 1 else ""
console.print(f"{test.name} :: {test.test} :: [{test.result}]{test.result.upper()}[/{test.result}]({message})", highlight=False)
else: # len(test.messages) > 1
console.print(f"{test.name} :: {test.test} :: [{test.result}]{test.result.upper()}[/{test.result}]", highlight=False)
console.print("\n".join(f" {message}" for message in test.messages), highlight=False)
def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib.Path | None = None) -> None: def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib.Path | None = None) -> None:
@ -88,6 +136,34 @@ def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib.
file.write(report) file.write(report)
def save_to_csv(ctx: click.Context, csv_file: pathlib.Path) -> None:
"""Save results to a CSV file."""
try:
ReportCsv.generate(results=_get_result_manager(ctx), csv_filename=csv_file)
console.print(f"CSV report saved to {csv_file}", style="cyan")
except OSError:
console.print(f"Failed to save CSV report to {csv_file}", style="cyan")
ctx.exit(ExitCode.USAGE_ERROR)
def save_markdown_report(ctx: click.Context, md_output: pathlib.Path) -> None:
"""Save the markdown report to a file.
Parameters
----------
ctx
Click context containing the result manager.
md_output
Path to save the markdown report.
"""
try:
MDReportGenerator.generate(results=_get_result_manager(ctx), md_filename=md_output)
console.print(f"Markdown report saved to {md_output}", style="cyan")
except OSError:
console.print(f"Failed to save Markdown report to {md_output}", style="cyan")
ctx.exit(ExitCode.USAGE_ERROR)
# Adding our own ANTA spinner - overriding rich SPINNERS for our own # Adding our own ANTA spinner - overriding rich SPINNERS for our own
# so ignore warning for redefinition # so ignore warning for redefinition
rich.spinner.SPINNERS = { # type: ignore[attr-defined] rich.spinner.SPINNERS = { # type: ignore[attr-defined]

View file

@ -40,7 +40,6 @@ class ExitCode(enum.IntEnum):
def parse_tags(ctx: click.Context, param: Option, value: str | None) -> set[str] | None: def parse_tags(ctx: click.Context, param: Option, value: str | None) -> set[str] | None:
# pylint: disable=unused-argument
# ruff: noqa: ARG001 # ruff: noqa: ARG001
"""Click option callback to parse an ANTA inventory tags.""" """Click option callback to parse an ANTA inventory tags."""
if value is not None: if value is not None:
@ -60,9 +59,10 @@ def exit_with_code(ctx: click.Context) -> None:
* 1 if status is `failure` * 1 if status is `failure`
* 2 if status is `error`. * 2 if status is `error`.
Args: Parameters
---- ----------
ctx: Click Context ctx
Click Context.
""" """
if ctx.obj.get("ignore_status"): if ctx.obj.get("ignore_status"):
@ -112,7 +112,7 @@ class AliasedGroup(click.Group):
return cmd.name, cmd, args return cmd.name, cmd, args
def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]: def core_options(f: Callable[..., Any]) -> Callable[..., Any]:
"""Click common options when requiring an inventory to interact with devices.""" """Click common options when requiring an inventory to interact with devices."""
@click.option( @click.option(
@ -190,22 +190,12 @@ def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]:
required=True, required=True,
type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path), type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path),
) )
@click.option(
"--tags",
help="List of tags using comma as separator: tag1,tag2,tag3.",
show_envvar=True,
envvar="ANTA_TAGS",
type=str,
required=False,
callback=parse_tags,
)
@click.pass_context @click.pass_context
@functools.wraps(f) @functools.wraps(f)
def wrapper( def wrapper(
ctx: click.Context, ctx: click.Context,
*args: tuple[Any], *args: tuple[Any],
inventory: Path, inventory: Path,
tags: set[str] | None,
username: str, username: str,
password: str | None, password: str | None,
enable_password: str | None, enable_password: str | None,
@ -216,10 +206,9 @@ def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]:
disable_cache: bool, disable_cache: bool,
**kwargs: dict[str, Any], **kwargs: dict[str, Any],
) -> Any: ) -> Any:
# pylint: disable=too-many-arguments
# If help is invoke somewhere, do not parse inventory # If help is invoke somewhere, do not parse inventory
if ctx.obj.get("_anta_help"): if ctx.obj.get("_anta_help"):
return f(*args, inventory=None, tags=tags, **kwargs) return f(*args, inventory=None, **kwargs)
if prompt: if prompt:
# User asked for a password prompt # User asked for a password prompt
if password is None: if password is None:
@ -255,7 +244,36 @@ def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]:
) )
except (TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError): except (TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError):
ctx.exit(ExitCode.USAGE_ERROR) ctx.exit(ExitCode.USAGE_ERROR)
return f(*args, inventory=i, tags=tags, **kwargs) 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.",
show_envvar=True,
envvar="ANTA_TAGS",
type=str,
required=False,
callback=parse_tags,
)
@click.pass_context
@functools.wraps(f)
def wrapper(
ctx: click.Context,
*args: tuple[Any],
tags: set[str] | None,
**kwargs: dict[str, Any],
) -> Any:
# 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 wrapper return wrapper
@ -268,7 +286,7 @@ def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]:
"-c", "-c",
envvar="ANTA_CATALOG", envvar="ANTA_CATALOG",
show_envvar=True, show_envvar=True,
help="Path to the test catalog YAML file", help="Path to the test catalog file",
type=click.Path( type=click.Path(
file_okay=True, file_okay=True,
dir_okay=False, dir_okay=False,
@ -278,19 +296,29 @@ def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]:
), ),
required=True, required=True,
) )
@click.option(
"--catalog-format",
envvar="ANTA_CATALOG_FORMAT",
show_envvar=True,
help="Format of the catalog file, either 'yaml' or 'json'",
default="yaml",
type=click.Choice(["yaml", "json"], case_sensitive=False),
)
@click.pass_context @click.pass_context
@functools.wraps(f) @functools.wraps(f)
def wrapper( def wrapper(
ctx: click.Context, ctx: click.Context,
*args: tuple[Any], *args: tuple[Any],
catalog: Path, catalog: Path,
catalog_format: str,
**kwargs: dict[str, Any], **kwargs: dict[str, Any],
) -> Any: ) -> Any:
# If help is invoke somewhere, do not parse catalog # If help is invoke somewhere, do not parse catalog
if ctx.obj.get("_anta_help"): if ctx.obj.get("_anta_help"):
return f(*args, catalog=None, **kwargs) return f(*args, catalog=None, **kwargs)
try: try:
c = AntaCatalog.parse(catalog) file_format = catalog_format.lower()
c = AntaCatalog.parse(catalog, file_format=file_format) # type: ignore[arg-type]
except (TypeError, ValueError, YAMLError, OSError): except (TypeError, ValueError, YAMLError, OSError):
ctx.exit(ExitCode.USAGE_ERROR) ctx.exit(ExitCode.USAGE_ERROR)
return f(*args, catalog=c, **kwargs) return f(*args, catalog=c, **kwargs)

28
anta/constants.py Normal file
View file

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

@ -21,6 +21,8 @@ REGEXP_TYPE_EOS_INTERFACE = r"^(Dps|Ethernet|Fabric|Loopback|Management|Port-Cha
"""Match EOS interface types like Ethernet1/1, Vlan1, Loopback1, etc.""" """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])$" 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.""" """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])$" 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`.""" """Match hostname like `my-hostname`, `my-hostname-1`, `my-hostname-1-2`."""
@ -112,9 +114,6 @@ def validate_regex(value: str) -> str:
return value return value
# ANTA framework
TestStatus = Literal["unset", "success", "failure", "error", "skipped"]
# AntaTest.Input types # AntaTest.Input types
AAAAuthMethod = Annotated[str, AfterValidator(aaa_group_prefix)] AAAAuthMethod = Annotated[str, AfterValidator(aaa_group_prefix)]
Vlan = Annotated[int, Field(ge=0, le=4094)] Vlan = Annotated[int, Field(ge=0, le=4094)]
@ -138,6 +137,12 @@ VxlanSrcIntf = Annotated[
BeforeValidator(interface_autocomplete), BeforeValidator(interface_autocomplete),
BeforeValidator(interface_case_sensitivity), 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"] Afi = Literal["ipv4", "ipv6", "vpn-ipv4", "vpn-ipv6", "evpn", "rt-membership", "path-selection", "link-state"]
Safi = Literal["unicast", "multicast", "labeled-unicast", "sr-te"] Safi = Literal["unicast", "multicast", "labeled-unicast", "sr-te"]
EncryptionAlgorithm = Literal["RSA", "ECDSA"] EncryptionAlgorithm = Literal["RSA", "ECDSA"]
@ -167,3 +172,69 @@ Revision = Annotated[int, Field(ge=1, le=99)]
Hostname = Annotated[str, Field(pattern=REGEXP_TYPE_HOSTNAME)] Hostname = Annotated[str, Field(pattern=REGEXP_TYPE_HOSTNAME)]
Port = Annotated[int, Field(ge=1, le=65535)] Port = Annotated[int, Field(ge=1, le=65535)]
RegexString = Annotated[str, AfterValidator(validate_regex)] 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

@ -17,29 +17,34 @@ if TYPE_CHECKING:
F = TypeVar("F", bound=Callable[..., Any]) F = TypeVar("F", bound=Callable[..., Any])
def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]: # TODO: Remove this decorator in ANTA v2.0.0 in favor of deprecated_test_class
def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]: # pragma: no cover
"""Return a decorator to log a message of WARNING severity when a test is deprecated. """Return a decorator to log a message of WARNING severity when a test is deprecated.
Args: Parameters
---- ----------
new_tests: A list of new test classes that should replace the deprecated test. new_tests
A list of new test classes that should replace the deprecated test.
Returns Returns
------- -------
Callable[[F], F]: A decorator that can be used to wrap test functions. Callable[[F], F]
A decorator that can be used to wrap test functions.
""" """
def decorator(function: F) -> F: def decorator(function: F) -> F:
"""Actual decorator that logs the message. """Actual decorator that logs the message.
Args: Parameters
---- ----------
function: The test function to be decorated. function
The test function to be decorated.
Returns Returns
------- -------
F: The decorated function. F
The decorated function.
""" """
@ -58,32 +63,87 @@ def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]:
return decorator return decorator
def deprecated_test_class(new_tests: list[str] | None = None, removal_in_version: str | None = None) -> Callable[[type[AntaTest]], type[AntaTest]]:
"""Return a decorator to log a message of WARNING severity when a test is deprecated.
Parameters
----------
new_tests
A list of new test classes that should replace the deprecated test.
removal_in_version
A string indicating the version in which the test will be removed.
Returns
-------
Callable[[type], type]
A decorator that can be used to wrap test functions.
"""
def decorator(cls: type[AntaTest]) -> type[AntaTest]:
"""Actual decorator that logs the message.
Parameters
----------
cls
The cls to be decorated.
Returns
-------
cls
The decorated cls.
"""
orig_init = cls.__init__
def new_init(*args: Any, **kwargs: Any) -> None:
"""Overload __init__ to generate a warning message for deprecation."""
if new_tests:
new_test_names = ", ".join(new_tests)
logger.warning("%s test is deprecated. Consider using the following new tests: %s.", cls.name, new_test_names)
else:
logger.warning("%s test is deprecated.", cls.name)
orig_init(*args, **kwargs)
if removal_in_version is not None:
cls.__removal_in_version = removal_in_version
# NOTE: we are ignoring mypy warning as we want to assign to a method here
cls.__init__ = new_init # type: ignore[method-assign]
return cls
return decorator
def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]: def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]:
"""Return a decorator to skip a test based on the device's hardware model. """Return a decorator to skip a test based on the device's hardware model.
This decorator factory generates a decorator that will check the hardware model of the device This decorator factory generates a decorator that will check the hardware model of the device
the test is run on. If the model is in the list of platforms specified, the test will be skipped. the test is run on. If the model is in the list of platforms specified, the test will be skipped.
Args: Parameters
---- ----------
platforms: List of hardware models on which the test should be skipped. platforms
List of hardware models on which the test should be skipped.
Returns Returns
------- -------
Callable[[F], F]: A decorator that can be used to wrap test functions. Callable[[F], F]
A decorator that can be used to wrap test functions.
""" """
def decorator(function: F) -> F: 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.
Args: Parameters
---- ----------
function: The test function to be decorated. function
The test function to be decorated.
Returns Returns
------- -------
F: The decorated function. F
The decorated function.
""" """

View file

@ -42,24 +42,34 @@ class AntaDevice(ABC):
Attributes Attributes
---------- ----------
name: Device name name : str
is_online: True if the device IP is reachable and a port can be open. Device name.
established: True if remote command execution succeeds. is_online : bool
hw_model: Hardware model of the device. True if the device IP is reachable and a port can be open.
tags: Tags for this device. established : bool
cache: In-memory cache from aiocache library for this device (None if cache is disabled). True if remote command execution succeeds.
cache_locks: Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled. hw_model : str
Hardware model of the device.
tags : set[str]
Tags for this device.
cache : Cache | None
In-memory cache from aiocache library for this device (None if cache is disabled).
cache_locks : dict
Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled.
""" """
def __init__(self, name: str, tags: set[str] | None = None, *, disable_cache: bool = False) -> None: def __init__(self, name: str, tags: set[str] | None = None, *, disable_cache: bool = False) -> None:
"""Initialize an AntaDevice. """Initialize an AntaDevice.
Args: Parameters
---- ----------
name: Device name. name
tags: Tags for this device. Device name.
disable_cache: Disable caching for all commands for this device. tags
Tags for this device.
disable_cache
Disable caching for all commands for this device.
""" """
self.name: str = name self.name: str = name
@ -96,7 +106,7 @@ class AntaDevice(ABC):
@property @property
def cache_statistics(self) -> dict[str, Any] | None: def cache_statistics(self) -> dict[str, Any] | None:
"""Returns the device cache statistics for logging purposes.""" """Return the device cache statistics for logging purposes."""
# Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough # Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough
# https://github.com/pylint-dev/pylint/issues/7258 # https://github.com/pylint-dev/pylint/issues/7258
if self.cache is not None: if self.cache is not None:
@ -116,6 +126,17 @@ class AntaDevice(ABC):
yield "established", self.established yield "established", self.established
yield "disable_cache", self.cache is None yield "disable_cache", self.cache is None
def __repr__(self) -> str:
"""Return a printable representation of an AntaDevice."""
return (
f"AntaDevice({self.name!r}, "
f"tags={self.tags!r}, "
f"hw_model={self.hw_model!r}, "
f"is_online={self.is_online!r}, "
f"established={self.established!r}, "
f"disable_cache={self.cache is None!r})"
)
@abstractmethod @abstractmethod
async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None:
"""Collect device command output. """Collect device command output.
@ -130,10 +151,12 @@ class AntaDevice(ABC):
exception and implement proper logging, the `output` attribute of the exception and implement proper logging, the `output` attribute of the
`AntaCommand` object passed as argument would be `None` in this case. `AntaCommand` object passed as argument would be `None` in this case.
Args: Parameters
---- ----------
command: The command to collect. command
collection_id: An identifier used to build the eAPI request ID. The command to collect.
collection_id
An identifier used to build the eAPI request ID.
""" """
async def collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: async def collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None:
@ -147,10 +170,12 @@ class AntaDevice(ABC):
When caching is NOT enabled, either at the device or command level, the method directly collects the output When caching is NOT enabled, either at the device or command level, the method directly collects the output
via the private `_collect` method without interacting with the cache. via the private `_collect` method without interacting with the cache.
Args: Parameters
---- ----------
command: The command to collect. command
collection_id: An identifier used to build the eAPI request ID. The command to collect.
collection_id
An identifier used to build the eAPI request ID.
""" """
# Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough # Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough
# https://github.com/pylint-dev/pylint/issues/7258 # https://github.com/pylint-dev/pylint/issues/7258
@ -170,10 +195,12 @@ class AntaDevice(ABC):
async def collect_commands(self, commands: list[AntaCommand], *, collection_id: str | None = None) -> None: async def collect_commands(self, commands: list[AntaCommand], *, collection_id: str | None = None) -> None:
"""Collect multiple commands. """Collect multiple commands.
Args: Parameters
---- ----------
commands: The commands to collect. commands
collection_id: An identifier used to build the eAPI request ID. The commands to collect.
collection_id
An identifier used to build the eAPI request ID.
""" """
await asyncio.gather(*(self.collect(command=command, collection_id=collection_id) for command in commands)) await asyncio.gather(*(self.collect(command=command, collection_id=collection_id) for command in commands))
@ -182,9 +209,12 @@ class AntaDevice(ABC):
"""Update attributes of an AntaDevice instance. """Update attributes of an AntaDevice instance.
This coroutine must update the following attributes of AntaDevice: This coroutine must update the following attributes of AntaDevice:
- `is_online`: When the device IP is reachable and a port can be open
- `established`: When a command execution succeeds - `is_online`: When the device IP is reachable and a port can be open.
- `hw_model`: The hardware model of the device
- `established`: When a command execution succeeds.
- `hw_model`: The hardware model of the device.
""" """
async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None: async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None:
@ -192,11 +222,14 @@ class AntaDevice(ABC):
It is not mandatory to implement this for a valid AntaDevice subclass. It is not mandatory to implement this for a valid AntaDevice subclass.
Args: Parameters
---- ----------
sources: List of files to copy to or from the device. sources
destination: Local or remote destination when copying the files. Can be a folder. List of files to copy to or from the device.
direction: Defines if this coroutine copies files to or from the device. destination
Local or remote destination when copying the files. Can be a folder.
direction
Defines if this coroutine copies files to or from the device.
""" """
_ = (sources, destination, direction) _ = (sources, destination, direction)
@ -209,16 +242,20 @@ class AsyncEOSDevice(AntaDevice):
Attributes Attributes
---------- ----------
name: Device name name : str
is_online: True if the device IP is reachable and a port can be open Device name.
established: True if remote command execution succeeds is_online : bool
hw_model: Hardware model of the device True if the device IP is reachable and a port can be open.
tags: Tags for this device established : bool
True if remote command execution succeeds.
hw_model : str
Hardware model of the device.
tags : set[str]
Tags for this device.
""" """
# pylint: disable=R0913 def __init__( # noqa: PLR0913
def __init__(
self, self,
host: str, host: str,
username: str, username: str,
@ -237,21 +274,34 @@ class AsyncEOSDevice(AntaDevice):
) -> None: ) -> None:
"""Instantiate an AsyncEOSDevice. """Instantiate an AsyncEOSDevice.
Args: Parameters
---- ----------
host: Device FQDN or IP. host
username: Username to connect to eAPI and SSH. Device FQDN or IP.
password: Password to connect to eAPI and SSH. username
name: Device name. Username to connect to eAPI and SSH.
enable: Collect commands using privileged mode. password
enable_password: Password used to gain privileged access on EOS. Password to connect to eAPI and SSH.
port: eAPI port. Defaults to 80 is proto is 'http' or 443 if proto is 'https'. name
ssh_port: SSH port. Device name.
tags: Tags for this device. enable
timeout: Timeout value in seconds for outgoing API calls. Collect commands using privileged mode.
insecure: Disable SSH Host Key validation. enable_password
proto: eAPI protocol. Value can be 'http' or 'https'. Password used to gain privileged access on EOS.
disable_cache: Disable caching for all commands for this device. 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.
""" """
if host is None: if host is None:
@ -298,6 +348,22 @@ class AsyncEOSDevice(AntaDevice):
yield ("_session", vars(self._session)) yield ("_session", vars(self._session))
yield ("_ssh_opts", _ssh_opts) yield ("_ssh_opts", _ssh_opts)
def __repr__(self) -> str:
"""Return a printable representation of an AsyncEOSDevice."""
return (
f"AsyncEOSDevice({self.name!r}, "
f"tags={self.tags!r}, "
f"hw_model={self.hw_model!r}, "
f"is_online={self.is_online!r}, "
f"established={self.established!r}, "
f"disable_cache={self.cache is None!r}, "
f"host={self._session.host!r}, "
f"eapi_port={self._session.port!r}, "
f"username={self._ssh_opts.username!r}, "
f"enable={self.enable!r}, "
f"insecure={self._ssh_opts.known_hosts is None!r})"
)
@property @property
def _keys(self) -> tuple[Any, ...]: def _keys(self) -> tuple[Any, ...]:
"""Two AsyncEOSDevice objects are equal if the hostname and the port are the same. """Two AsyncEOSDevice objects are equal if the hostname and the port are the same.
@ -306,17 +372,19 @@ class AsyncEOSDevice(AntaDevice):
""" """
return (self._session.host, self._session.port) return (self._session.host, self._session.port)
async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: # noqa: C901 function is too complex - because of many required except blocks #pylint: disable=line-too-long async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None:
"""Collect device command output from EOS using aio-eapi. """Collect device command output from EOS using aio-eapi.
Supports outformat `json` and `text` as output structure. Supports outformat `json` and `text` as output structure.
Gain privileged access using the `enable_password` attribute Gain privileged access using the `enable_password` attribute
of the `AntaDevice` instance if populated. of the `AntaDevice` instance if populated.
Args: Parameters
---- ----------
command: The command to collect. command
collection_id: An identifier used to build the eAPI request ID. The command to collect.
collection_id
An identifier used to build the eAPI request ID.
""" """
commands: list[dict[str, str | int]] = [] commands: list[dict[str, str | int]] = []
if self.enable and self._enable_password is not None: if self.enable and self._enable_password is not None:
@ -341,15 +409,7 @@ class AsyncEOSDevice(AntaDevice):
command.output = response[-1] command.output = response[-1]
except asynceapi.EapiCommandError as e: except asynceapi.EapiCommandError as e:
# This block catches exceptions related to EOS issuing an error. # This block catches exceptions related to EOS issuing an error.
command.errors = e.errors self._log_eapi_command_error(command, e)
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 command.supported:
logger.error("Command '%s' failed on %s: %s", command.command, self.name, e.errors[0] if len(e.errors) == 1 else e.errors)
else:
logger.debug("Command '%s' is not supported on '%s' (%s)", command.command, self.name, self.hw_model)
except TimeoutException as e: except TimeoutException as e:
# This block catches Timeout exceptions. # This block catches Timeout exceptions.
command.errors = [exc_to_str(e)] command.errors = [exc_to_str(e)]
@ -378,6 +438,18 @@ class AsyncEOSDevice(AntaDevice):
anta_log_exception(e, f"An error occurred while issuing an eAPI request to {self.name}", logger) anta_log_exception(e, f"An error occurred while issuing an eAPI request to {self.name}", logger)
logger.debug("%s: %s", self.name, command) logger.debug("%s: %s", self.name, command)
def _log_eapi_command_error(self, command: AntaCommand, e: asynceapi.EapiCommandError) -> None:
"""Appropriately log the eapi command error."""
command.errors = e.errors
if command.requires_privileges:
logger.error("Command '%s' requires privileged mode on %s. Verify user permissions and if the `enable` option is required.", command.command, self.name)
if not command.supported:
logger.debug("Command '%s' is not supported on '%s' (%s)", command.command, self.name, self.hw_model)
elif command.returned_known_eos_error:
logger.debug("Command '%s' returned a known error '%s': %s", command.command, self.name, command.errors)
else:
logger.error("Command '%s' failed on %s: %s", command.command, self.name, e.errors[0] if len(e.errors) == 1 else e.errors)
async def refresh(self) -> None: async def refresh(self) -> None:
"""Update attributes of an AsyncEOSDevice instance. """Update attributes of an AsyncEOSDevice instance.
@ -397,6 +469,10 @@ class AsyncEOSDevice(AntaDevice):
self.hw_model = show_version.json_output.get("modelName", None) self.hw_model = show_version.json_output.get("modelName", None)
if self.hw_model is None: if self.hw_model is None:
logger.critical("Cannot parse 'show version' returned by device %s", self.name) logger.critical("Cannot parse 'show version' returned by device %s", self.name)
# in some cases it is possible that 'modelName' comes back empty
# and it is nice to get a meaninfule error message
elif self.hw_model == "":
logger.critical("Got an empty 'modelName' in the 'show version' returned by device %s", self.name)
else: else:
logger.warning("Could not connect to device %s: cannot open eAPI port", self.name) logger.warning("Could not connect to device %s: cannot open eAPI port", self.name)
@ -405,11 +481,14 @@ class AsyncEOSDevice(AntaDevice):
async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None: async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None:
"""Copy files to and from the device using asyncssh.scp(). """Copy files to and from the device using asyncssh.scp().
Args: Parameters
---- ----------
sources: List of files to copy to or from the device. sources
destination: Local or remote destination when copying the files. Can be a folder. List of files to copy to or from the device.
direction: Defines if this coroutine copies files to or from the device. destination
Local or remote destination when copying the files. Can be a folder.
direction
Defines if this coroutine copies files to or from the device.
""" """
async with asyncssh.connect( async with asyncssh.connect(

View file

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

36
anta/input_models/avt.py Normal file
View file

@ -0,0 +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 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})"

37
anta/input_models/bfd.py Normal file
View file

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

@ -0,0 +1,83 @@
# 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)

19
anta/input_models/cvx.py Normal file
View file

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

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

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

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

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

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

@ -0,0 +1,31 @@
# 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})"

35
anta/input_models/stun.py Normal file
View file

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

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

@ -44,10 +44,12 @@ class AntaInventory(dict[str, AntaDevice]):
def _update_disable_cache(kwargs: dict[str, Any], *, inventory_disable_cache: bool) -> dict[str, Any]: 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. """Return new dictionary, replacing kwargs with added disable_cache value from inventory_value if disable_cache has not been set by CLI.
Args: Parameters
---- ----------
inventory_disable_cache: The value of disable_cache in the inventory inventory_disable_cache
kwargs: The kwargs to instantiate the device The value of disable_cache in the inventory.
kwargs
The kwargs to instantiate the device.
""" """
updated_kwargs = kwargs.copy() updated_kwargs = kwargs.copy()
@ -62,11 +64,14 @@ class AntaInventory(dict[str, AntaDevice]):
) -> None: ) -> None:
"""Parse the host section of an AntaInventoryInput and add the devices to the inventory. """Parse the host section of an AntaInventoryInput and add the devices to the inventory.
Args: Parameters
---- ----------
inventory_input: AntaInventoryInput used to parse the devices inventory_input
inventory: AntaInventory to add the parsed devices to AntaInventoryInput used to parse the devices.
**kwargs: Additional keyword arguments to pass to the device constructor inventory
AntaInventory to add the parsed devices to.
**kwargs
Additional keyword arguments to pass to the device constructor.
""" """
if inventory_input.hosts is None: if inventory_input.hosts is None:
@ -91,15 +96,19 @@ class AntaInventory(dict[str, AntaDevice]):
) -> None: ) -> None:
"""Parse the network section of an AntaInventoryInput and add the devices to the inventory. """Parse the network section of an AntaInventoryInput and add the devices to the inventory.
Args: Parameters
---- ----------
inventory_input: AntaInventoryInput used to parse the devices inventory_input
inventory: AntaInventory to add the parsed devices to AntaInventoryInput used to parse the devices.
**kwargs: Additional keyword arguments to pass to the device constructor inventory
AntaInventory to add the parsed devices to.
**kwargs
Additional keyword arguments to pass to the device constructor.
Raises Raises
------ ------
InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema. InventoryIncorrectSchemaError
Inventory file is not following AntaInventory Schema.
""" """
if inventory_input.networks is None: if inventory_input.networks is None:
@ -124,15 +133,19 @@ class AntaInventory(dict[str, AntaDevice]):
) -> None: ) -> None:
"""Parse the range section of an AntaInventoryInput and add the devices to the inventory. """Parse the range section of an AntaInventoryInput and add the devices to the inventory.
Args: Parameters
---- ----------
inventory_input: AntaInventoryInput used to parse the devices inventory_input
inventory: AntaInventory to add the parsed devices to AntaInventoryInput used to parse the devices.
**kwargs: Additional keyword arguments to pass to the device constructor inventory
AntaInventory to add the parsed devices to.
**kwargs
Additional keyword arguments to pass to the device constructor.
Raises Raises
------ ------
InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema. InventoryIncorrectSchemaError
Inventory file is not following AntaInventory Schema.
""" """
if inventory_input.ranges is None: if inventory_input.ranges is None:
@ -158,7 +171,6 @@ class AntaInventory(dict[str, AntaDevice]):
anta_log_exception(e, message, logger) anta_log_exception(e, message, logger)
raise InventoryIncorrectSchemaError(message) from e raise InventoryIncorrectSchemaError(message) from e
# pylint: disable=too-many-arguments
@staticmethod @staticmethod
def parse( def parse(
filename: str | Path, filename: str | Path,
@ -175,21 +187,31 @@ class AntaInventory(dict[str, AntaDevice]):
The inventory devices are AsyncEOSDevice instances. The inventory devices are AsyncEOSDevice instances.
Args: Parameters
---- ----------
filename: Path to device inventory YAML file. filename
username: Username to use to connect to devices. Path to device inventory YAML file.
password: Password to use to connect to devices. username
enable_password: Enable password to use if required. Username to use to connect to devices.
timeout: Timeout value in seconds for outgoing API calls. password
enable: Whether or not the commands need to be run in enable mode towards the devices. Password to use to connect to devices.
insecure: Disable SSH Host Key validation. enable_password
disable_cache: Disable cache globally. Enable password to use if required.
timeout
Timeout value in seconds for outgoing API calls.
enable
Whether or not the commands need to be run in enable mode towards the devices.
insecure
Disable SSH Host Key validation.
disable_cache
Disable cache globally.
Raises Raises
------ ------
InventoryRootKeyError: Root key of inventory is missing. InventoryRootKeyError
InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema. Root key of inventory is missing.
InventoryIncorrectSchemaError
Inventory file is not following AntaInventory Schema.
""" """
inventory = AntaInventory() inventory = AntaInventory()
@ -254,14 +276,18 @@ class AntaInventory(dict[str, AntaDevice]):
def get_inventory(self, *, established_only: bool = False, tags: set[str] | None = None, devices: set[str] | None = None) -> AntaInventory: def get_inventory(self, *, established_only: bool = False, tags: set[str] | None = None, devices: set[str] | None = None) -> AntaInventory:
"""Return a filtered inventory. """Return a filtered inventory.
Args: Parameters
---- ----------
established_only: Whether or not to include only established devices. established_only
tags: Tags to filter devices. Whether or not to include only established devices.
devices: Names to filter devices. tags
Tags to filter devices.
devices
Names to filter devices.
Returns Returns
------- -------
AntaInventory
An inventory with filtered AntaDevice objects. An inventory with filtered AntaDevice objects.
""" """
@ -293,9 +319,10 @@ class AntaInventory(dict[str, AntaDevice]):
def add_device(self, device: AntaDevice) -> None: def add_device(self, device: AntaDevice) -> None:
"""Add a device to final inventory. """Add a device to final inventory.
Args: Parameters
---- ----------
device: Device object to be added device
Device object to be added.
""" """
self[device.name] = device self[device.name] = device

View file

@ -6,7 +6,9 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
import yaml
from pydantic import BaseModel, ConfigDict, IPvAnyAddress, IPvAnyNetwork from pydantic import BaseModel, ConfigDict, IPvAnyAddress, IPvAnyNetwork
from anta.custom_types import Hostname, Port from anta.custom_types import Hostname, Port
@ -19,11 +21,16 @@ class AntaInventoryHost(BaseModel):
Attributes Attributes
---------- ----------
host: IP Address or FQDN of the device. host : Hostname | IPvAnyAddress
port: Custom eAPI port to use. IP Address or FQDN of the device.
name: Custom name of the device. port : Port | None
tags: Tags of the device. Custom eAPI port to use.
disable_cache: Disable cache for this device. name : str | None
Custom name of the device.
tags : set[str]
Tags of the device.
disable_cache : bool
Disable cache for this device.
""" """
@ -41,9 +48,12 @@ class AntaInventoryNetwork(BaseModel):
Attributes Attributes
---------- ----------
network: Subnet to use for scanning. network : IPvAnyNetwork
tags: Tags of the devices in this network. Subnet to use for scanning.
disable_cache: Disable cache for all devices in this network. tags : set[str]
Tags of the devices in this network.
disable_cache : bool
Disable cache for all devices in this network.
""" """
@ -59,10 +69,14 @@ class AntaInventoryRange(BaseModel):
Attributes Attributes
---------- ----------
start: IPv4 or IPv6 address for the beginning of the range. start : IPvAnyAddress
stop: IPv4 or IPv6 address for the end of the range. IPv4 or IPv6 address for the beginning of the range.
tags: Tags of the devices in this IP range. stop : IPvAnyAddress
disable_cache: Disable cache for all devices in this IP range. 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.
""" """
@ -82,3 +96,17 @@ class AntaInventoryInput(BaseModel):
networks: list[AntaInventoryNetwork] | None = None networks: list[AntaInventoryNetwork] | None = None
hosts: list[AntaInventoryHost] | None = None hosts: list[AntaInventoryHost] | None = None
ranges: list[AntaInventoryRange] | 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)

View file

@ -49,10 +49,12 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None:
If a file is provided and logging level is DEBUG, only the logging level INFO and higher will If a file is provided and logging level is DEBUG, only the logging level INFO and higher will
be logged to stdout while all levels will be logged in the file. be logged to stdout while all levels will be logged in the file.
Args: Parameters
---- ----------
level: ANTA logging level level
file: Send logs to a file ANTA logging level
file
Send logs to a file
""" """
# Init root logger # Init root logger
@ -104,11 +106,14 @@ def anta_log_exception(exception: BaseException, message: str | None = None, cal
If `anta.__DEBUG__` is True then the `logger.exception` method is called to get the traceback, otherwise `logger.error` is called. If `anta.__DEBUG__` is True then the `logger.exception` method is called to get the traceback, otherwise `logger.error` is called.
Args: Parameters
---- ----------
exception: The Exception being logged. exception
message: An optional message. The Exception being logged.
calling_logger: A logger to which the exception should be logged. If not present, the logger in this file is used. message
An optional message.
calling_logger
A logger to which the exception should be logged. If not present, the logger in this file is used.
""" """
if calling_logger is None: if calling_logger is None:

View file

@ -16,9 +16,10 @@ from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, TypeVar
from pydantic import BaseModel, ConfigDict, ValidationError, create_model from pydantic import BaseModel, ConfigDict, ValidationError, create_model
from anta import GITHUB_SUGGESTION from anta import GITHUB_SUGGESTION
from anta.constants import KNOWN_EOS_ERRORS
from anta.custom_types import REGEXP_EOS_BLACKLIST_CMDS, Revision from anta.custom_types import REGEXP_EOS_BLACKLIST_CMDS, Revision
from anta.logger import anta_log_exception, exc_to_str from anta.logger import anta_log_exception, exc_to_str
from anta.result_manager.models import TestResult from anta.result_manager.models import AntaTestStatus, TestResult
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Coroutine from collections.abc import Coroutine
@ -48,16 +49,21 @@ class AntaTemplate:
Attributes Attributes
---------- ----------
template: Python f-string. Example: 'show vlan {vlan_id}' template
version: eAPI version - valid values are 1 or "latest". Python f-string. Example: 'show vlan {vlan_id}'.
revision: Revision of the command. Valid values are 1 to 99. Revision has precedence over version. version
ofmt: eAPI output - json or text. eAPI version - valid values are 1 or "latest".
use_cache: Enable or disable caching for this AntaTemplate if the AntaDevice supports it. revision
Revision of the command. Valid values are 1 to 99. Revision has precedence over version.
ofmt
eAPI output - json or text.
use_cache
Enable or disable caching for this AntaTemplate if the AntaDevice supports it.
""" """
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
def __init__( # noqa: PLR0913 def __init__(
self, self,
template: str, template: str,
version: Literal[1, "latest"] = "latest", version: Literal[1, "latest"] = "latest",
@ -66,7 +72,6 @@ class AntaTemplate:
*, *,
use_cache: bool = True, use_cache: bool = True,
) -> None: ) -> None:
# pylint: disable=too-many-arguments
self.template = template self.template = template
self.version = version self.version = version
self.revision = revision self.revision = revision
@ -95,12 +100,14 @@ class AntaTemplate:
Keep the parameters used in the AntaTemplate instance. Keep the parameters used in the AntaTemplate instance.
Args: Parameters
---- ----------
params: dictionary of variables with string values to render the Python f-string params
Dictionary of variables with string values to render the Python f-string.
Returns Returns
------- -------
AntaCommand
The rendered AntaCommand. The rendered AntaCommand.
This AntaCommand instance have a template attribute that references this This AntaCommand instance have a template attribute that references this
AntaTemplate instance. AntaTemplate instance.
@ -141,15 +148,24 @@ class AntaCommand(BaseModel):
Attributes Attributes
---------- ----------
command: Device command command
version: eAPI version - valid values are 1 or "latest". Device command.
revision: eAPI revision of the command. Valid values are 1 to 99. Revision has precedence over version. version
ofmt: eAPI output - json or text. eAPI version - valid values are 1 or "latest".
output: Output of the command. Only defined if there was no errors. revision
template: AntaTemplate object used to render this command. eAPI revision of the command. Valid values are 1 to 99. Revision has precedence over version.
errors: If the command execution fails, eAPI returns a list of strings detailing the error(s). ofmt
params: Pydantic Model containing the variables values used to render the template. eAPI output - json or text.
use_cache: Enable or disable caching for this AntaCommand if the AntaDevice supports it. 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.
""" """
@ -225,7 +241,12 @@ class AntaCommand(BaseModel):
@property @property
def supported(self) -> bool: def supported(self) -> bool:
"""Return True if the command is supported on the device hardware platform, False otherwise. """Indicates if the command is supported on the device.
Returns
-------
bool
True if the command is supported on the device hardware platform, False otherwise.
Raises Raises
------ ------
@ -235,8 +256,22 @@ class AntaCommand(BaseModel):
""" """
if not self.collected and not self.error: 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()." msg = f"Command '{self.command}' has not been collected and has not returned an error. Call AntaDevice.collect()."
raise RuntimeError(msg) raise RuntimeError(msg)
return not any("not supported on this hardware platform" in e for e in self.errors) return all("not supported on this hardware platform" not in e for e in self.errors)
@property
def returned_known_eos_error(self) -> bool:
"""Return True if the command returned a known_eos_error on the device, False otherwise.
RuntimeError
If the command has not been collected and has not returned an error.
AntaDevice.collect() must be called before this property.
"""
if not self.collected and not self.error:
msg = f"Command '{self.command}' has not been collected and has not returned an error. Call AntaDevice.collect()."
raise RuntimeError(msg)
return any(any(re.match(pattern, e) for e in self.errors) for pattern in KNOWN_EOS_ERRORS)
class AntaTemplateRenderError(RuntimeError): class AntaTemplateRenderError(RuntimeError):
@ -245,10 +280,12 @@ class AntaTemplateRenderError(RuntimeError):
def __init__(self, template: AntaTemplate, key: str) -> None: def __init__(self, template: AntaTemplate, key: str) -> None:
"""Initialize an AntaTemplateRenderError. """Initialize an AntaTemplateRenderError.
Args: Parameters
---- ----------
template: The AntaTemplate instance that failed to render template
key: Key that has not been provided to render the template The AntaTemplate instance that failed to render.
key
Key that has not been provided to render the template.
""" """
self.template = template self.template = template
@ -267,8 +304,7 @@ class AntaTest(ABC):
The following is an example of an AntaTest subclass implementation: The following is an example of an AntaTest subclass implementation:
```python ```python
class VerifyReachability(AntaTest): class VerifyReachability(AntaTest):
name = "VerifyReachability" '''Test the network reachability to one or many destination IP(s).'''
description = "Test the network reachability to one or many destination IP(s)."
categories = ["connectivity"] categories = ["connectivity"]
commands = [AntaTemplate(template="ping vrf {vrf} {dst} source {src} repeat 2")] commands = [AntaTemplate(template="ping vrf {vrf} {dst} source {src} repeat 2")]
@ -297,19 +333,31 @@ class AntaTest(ABC):
Attributes Attributes
---------- ----------
device: AntaDevice instance on which this test is run device
inputs: AntaTest.Input instance carrying the test inputs AntaDevice instance on which this test is run.
instance_commands: List of AntaCommand instances of this test inputs
result: TestResult instance representing the result of this test AntaTest.Input instance carrying the test inputs.
logger: Python logger for this test instance instance_commands
List of AntaCommand instances of this test.
result
TestResult instance representing the result of this test.
logger
Python logger for this test instance.
""" """
# Mandatory class attributes # Optional class attributes
# TODO: find a way to tell mypy these are mandatory for child classes - maybe Protocol
name: ClassVar[str] name: ClassVar[str]
description: ClassVar[str] description: ClassVar[str]
__removal_in_version: ClassVar[str]
"""Internal class variable set by the `deprecated_test_class` decorator."""
# Mandatory class attributes
# TODO: find a way to tell mypy these are mandatory for child classes
# follow this https://discuss.python.org/t/provide-a-canonical-way-to-declare-an-abstract-class-variable/69416
# for now only enforced at runtime with __init_subclass__
categories: ClassVar[list[str]] categories: ClassVar[list[str]]
commands: ClassVar[list[AntaTemplate | AntaCommand]] commands: ClassVar[list[AntaTemplate | AntaCommand]]
# Class attributes to handle the progress bar of ANTA CLI # Class attributes to handle the progress bar of ANTA CLI
progress: Progress | None = None progress: Progress | None = None
nrfu_task: TaskID | None = None nrfu_task: TaskID | None = None
@ -332,7 +380,8 @@ class AntaTest(ABC):
Attributes Attributes
---------- ----------
result_overwrite: Define fields to overwrite in the TestResult object result_overwrite
Define fields to overwrite in the TestResult object.
""" """
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@ -351,9 +400,12 @@ class AntaTest(ABC):
Attributes Attributes
---------- ----------
description: overwrite TestResult.description description
categories: overwrite TestResult.categories Overwrite `TestResult.description`.
custom_field: a free string that will be included in the TestResult object categories
Overwrite `TestResult.categories`.
custom_field
A free string that will be included in the TestResult object.
""" """
@ -367,7 +419,8 @@ class AntaTest(ABC):
Attributes Attributes
---------- ----------
tags: Tag of devices on which to run the test. tags
Tag of devices on which to run the test.
""" """
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@ -381,11 +434,14 @@ class AntaTest(ABC):
) -> None: ) -> None:
"""AntaTest Constructor. """AntaTest Constructor.
Args: Parameters
---- ----------
device: AntaDevice instance on which the test will be run device
inputs: dictionary of attributes used to instantiate the AntaTest.Input instance AntaDevice instance on which the test will be run.
eos_data: Populate outputs of the test commands instead of collecting from devices. inputs
Dictionary of attributes used to instantiate the AntaTest.Input instance.
eos_data
Populate outputs of the test commands instead of collecting from devices.
This list must have the same length and order than the `instance_commands` instance attribute. This list must have the same length and order than the `instance_commands` instance attribute.
""" """
self.logger: logging.Logger = logging.getLogger(f"{self.module}.{self.__class__.__name__}") self.logger: logging.Logger = logging.getLogger(f"{self.module}.{self.__class__.__name__}")
@ -399,7 +455,7 @@ class AntaTest(ABC):
description=self.description, description=self.description,
) )
self._init_inputs(inputs) self._init_inputs(inputs)
if self.result.result == "unset": if self.result.result == AntaTestStatus.UNSET:
self._init_commands(eos_data) self._init_commands(eos_data)
def _init_inputs(self, inputs: dict[str, Any] | AntaTest.Input | None) -> None: def _init_inputs(self, inputs: dict[str, Any] | AntaTest.Input | None) -> None:
@ -450,7 +506,7 @@ class AntaTest(ABC):
except NotImplementedError as e: except NotImplementedError as e:
self.result.is_error(message=e.args[0]) self.result.is_error(message=e.args[0])
return return
except Exception as e: # pylint: disable=broad-exception-caught except Exception as e: # noqa: BLE001
# render() is user-defined code. # render() is user-defined code.
# We need to catch everything if we want the AntaTest object # We need to catch everything if we want the AntaTest object
# to live until the reporting # to live until the reporting
@ -475,12 +531,19 @@ class AntaTest(ABC):
self.instance_commands[index].output = data self.instance_commands[index].output = data
def __init_subclass__(cls) -> None: def __init_subclass__(cls) -> None:
"""Verify that the mandatory class attributes are defined.""" """Verify that the mandatory class attributes are defined and set name and description if not set."""
mandatory_attributes = ["name", "description", "categories", "commands"] mandatory_attributes = ["categories", "commands"]
for attr in mandatory_attributes: if missing_attrs := [attr for attr in mandatory_attributes if not hasattr(cls, attr)]:
if not hasattr(cls, attr): msg = f"Class {cls.__module__}.{cls.__name__} is missing required class attribute(s): {', '.join(missing_attrs)}"
msg = f"Class {cls.__module__}.{cls.__name__} is missing required class attribute {attr}" raise AttributeError(msg)
raise NotImplementedError(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 @property
def module(self) -> str: def module(self) -> str:
@ -528,7 +591,7 @@ class AntaTest(ABC):
try: try:
if self.blocked is False: if self.blocked is False:
await self.device.collect_commands(self.instance_commands, collection_id=self.name) await self.device.collect_commands(self.instance_commands, collection_id=self.name)
except Exception as e: # pylint: disable=broad-exception-caught except Exception as e: # noqa: BLE001
# device._collect() is user-defined code. # device._collect() is user-defined code.
# We need to catch everything if we want the AntaTest object # We need to catch everything if we want the AntaTest object
# to live until the reporting # to live until the reporting
@ -556,16 +619,20 @@ class AntaTest(ABC):
) -> TestResult: ) -> TestResult:
"""Inner function for the anta_test decorator. """Inner function for the anta_test decorator.
Args: Parameters
---- ----------
self: The test instance. self
eos_data: Populate outputs of the test commands instead of collecting from devices. 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. This list must have the same length and order than the `instance_commands` instance attribute.
kwargs: Any keyword argument to pass to the test. kwargs
Any keyword argument to pass to the test.
Returns Returns
------- -------
result: TestResult instance attribute populated with error status if any TestResult
The TestResult instance attribute populated with error status if any.
""" """
if self.result.result != "unset": if self.result.result != "unset":
@ -583,20 +650,15 @@ class AntaTest(ABC):
AntaTest.update_progress() AntaTest.update_progress()
return self.result return self.result
if cmds := self.failed_commands: if self.failed_commands:
unsupported_commands = [f"'{c.command}' is not supported on {self.device.hw_model}" for c in cmds if not c.supported] self._handle_failed_commands()
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))
else:
self.result.is_error(message="\n".join([f"{c.command} has failed: {', '.join(c.errors)}" for c in cmds]))
AntaTest.update_progress() AntaTest.update_progress()
return self.result return self.result
try: try:
function(self, **kwargs) function(self, **kwargs)
except Exception as e: # pylint: disable=broad-exception-caught except Exception as e: # noqa: BLE001
# test() is user-defined code. # test() is user-defined code.
# We need to catch everything if we want the AntaTest object # We need to catch everything if we want the AntaTest object
# to live until the reporting # to live until the reporting
@ -610,6 +672,28 @@ class AntaTest(ABC):
return wrapper return wrapper
def _handle_failed_commands(self) -> None:
"""Handle failed commands inside a test.
There can be 3 types:
* unsupported on hardware commands which set the test status to 'skipped'
* known EOS error which set the test status to 'failure'
* unknown failure which set the test status to 'error'
"""
cmds = self.failed_commands
unsupported_commands = [f"'{c.command}' is not supported on {self.device.hw_model}" for c in cmds if not c.supported]
if unsupported_commands:
msg = f"Test {self.name} has been skipped because it is not supported on {self.device.hw_model}: {GITHUB_SUGGESTION}"
self.logger.warning(msg)
self.result.is_skipped("\n".join(unsupported_commands))
return
returned_known_eos_error = [f"'{c.command}' failed on {self.device.name}: {', '.join(c.errors)}" for c in cmds if c.returned_known_eos_error]
if returned_known_eos_error:
self.result.is_failure("\n".join(returned_known_eos_error))
return
self.result.is_error(message="\n".join([f"{c.command} has failed: {', '.join(c.errors)}" for c in cmds]))
@classmethod @classmethod
def update_progress(cls: type[AntaTest]) -> None: def update_progress(cls: type[AntaTest]) -> None:
"""Update progress bar for all AntaTest objects if it exists.""" """Update progress bar for all AntaTest objects if it exists."""

0
anta/py.typed Normal file
View file

View file

@ -7,19 +7,20 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from jinja2 import Template from jinja2 import Template
from rich.table import Table from rich.table import Table
from anta import RICH_COLOR_PALETTE, RICH_COLOR_THEME from anta import RICH_COLOR_PALETTE, RICH_COLOR_THEME
from anta.tools import convert_categories
if TYPE_CHECKING: if TYPE_CHECKING:
import pathlib import pathlib
from anta.custom_types import TestStatus
from anta.result_manager import ResultManager from anta.result_manager import ResultManager
from anta.result_manager.models import TestResult from anta.result_manager.models import AntaTestStatus, TestResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -27,17 +28,33 @@ logger = logging.getLogger(__name__)
class ReportTable: class ReportTable:
"""TableReport Generate a Table based on TestResult.""" """TableReport Generate a Table based on TestResult."""
@dataclass()
class Headers: # pylint: disable=too-many-instance-attributes
"""Headers for the table report."""
device: str = "Device"
test_case: str = "Test Name"
number_of_success: str = "# of success"
number_of_failure: str = "# of failure"
number_of_skipped: str = "# of skipped"
number_of_errors: str = "# of errors"
list_of_error_nodes: str = "List of failed or error nodes"
list_of_error_tests: str = "List of failed or error test cases"
def _split_list_to_txt_list(self, usr_list: list[str], delimiter: str | None = None) -> str: def _split_list_to_txt_list(self, usr_list: list[str], delimiter: str | None = None) -> str:
"""Split list to multi-lines string. """Split list to multi-lines string.
Args: Parameters
---- ----------
usr_list (list[str]): List of string to concatenate usr_list : list[str]
delimiter (str, optional): A delimiter to use to start string. Defaults to None. List of string to concatenate.
delimiter : str, optional
A delimiter to use to start string. Defaults to None.
Returns Returns
------- -------
str: Multi-lines string str
Multi-lines string.
""" """
if delimiter is not None: if delimiter is not None:
@ -49,55 +66,58 @@ class ReportTable:
First key is considered as header and is colored using RICH_COLOR_PALETTE.HEADER First key is considered as header and is colored using RICH_COLOR_PALETTE.HEADER
Args: Parameters
---- ----------
headers: List of headers. headers
table: A rich Table instance. List of headers.
table
A rich Table instance.
Returns Returns
------- -------
Table
A rich `Table` instance with headers. A rich `Table` instance with headers.
""" """
for idx, header in enumerate(headers): for idx, header in enumerate(headers):
if idx == 0: if idx == 0:
table.add_column(header, justify="left", style=RICH_COLOR_PALETTE.HEADER, no_wrap=True) table.add_column(header, justify="left", style=RICH_COLOR_PALETTE.HEADER, no_wrap=True)
elif header == "Test Name":
# We always want the full test name
table.add_column(header, justify="left", no_wrap=True)
else: else:
table.add_column(header, justify="left") table.add_column(header, justify="left")
return table return table
def _color_result(self, status: TestStatus) -> str: def _color_result(self, status: AntaTestStatus) -> str:
"""Return a colored string based on the status value. """Return a colored string based on an AntaTestStatus.
Args: Parameters
---- ----------
status (TestStatus): status value to color. status
AntaTestStatus enum to color.
Returns Returns
------- -------
str: the colored string str
The colored string.
""" """
color = RICH_COLOR_THEME.get(status, "") color = RICH_COLOR_THEME.get(str(status), "")
return f"[{color}]{status}" if color != "" else str(status) return f"[{color}]{status}" if color != "" else str(status)
def report_all(self, manager: ResultManager, title: str = "All tests results") -> Table: def report_all(self, manager: ResultManager, title: str = "All tests results") -> Table:
"""Create a table report with all tests for one or all devices. """Create a table report with all tests for one or all devices.
Create table with full output: Host / Test / Status / Message Create table with full output: Device | Test Name | Test Status | Message(s) | Test description | Test category
Args: Parameters
---- ----------
manager: A ResultManager instance. manager
title: Title for the report. Defaults to 'All tests results'. A ResultManager instance.
title
Title for the report. Defaults to 'All tests results'.
Returns Returns
------- -------
A fully populated rich `Table` Table
A fully populated rich `Table`.
""" """
table = Table(title=title, show_lines=True) table = Table(title=title, show_lines=True)
headers = ["Device", "Test Name", "Test Status", "Message(s)", "Test description", "Test category"] headers = ["Device", "Test Name", "Test Status", "Message(s)", "Test description", "Test category"]
@ -106,7 +126,7 @@ class ReportTable:
def add_line(result: TestResult) -> None: def add_line(result: TestResult) -> None:
state = self._color_result(result.result) state = self._color_result(result.result)
message = self._split_list_to_txt_list(result.messages) if len(result.messages) > 0 else "" message = self._split_list_to_txt_list(result.messages) if len(result.messages) > 0 else ""
categories = ", ".join(result.categories) categories = ", ".join(convert_categories(result.categories))
table.add_row(str(result.name), result.test, state, message, result.description, categories) table.add_row(str(result.name), result.test, state, message, result.description, categories)
for result in manager.results: for result in manager.results:
@ -121,43 +141,42 @@ class ReportTable:
) -> Table: ) -> Table:
"""Create a table report with result aggregated per test. """Create a table report with result aggregated per test.
Create table with full output: Test | Number of success | Number of failure | Number of error | List of nodes in error or failure Create table with full output:
Test Name | # of success | # of skipped | # of failure | # of errors | List of failed or error nodes
Args: Parameters
---- ----------
manager: A ResultManager instance. manager
tests: List of test names to include. None to select all tests. A ResultManager instance.
title: Title of the report. tests
List of test names to include. None to select all tests.
title
Title of the report.
Returns Returns
------- -------
Table
A fully populated rich `Table`. A fully populated rich `Table`.
""" """
table = Table(title=title, show_lines=True) table = Table(title=title, show_lines=True)
headers = [ headers = [
"Test Case", self.Headers.test_case,
"# of success", self.Headers.number_of_success,
"# of skipped", self.Headers.number_of_skipped,
"# of failure", self.Headers.number_of_failure,
"# of errors", self.Headers.number_of_errors,
"List of failed or error nodes", self.Headers.list_of_error_nodes,
] ]
table = self._build_headers(headers=headers, table=table) table = self._build_headers(headers=headers, table=table)
for test in manager.get_tests(): for test, stats in sorted(manager.test_stats.items()):
if tests is None or test in tests: if tests is None or test in tests:
results = manager.filter_by_tests({test}).results
nb_failure = len([result for result in results if result.result == "failure"])
nb_error = len([result for result in results if result.result == "error"])
list_failure = [result.name for result in results if result.result in ["failure", "error"]]
nb_success = len([result for result in results if result.result == "success"])
nb_skipped = len([result for result in results if result.result == "skipped"])
table.add_row( table.add_row(
test, test,
str(nb_success), str(stats.devices_success_count),
str(nb_skipped), str(stats.devices_skipped_count),
str(nb_failure), str(stats.devices_failure_count),
str(nb_error), str(stats.devices_error_count),
str(list_failure), ", ".join(stats.devices_failure),
) )
return table return table
@ -169,43 +188,41 @@ class ReportTable:
) -> Table: ) -> Table:
"""Create a table report with result aggregated per device. """Create a table report with result aggregated per device.
Create table with full output: Host | Number of success | Number of failure | Number of error | List of nodes in error or failure Create table with full output: Device | # of success | # of skipped | # of failure | # of errors | List of failed or error test cases
Args: Parameters
---- ----------
manager: A ResultManager instance. manager
devices: List of device names to include. None to select all devices. A ResultManager instance.
title: Title of the report. devices
List of device names to include. None to select all devices.
title
Title of the report.
Returns Returns
------- -------
Table
A fully populated rich `Table`. A fully populated rich `Table`.
""" """
table = Table(title=title, show_lines=True) table = Table(title=title, show_lines=True)
headers = [ headers = [
"Device", self.Headers.device,
"# of success", self.Headers.number_of_success,
"# of skipped", self.Headers.number_of_skipped,
"# of failure", self.Headers.number_of_failure,
"# of errors", self.Headers.number_of_errors,
"List of failed or error test cases", self.Headers.list_of_error_tests,
] ]
table = self._build_headers(headers=headers, table=table) table = self._build_headers(headers=headers, table=table)
for device in manager.get_devices(): for device, stats in sorted(manager.device_stats.items()):
if devices is None or device in devices: if devices is None or device in devices:
results = manager.filter_by_devices({device}).results
nb_failure = len([result for result in results if result.result == "failure"])
nb_error = len([result for result in results if result.result == "error"])
list_failure = [result.test for result in results if result.result in ["failure", "error"]]
nb_success = len([result for result in results if result.result == "success"])
nb_skipped = len([result for result in results if result.result == "skipped"])
table.add_row( table.add_row(
device, device,
str(nb_success), str(stats.tests_success_count),
str(nb_skipped), str(stats.tests_skipped_count),
str(nb_failure), str(stats.tests_failure_count),
str(nb_error), str(stats.tests_error_count),
str(list_failure), ", ".join(stats.tests_failure),
) )
return table return table
@ -227,6 +244,9 @@ class ReportJinja:
Report is built based on a J2 template provided by user. Report is built based on a J2 template provided by user.
Data structure sent to template is: Data structure sent to template is:
Example
-------
```
>>> print(ResultManager.json) >>> print(ResultManager.json)
[ [
{ {
@ -238,15 +258,20 @@ class ReportJinja:
description: ..., description: ...,
} }
] ]
```
Args: Parameters
---- ----------
data: List of results from ResultManager.results data
trim_blocks: enable trim_blocks for J2 rendering. List of results from `ResultManager.results`.
lstrip_blocks: enable lstrip_blocks for J2 rendering. trim_blocks
enable trim_blocks for J2 rendering.
lstrip_blocks
enable lstrip_blocks for J2 rendering.
Returns Returns
------- -------
str
Rendered template Rendered template
""" """

View file

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

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

@ -6,16 +6,20 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import TYPE_CHECKING import logging
from collections import defaultdict
from functools import cached_property
from itertools import chain
from typing import Any
from pydantic import TypeAdapter from anta.result_manager.models import AntaTestStatus, TestResult
from anta.custom_types import TestStatus from .models import CategoryStats, DeviceStats, TestStats
if TYPE_CHECKING: logger = logging.getLogger(__name__)
from anta.result_manager.models import TestResult
# pylint: disable=too-many-instance-attributes
class ResultManager: class ResultManager:
"""Helper to manage Test Results and generate reports. """Helper to manage Test Results and generate reports.
@ -69,6 +73,15 @@ class ResultManager:
] ]
""" """
_result_entries: list[TestResult]
status: AntaTestStatus
error_status: bool
_device_stats: defaultdict[str, DeviceStats]
_category_stats: defaultdict[str, CategoryStats]
_test_stats: defaultdict[str, TestStats]
_stats_in_sync: bool
def __init__(self) -> None: def __init__(self) -> None:
"""Class constructor. """Class constructor.
@ -90,10 +103,17 @@ class ResultManager:
If the status of the added test is error, the status is untouched and the If the status of the added test is error, the status is untouched and the
error_status is set to True. error_status is set to True.
""" """
self.reset()
def reset(self) -> None:
"""Create or reset the attributes of the ResultManager instance."""
self._result_entries: list[TestResult] = [] self._result_entries: list[TestResult] = []
self.status: TestStatus = "unset" self.status: AntaTestStatus = AntaTestStatus.UNSET
self.error_status = False self.error_status = False
# Initialize the statistics attributes
self._reset_stats()
def __len__(self) -> int: def __len__(self) -> int:
"""Implement __len__ method to count number of results.""" """Implement __len__ method to count number of results."""
return len(self._result_entries) return len(self._result_entries)
@ -105,67 +125,226 @@ class ResultManager:
@results.setter @results.setter
def results(self, value: list[TestResult]) -> None: def results(self, value: list[TestResult]) -> None:
self._result_entries = [] """Set the list of TestResult."""
self.status = "unset" # When setting the results, we need to reset the state of the current instance
self.error_status = False self.reset()
for e in value:
self.add(e) 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 @property
def json(self) -> str: def json(self) -> str:
"""Get a JSON representation of the results.""" """Get a JSON representation of the results."""
return json.dumps([result.model_dump() for result in self._result_entries], indent=4) return json.dumps(self.dump, indent=4)
@property
def device_stats(self) -> 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.
"""
if test_status == "error":
self.error_status = True
return
if self.status == "unset" or (self.status == "skipped" and test_status in {"success", "failure"}):
self.status = test_status
elif self.status == "success" and test_status == "failure":
self.status = AntaTestStatus.FAILURE
def _reset_stats(self) -> None:
"""Create or reset the statistics attributes."""
self._device_stats = defaultdict(DeviceStats)
self._category_stats = defaultdict(CategoryStats)
self._test_stats = defaultdict(TestStats)
self._stats_in_sync = False
def _update_stats(self, result: TestResult) -> None:
"""Update the statistics based on the test result.
Parameters
----------
result
TestResult to update the statistics.
"""
count_attr = f"tests_{result.result}_count"
# Update device stats
device_stats: DeviceStats = self._device_stats[result.name]
setattr(device_stats, count_attr, getattr(device_stats, count_attr) + 1)
if result.result in ("failure", "error"):
device_stats.tests_failure.add(result.test)
device_stats.categories_failed.update(result.categories)
elif result.result == "skipped":
device_stats.categories_skipped.update(result.categories)
# Update category stats
for category in result.categories:
category_stats: CategoryStats = self._category_stats[category]
setattr(category_stats, count_attr, getattr(category_stats, count_attr) + 1)
# Update test stats
count_attr = f"devices_{result.result}_count"
test_stats: TestStats = self._test_stats[result.test]
setattr(test_stats, count_attr, getattr(test_stats, count_attr) + 1)
if result.result in ("failure", "error"):
test_stats.devices_failure.add(result.name)
def _compute_stats(self) -> None:
"""Compute all statistics from the current results."""
logger.info("Computing statistics for all results.")
# Reset all stats
self._reset_stats()
# Recompute stats for all results
for result in self._result_entries:
self._update_stats(result)
self._stats_in_sync = True
def _ensure_stats_in_sync(self) -> None:
"""Ensure statistics are in sync with current results."""
if not self._stats_in_sync:
self._compute_stats()
def add(self, result: TestResult) -> None: def add(self, result: TestResult) -> None:
"""Add a result to the ResultManager instance. """Add a result to the ResultManager instance.
Args: 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.
result: TestResult to add to the ResultManager instance.
Parameters
----------
result
TestResult to add to the ResultManager instance.
""" """
def _update_status(test_status: TestStatus) -> None:
result_validator = TypeAdapter(TestStatus)
result_validator.validate_python(test_status)
if test_status == "error":
self.error_status = True
return
if self.status == "unset" or self.status == "skipped" and test_status in {"success", "failure"}:
self.status = test_status
elif self.status == "success" and test_status == "failure":
self.status = "failure"
self._result_entries.append(result) self._result_entries.append(result)
_update_status(result.result) self._update_status(result.result)
self._stats_in_sync = False
# 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.
"""
# 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.
"""
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: def get_status(self, *, ignore_error: bool = False) -> str:
"""Return the current status including error_status if ignore_error is False.""" """Return the current status including error_status if ignore_error is False."""
return "error" if self.error_status and not ignore_error else self.status return "error" if self.error_status and not ignore_error else self.status
def filter(self, hide: set[TestStatus]) -> ResultManager: def filter(self, hide: set[AntaTestStatus]) -> ResultManager:
"""Get a filtered ResultManager based on test status. """Get a filtered ResultManager based on test status.
Args: Parameters
---- ----------
hide: set of TestStatus literals to select tests to hide based on their status. hide
Set of AntaTestStatus enum members to select tests to hide based on their status.
Returns Returns
------- -------
ResultManager
A filtered `ResultManager`. A filtered `ResultManager`.
""" """
possible_statuses = set(AntaTestStatus)
manager = ResultManager() manager = ResultManager()
manager.results = [test for test in self._result_entries if test.result not in hide] manager.results = self.get_results(possible_statuses - hide)
return manager return manager
def filter_by_tests(self, tests: set[str]) -> ResultManager: def filter_by_tests(self, tests: set[str]) -> ResultManager:
"""Get a filtered ResultManager that only contains specific tests. """Get a filtered ResultManager that only contains specific tests.
Args: Parameters
---- ----------
tests: Set of test names to filter the results. tests
Set of test names to filter the results.
Returns Returns
------- -------
ResultManager
A filtered `ResultManager`. A filtered `ResultManager`.
""" """
manager = ResultManager() manager = ResultManager()
@ -175,12 +354,14 @@ class ResultManager:
def filter_by_devices(self, devices: set[str]) -> ResultManager: def filter_by_devices(self, devices: set[str]) -> ResultManager:
"""Get a filtered ResultManager that only contains specific devices. """Get a filtered ResultManager that only contains specific devices.
Args: Parameters
---- ----------
devices: Set of device names to filter the results. devices
Set of device names to filter the results.
Returns Returns
------- -------
ResultManager
A filtered `ResultManager`. A filtered `ResultManager`.
""" """
manager = ResultManager() manager = ResultManager()
@ -192,6 +373,7 @@ class ResultManager:
Returns Returns
------- -------
set[str]
Set of test names. Set of test names.
""" """
return {str(result.test) for result in self._result_entries} return {str(result.test) for result in self._result_entries}
@ -201,6 +383,7 @@ class ResultManager:
Returns Returns
------- -------
set[str]
Set of device names. Set of device names.
""" """
return {str(result.name) for result in self._result_entries} return {str(result.name) for result in self._result_entries}

View file

@ -5,9 +5,27 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from pydantic import BaseModel from pydantic import BaseModel
from anta.custom_types import TestStatus
class AntaTestStatus(str, Enum):
"""Test status Enum for the TestResult.
NOTE: This could be updated to StrEnum when Python 3.11 is the minimum supported version in ANTA.
"""
UNSET = "unset"
SUCCESS = "success"
FAILURE = "failure"
ERROR = "error"
SKIPPED = "skipped"
def __str__(self) -> str:
"""Override the __str__ method to return the value of the Enum, mimicking the behavior of StrEnum."""
return self.value
class TestResult(BaseModel): class TestResult(BaseModel):
@ -15,13 +33,20 @@ class TestResult(BaseModel):
Attributes Attributes
---------- ----------
name: Device name where the test has run. name : str
test: Test name runs on the device. Name of the device where the test was run.
categories: List of categories the TestResult belongs to, by default the AntaTest categories. test : str
description: TestResult description, by default the AntaTest description. Name of the test run on the device.
result: Result of the test. Can be one of "unset", "success", "failure", "error" or "skipped". categories : list[str]
messages: Message to report after the test if any. List of categories the TestResult belongs to. Defaults to the AntaTest categories.
custom_field: Custom field to store a string for flexibility in integrating with ANTA description : str
Description of the TestResult. Defaults to the AntaTest description.
result : AntaTestStatus
Result of the test. Must be one of the AntaTestStatus Enum values: unset, success, failure, error or skipped.
messages : list[str]
Messages to report after the test, if any.
custom_field : str | None
Custom field to store a string for flexibility in integrating with ANTA.
""" """
@ -29,57 +54,63 @@ class TestResult(BaseModel):
test: str test: str
categories: list[str] categories: list[str]
description: str description: str
result: TestStatus = "unset" result: AntaTestStatus = AntaTestStatus.UNSET
messages: list[str] = [] messages: list[str] = []
custom_field: str | None = None custom_field: str | None = None
def is_success(self, message: str | None = None) -> None: def is_success(self, message: str | None = None) -> None:
"""Set status to success. """Set status to success.
Args: Parameters
---- ----------
message: Optional message related to the test message
Optional message related to the test.
""" """
self._set_status("success", message) self._set_status(AntaTestStatus.SUCCESS, message)
def is_failure(self, message: str | None = None) -> None: def is_failure(self, message: str | None = None) -> None:
"""Set status to failure. """Set status to failure.
Args: Parameters
---- ----------
message: Optional message related to the test message
Optional message related to the test.
""" """
self._set_status("failure", message) self._set_status(AntaTestStatus.FAILURE, message)
def is_skipped(self, message: str | None = None) -> None: def is_skipped(self, message: str | None = None) -> None:
"""Set status to skipped. """Set status to skipped.
Args: Parameters
---- ----------
message: Optional message related to the test message
Optional message related to the test.
""" """
self._set_status("skipped", message) self._set_status(AntaTestStatus.SKIPPED, message)
def is_error(self, message: str | None = None) -> None: def is_error(self, message: str | None = None) -> None:
"""Set status to error. """Set status to error.
Args: Parameters
---- ----------
message: Optional message related to the test message
Optional message related to the test.
""" """
self._set_status("error", message) self._set_status(AntaTestStatus.ERROR, message)
def _set_status(self, status: TestStatus, message: str | None = None) -> None: def _set_status(self, status: AntaTestStatus, message: str | None = None) -> None:
"""Set status and insert optional message. """Set status and insert optional message.
Args: Parameters
---- ----------
status: status of the test status
message: optional message Status of the test.
message
Optional message.
""" """
self.result = status self.result = status
@ -89,3 +120,42 @@ class TestResult(BaseModel):
def __str__(self) -> str: def __str__(self) -> str:
"""Return a human readable string of this TestResult.""" """Return a human readable string of this TestResult."""
return f"Test '{self.test}' (on '{self.name}'): Result '{self.result}'\nMessages: {self.messages}" return f"Test '{self.test}' (on '{self.name}'): Result '{self.result}'\nMessages: {self.messages}"
# Pylint does not treat dataclasses differently: https://github.com/pylint-dev/pylint/issues/9058
# pylint: disable=too-many-instance-attributes
@dataclass
class DeviceStats:
"""Device statistics for a run of tests."""
tests_success_count: int = 0
tests_skipped_count: int = 0
tests_failure_count: int = 0
tests_error_count: int = 0
tests_unset_count: int = 0
tests_failure: set[str] = field(default_factory=set)
categories_failed: set[str] = field(default_factory=set)
categories_skipped: set[str] = field(default_factory=set)
@dataclass
class CategoryStats:
"""Category statistics for a run of tests."""
tests_success_count: int = 0
tests_skipped_count: int = 0
tests_failure_count: int = 0
tests_error_count: int = 0
tests_unset_count: int = 0
@dataclass
class TestStats:
"""Test statistics for a run of tests."""
devices_success_count: int = 0
devices_skipped_count: int = 0
devices_failure_count: int = 0
devices_error_count: int = 0
devices_unset_count: int = 0
devices_failure: set[str] = field(default_factory=set)

View file

@ -8,7 +8,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os import os
import resource import sys
from collections import defaultdict from collections import defaultdict
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@ -26,11 +26,11 @@ if TYPE_CHECKING:
from anta.result_manager import ResultManager from anta.result_manager import ResultManager
from anta.result_manager.models import TestResult from anta.result_manager.models import TestResult
logger = logging.getLogger(__name__) if os.name == "posix":
import resource
DEFAULT_NOFILE = 16384 DEFAULT_NOFILE = 16384
def adjust_rlimit_nofile() -> tuple[int, int]: def adjust_rlimit_nofile() -> tuple[int, int]:
"""Adjust the maximum number of open file descriptors for the ANTA process. """Adjust the maximum number of open file descriptors for the ANTA process.
@ -40,7 +40,8 @@ def adjust_rlimit_nofile() -> tuple[int, int]:
Returns Returns
------- -------
tuple[int, int]: The new soft and hard limits for open file descriptors. tuple[int, int]
The new soft and hard limits for open file descriptors.
""" """
try: try:
nofile = int(os.environ.get("ANTA_NOFILE", DEFAULT_NOFILE)) nofile = int(os.environ.get("ANTA_NOFILE", DEFAULT_NOFILE))
@ -50,18 +51,22 @@ def adjust_rlimit_nofile() -> tuple[int, int]:
limits = resource.getrlimit(resource.RLIMIT_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]) logger.debug("Initial limit numbers for open file descriptors for the current ANTA process: Soft Limit: %s | Hard Limit: %s", limits[0], limits[1])
nofile = nofile if limits[1] > nofile else limits[1] nofile = min(limits[1], nofile)
logger.debug("Setting soft limit for open file descriptors for the current ANTA process to %s", 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])) resource.setrlimit(resource.RLIMIT_NOFILE, (nofile, limits[1]))
return resource.getrlimit(resource.RLIMIT_NOFILE) return resource.getrlimit(resource.RLIMIT_NOFILE)
logger = logging.getLogger(__name__)
def log_cache_statistics(devices: list[AntaDevice]) -> None: def log_cache_statistics(devices: list[AntaDevice]) -> None:
"""Log cache statistics for each device in the inventory. """Log cache statistics for each device in the inventory.
Args: Parameters
---- ----------
devices: List of devices in the inventory. devices
List of devices in the inventory.
""" """
for device in devices: for device in devices:
if device.cache_statistics is not None: if device.cache_statistics is not None:
@ -78,15 +83,21 @@ def log_cache_statistics(devices: list[AntaDevice]) -> None:
async def setup_inventory(inventory: AntaInventory, tags: set[str] | None, devices: set[str] | None, *, established_only: bool) -> AntaInventory | None: 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. """Set up the inventory for the ANTA run.
Args: Parameters
---- ----------
inventory: AntaInventory object that includes the device(s). inventory
tags: Tags to filter devices from the inventory. AntaInventory object that includes the device(s).
devices: Devices on which to run tests. None means all devices. 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 Returns
------- -------
AntaInventory | None: The filtered inventory or None if there are no devices to run tests on. AntaInventory | None
The filtered inventory or None if there are no devices to run tests on.
""" """
if len(inventory) == 0: if len(inventory) == 0:
logger.info("The inventory is empty, exiting") logger.info("The inventory is empty, exiting")
@ -116,15 +127,20 @@ def prepare_tests(
) -> defaultdict[AntaDevice, set[AntaTestDefinition]] | None: ) -> defaultdict[AntaDevice, set[AntaTestDefinition]] | None:
"""Prepare the tests to run. """Prepare the tests to run.
Args: Parameters
---- ----------
inventory: AntaInventory object that includes the device(s). inventory
catalog: AntaCatalog object that includes the list of tests. AntaInventory object that includes the device(s).
tests: Tests to run against devices. None means all tests. catalog
tags: Tags to filter devices from the inventory. 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 Returns
------- -------
defaultdict[AntaDevice, set[AntaTestDefinition]] | None
A mapping of devices to the tests to run or None if there are no tests to run. 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 # Build indexes for the catalog. If `tests` is set, filter the indexes based on these tests
@ -133,11 +149,16 @@ def prepare_tests(
# Using a set to avoid inserting duplicate tests # Using a set to avoid inserting duplicate tests
device_to_tests: defaultdict[AntaDevice, set[AntaTestDefinition]] = defaultdict(set) device_to_tests: defaultdict[AntaDevice, set[AntaTestDefinition]] = defaultdict(set)
# Create AntaTestRunner tuples from the tags total_test_count = 0
# Create the device to tests mapping from the tags
for device in inventory.devices: for device in inventory.devices:
if tags: if tags:
# If there are CLI tags, only execute tests with matching tags # If there are CLI tags, execute tests with matching tags for this device
device_to_tests[device].update(catalog.get_tests_by_tags(tags)) 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: else:
# If there is no CLI tags, execute all tests that do not have any tags # 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]) device_to_tests[device].update(catalog.tag_to_tests[None])
@ -145,11 +166,12 @@ def prepare_tests(
# Then add the tests with matching tags from device tags # Then add the tests with matching tags from device tags
device_to_tests[device].update(catalog.get_tests_by_tags(device.tags)) device_to_tests[device].update(catalog.get_tests_by_tags(device.tags))
catalog.final_tests_count += len(device_to_tests[device]) total_test_count += len(device_to_tests[device])
if catalog.final_tests_count == 0: if total_test_count == 0:
msg = ( 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." 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) logger.warning(msg)
return None return None
@ -157,15 +179,19 @@ def prepare_tests(
return device_to_tests return device_to_tests
def get_coroutines(selected_tests: defaultdict[AntaDevice, set[AntaTestDefinition]]) -> list[Coroutine[Any, Any, TestResult]]: 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. """Get the coroutines for the ANTA run.
Args: Parameters
---- ----------
selected_tests: A mapping of devices to the tests to run. The selected tests are generated by the `prepare_tests` function. 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 Returns
------- -------
list[Coroutine[Any, Any, TestResult]]
The list of coroutines to run. The list of coroutines to run.
""" """
coros = [] coros = []
@ -173,13 +199,15 @@ def get_coroutines(selected_tests: defaultdict[AntaDevice, set[AntaTestDefinitio
for test in test_definitions: for test in test_definitions:
try: try:
test_instance = test.test(device=device, inputs=test.inputs) test_instance = test.test(device=device, inputs=test.inputs)
if manager is not None:
manager.add(test_instance.result)
coros.append(test_instance.test()) coros.append(test_instance.test())
except Exception as e: # noqa: PERF203, pylint: disable=broad-exception-caught except Exception as e: # noqa: PERF203, BLE001
# An AntaTest instance is potentially user-defined code. # An AntaTest instance is potentially user-defined code.
# We need to catch everything and exit gracefully with an error message. # We need to catch everything and exit gracefully with an error message.
message = "\n".join( message = "\n".join(
[ [
f"There is an error when creating test {test.test.module}.{test.test.__name__}.", 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}", f"If this is not a custom test implementation: {GITHUB_SUGGESTION}",
], ],
) )
@ -188,7 +216,7 @@ def get_coroutines(selected_tests: defaultdict[AntaDevice, set[AntaTestDefinitio
@cprofile() @cprofile()
async def main( # noqa: PLR0913 async def main(
manager: ResultManager, manager: ResultManager,
inventory: AntaInventory, inventory: AntaInventory,
catalog: AntaCatalog, catalog: AntaCatalog,
@ -199,26 +227,30 @@ async def main( # noqa: PLR0913
established_only: bool = True, established_only: bool = True,
dry_run: bool = False, dry_run: bool = False,
) -> None: ) -> None:
# pylint: disable=too-many-arguments
"""Run ANTA. """Run ANTA.
Use this as an entrypoint to the test framework in your script. Use this as an entrypoint to the test framework in your script.
ResultManager object gets updated with the test results. ResultManager object gets updated with the test results.
Args: Parameters
---- ----------
manager: ResultManager object to populate with the test results. manager
inventory: AntaInventory object that includes the device(s). ResultManager object to populate with the test results.
catalog: AntaCatalog object that includes the list of tests. inventory
devices: Devices on which to run tests. None means all devices. These may come from the `--device / -d` CLI option in NRFU. AntaInventory object that includes the device(s).
tests: Tests to run against devices. None means all tests. These may come from the `--test / -t` CLI option in NRFU. catalog
tags: Tags to filter devices from the inventory. These may come from the `--tags` CLI option in NRFU. AntaCatalog object that includes the list of tests.
established_only: Include only established device(s). devices
dry_run: Build the list of coroutine to run and stop before test execution. 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.
""" """
# Adjust the maximum number of open file descriptors for the ANTA process
limits = adjust_rlimit_nofile()
if not catalog.tests: if not catalog.tests:
logger.info("The list of tests is empty, exiting") logger.info("The list of tests is empty, exiting")
return return
@ -233,25 +265,35 @@ async def main( # noqa: PLR0913
selected_tests = prepare_tests(selected_inventory, catalog, tests, tags) selected_tests = prepare_tests(selected_inventory, catalog, tests, tags)
if selected_tests is None: if selected_tests is None:
return return
final_tests_count = sum(len(tests) for tests in selected_tests.values())
run_info = ( run_info = (
"--- ANTA NRFU Run Information ---\n" "--- ANTA NRFU Run Information ---\n"
f"Number of devices: {len(inventory)} ({len(selected_inventory)} established)\n" f"Number of devices: {len(inventory)} ({len(selected_inventory)} established)\n"
f"Total number of selected tests: {catalog.final_tests_count}\n" f"Total number of selected tests: {final_tests_count}\n"
f"Maximum number of open file descriptors for the current ANTA process: {limits[0]}\n"
"---------------------------------"
) )
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"
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"
run_info += "---------------------------------"
logger.info(run_info) logger.info(run_info)
if catalog.final_tests_count > limits[0]: if final_tests_count > limits[0]:
logger.warning( logger.warning(
"The number of concurrent tests is higher than the open file descriptors limit for this ANTA process.\n" "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" "Errors may occur while running the tests.\n"
"Please consult the ANTA FAQ." "Please consult the ANTA FAQ."
) )
coroutines = get_coroutines(selected_tests) coroutines = get_coroutines(selected_tests, manager if dry_run else None)
if dry_run: if dry_run:
logger.info("Dry-run mode, exiting before running the tests.") logger.info("Dry-run mode, exiting before running the tests.")
@ -263,8 +305,8 @@ async def main( # noqa: PLR0913
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(coroutines))
with Catchtime(logger=logger, message="Running ANTA tests"): with Catchtime(logger=logger, message="Running ANTA tests"):
test_results = await asyncio.gather(*coroutines) results = await asyncio.gather(*coroutines)
for r in test_results: for result in results:
manager.add(r) manager.add(result)
log_cache_statistics(selected_inventory.devices) log_cache_statistics(selected_inventory.devices)

View file

@ -35,8 +35,6 @@ class VerifyTacacsSourceIntf(AntaTest):
``` ```
""" """
name = "VerifyTacacsSourceIntf"
description = "Verifies TACACS source-interface for a specified VRF."
categories: ClassVar[list[str]] = ["aaa"] categories: ClassVar[list[str]] = ["aaa"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)]
@ -81,8 +79,6 @@ class VerifyTacacsServers(AntaTest):
``` ```
""" """
name = "VerifyTacacsServers"
description = "Verifies TACACS servers are configured for a specified VRF."
categories: ClassVar[list[str]] = ["aaa"] categories: ClassVar[list[str]] = ["aaa"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)]
@ -134,8 +130,6 @@ class VerifyTacacsServerGroups(AntaTest):
``` ```
""" """
name = "VerifyTacacsServerGroups"
description = "Verifies if the provided TACACS server group(s) are configured."
categories: ClassVar[list[str]] = ["aaa"] categories: ClassVar[list[str]] = ["aaa"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)]
@ -184,8 +178,6 @@ class VerifyAuthenMethods(AntaTest):
``` ```
""" """
name = "VerifyAuthenMethods"
description = "Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x)."
categories: ClassVar[list[str]] = ["aaa"] categories: ClassVar[list[str]] = ["aaa"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authentication", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authentication", revision=1)]
@ -245,8 +237,6 @@ class VerifyAuthzMethods(AntaTest):
``` ```
""" """
name = "VerifyAuthzMethods"
description = "Verifies the AAA authorization method lists for different authorization types (commands, exec)."
categories: ClassVar[list[str]] = ["aaa"] categories: ClassVar[list[str]] = ["aaa"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authorization", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authorization", revision=1)]
@ -301,8 +291,6 @@ class VerifyAcctDefaultMethods(AntaTest):
``` ```
""" """
name = "VerifyAcctDefaultMethods"
description = "Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x)."
categories: ClassVar[list[str]] = ["aaa"] categories: ClassVar[list[str]] = ["aaa"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)]
@ -364,8 +352,6 @@ class VerifyAcctConsoleMethods(AntaTest):
``` ```
""" """
name = "VerifyAcctConsoleMethods"
description = "Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x)."
categories: ClassVar[list[str]] = ["aaa"] categories: ClassVar[list[str]] = ["aaa"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)]

View file

@ -7,19 +7,16 @@
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from ipaddress import IPv4Address
from typing import ClassVar from typing import ClassVar
from pydantic import BaseModel
from anta.decorators import skip_on_platforms from anta.decorators import skip_on_platforms
from anta.input_models.avt import AVTPath
from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_value from anta.tools import get_value
class VerifyAVTPathHealth(AntaTest): class VerifyAVTPathHealth(AntaTest):
""" """Verifies the status of all Adaptive Virtual Topology (AVT) paths for all VRFs.
Verifies the status of all Adaptive Virtual Topology (AVT) paths for all VRFs.
Expected Results Expected Results
---------------- ----------------
@ -34,7 +31,6 @@ class VerifyAVTPathHealth(AntaTest):
``` ```
""" """
name = "VerifyAVTPathHealth"
description = "Verifies the status of all AVT paths for all VRFs." description = "Verifies the status of all AVT paths for all VRFs."
categories: ClassVar[list[str]] = ["avt"] categories: ClassVar[list[str]] = ["avt"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show adaptive-virtual-topology path")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show adaptive-virtual-topology path")]
@ -73,15 +69,22 @@ class VerifyAVTPathHealth(AntaTest):
class VerifyAVTSpecificPath(AntaTest): class VerifyAVTSpecificPath(AntaTest):
""" """Verifies the Adaptive Virtual Topology (AVT) path.
Verifies the status and type of an Adaptive Virtual Topology (AVT) path for a specified VRF.
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 Expected Results
---------------- ----------------
* Success: The test will pass if all AVT paths for the specified VRF are active, valid, and match the specified type (direct/multihop) if provided. * Success: The test will pass if all of the following conditions are met:
If multiple paths are configured, the test will pass only if all the paths are valid and active. - All AVT paths for the specified VRF are active, valid, and match the specified path type (direct/multihop), if provided.
* Failure: The test will fail if no AVT paths are configured for the specified VRF, or if any configured path is not active, valid, - If multiple paths are configured, the test will pass only if all paths meet these criteria.
or does not match the specified type. * 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 Examples
-------- --------
@ -97,36 +100,16 @@ class VerifyAVTSpecificPath(AntaTest):
``` ```
""" """
name = "VerifyAVTSpecificPath"
description = "Verifies the status and type of an AVT path for a specified VRF."
categories: ClassVar[list[str]] = ["avt"] categories: ClassVar[list[str]] = ["avt"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show adaptive-virtual-topology path", revision=1)]
AntaTemplate(template="show adaptive-virtual-topology path vrf {vrf} avt {avt_name} destination {destination}")
]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyAVTSpecificPath test.""" """Input model for the VerifyAVTSpecificPath test."""
avt_paths: list[AVTPaths] avt_paths: list[AVTPath]
"""List of AVT paths to verify.""" """List of AVT paths to verify."""
AVTPaths: ClassVar[type[AVTPath]] = AVTPath
class AVTPaths(BaseModel): """To maintain backward compatibility."""
"""Model for the details of AVT paths."""
vrf: str = "default"
"""The VRF for the AVT path. Defaults to 'default' if not provided."""
avt_name: str
"""Name of the adaptive virtual topology."""
destination: IPv4Address
"""The IPv4 address of the AVT peer."""
next_hop: IPv4Address
"""The IPv4 address of the next hop for the AVT peer."""
path_type: str | None = None
"""The type of the AVT path. If not provided, both 'direct' and 'multihop' paths are considered."""
def render(self, template: AntaTemplate) -> list[AntaCommand]:
"""Render the template for each input AVT path/peer."""
return [template.render(vrf=path.vrf, avt_name=path.avt_name, destination=path.destination) for path in self.inputs.avt_paths]
@skip_on_platforms(["cEOSLab", "vEOS-lab"]) @skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test @AntaTest.anta_test
@ -135,64 +118,43 @@ class VerifyAVTSpecificPath(AntaTest):
# Assume the test is successful until a failure is detected # Assume the test is successful until a failure is detected
self.result.is_success() self.result.is_success()
# Process each command in the instance command_output = self.instance_commands[0].json_output
for command, input_avt in zip(self.instance_commands, self.inputs.avt_paths): for avt_path in self.inputs.avt_paths:
# Extract the command output and parameters if (path_output := get_value(command_output, f"vrfs.{avt_path.vrf}.avts.{avt_path.avt_name}.avtPaths")) is None:
vrf = command.params.vrf self.result.is_failure(f"{avt_path} - No AVT path configured")
avt_name = command.params.avt_name return
peer = str(command.params.destination)
command_output = command.json_output.get("vrfs", {}) path_found = path_type_found = False
# If no AVT is configured, mark the test as failed and skip to the next command
if not command_output:
self.result.is_failure(f"AVT configuration for peer '{peer}' under topology '{avt_name}' in VRF '{vrf}' is not found.")
continue
# Extract the AVT paths
avt_paths = get_value(command_output, f"{vrf}.avts.{avt_name}.avtPaths")
next_hop, input_path_type = str(input_avt.next_hop), input_avt.path_type
nexthop_path_found = path_type_found = False
# Check each AVT path # Check each AVT path
for path, path_data in avt_paths.items(): for path, path_data in path_output.items():
# If the path does not match the expected next hop, skip to the next path dest = path_data.get("destination")
if path_data.get("nexthopAddr") != next_hop: nexthop = path_data.get("nexthopAddr")
continue
nexthop_path_found = True
path_type = "direct" if get_value(path_data, "flags.directPath") else "multihop" path_type = "direct" if get_value(path_data, "flags.directPath") else "multihop"
# If the path type does not match the expected path type, skip to the next path if not avt_path.path_type:
if input_path_type and path_type != input_path_type: path_found = all([dest == str(avt_path.destination), nexthop == str(avt_path.next_hop)])
continue
path_type_found = True 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") valid = get_value(path_data, "flags.valid")
active = get_value(path_data, "flags.active") active = get_value(path_data, "flags.active")
# Check the path status and type against the expected values
if not all([valid, active]): if not all([valid, active]):
failure_reasons = [] self.result.is_failure(f"{avt_path} - Incorrect path {path} - Valid: {valid}, Active: {active}")
if not get_value(path_data, "flags.active"):
failure_reasons.append("inactive")
if not get_value(path_data, "flags.valid"):
failure_reasons.append("invalid")
# Construct the failure message prefix
failed_log = f"AVT path '{path}' for topology '{avt_name}' in VRF '{vrf}'"
self.result.is_failure(f"{failed_log} is {', '.join(failure_reasons)}.")
# If no matching next hop or path type was found, mark the test as failed # If no matching path found, mark the test as failed
if not nexthop_path_found or not path_type_found: if not path_found:
self.result.is_failure( if avt_path.path_type and not path_type_found:
f"No '{input_path_type}' path found with next-hop address '{next_hop}' for AVT peer '{peer}' under topology '{avt_name}' in VRF '{vrf}'." 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): class VerifyAVTRole(AntaTest):
""" """Verifies the Adaptive Virtual Topology (AVT) role of a device.
Verifies the Adaptive Virtual Topology (AVT) role of a device.
Expected Results Expected Results
---------------- ----------------
@ -208,7 +170,6 @@ class VerifyAVTRole(AntaTest):
``` ```
""" """
name = "VerifyAVTRole"
description = "Verifies the AVT role of a device." description = "Verifies the AVT role of a device."
categories: ClassVar[list[str]] = ["avt"] categories: ClassVar[list[str]] = ["avt"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show adaptive-virtual-topology path")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show adaptive-virtual-topology path")]

View file

@ -8,12 +8,11 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from ipaddress import IPv4Address from typing import TYPE_CHECKING, ClassVar
from typing import TYPE_CHECKING, Any, ClassVar
from pydantic import BaseModel, Field from pydantic import Field
from anta.custom_types import BfdInterval, BfdMultiplier from anta.input_models.bfd import BFDPeer
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
from anta.tools import get_value from anta.tools import get_value
@ -22,12 +21,24 @@ if TYPE_CHECKING:
class VerifyBFDSpecificPeers(AntaTest): class VerifyBFDSpecificPeers(AntaTest):
"""Verifies if the IPv4 BFD peer's sessions are UP and remote disc is non-zero in the specified VRF. """Verifies the state of 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. For each specified BFD peer:
- Validates that the state is `up`
- Confirms that the remote discriminator identifier (disc) is non-zero.
Expected Results Expected Results
---------------- ----------------
* Success: The test will pass if IPv4 BFD peers are up and remote disc is non-zero in the specified VRF. * Success: If all of the following conditions are met:
* 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. - 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 Examples
-------- --------
@ -42,29 +53,21 @@ class VerifyBFDSpecificPeers(AntaTest):
``` ```
""" """
name = "VerifyBFDSpecificPeers"
description = "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF."
categories: ClassVar[list[str]] = ["bfd"] categories: ClassVar[list[str]] = ["bfd"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers", revision=4)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers", revision=1)]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyBFDSpecificPeers test.""" """Input model for the VerifyBFDSpecificPeers test."""
bfd_peers: list[BFDPeer] bfd_peers: list[BFDPeer]
"""List of IPv4 BFD peers.""" """List of IPv4 BFD"""
BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer
class BFDPeer(BaseModel): """To maintain backward compatibility."""
"""Model for an IPv4 BFD peer."""
peer_address: IPv4Address
"""IPv4 address of a BFD peer."""
vrf: str = "default"
"""Optional VRF for BFD peer. If not provided, it defaults to `default`."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyBFDSpecificPeers.""" """Main test function for VerifyBFDSpecificPeers."""
failures: dict[Any, Any] = {} self.result.is_success()
# Iterating over BFD peers # Iterating over BFD peers
for bfd_peer in self.inputs.bfd_peers: for bfd_peer in self.inputs.bfd_peers:
@ -78,31 +81,33 @@ class VerifyBFDSpecificPeers(AntaTest):
# Check if BFD peer configured # Check if BFD peer configured
if not bfd_output: if not bfd_output:
failures[peer] = {vrf: "Not Configured"} self.result.is_failure(f"{bfd_peer} - Not found")
continue continue
# Check BFD peer status and remote disc # Check BFD peer status and remote disc
if not (bfd_output.get("status") == "up" and bfd_output.get("remoteDisc") != 0): state = bfd_output.get("status")
failures[peer] = { remote_disc = bfd_output.get("remoteDisc")
vrf: { if not (state == "up" and remote_disc != 0):
"status": bfd_output.get("status"), self.result.is_failure(f"{bfd_peer} - Session not properly established - State: {state} Remote Discriminator: {remote_disc}")
"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): class VerifyBFDPeersIntervals(AntaTest):
"""Verifies the timers of the IPv4 BFD peers in the specified VRF. """Verifies the timers 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 `Transmit interval, Receive interval and Multiplier`.
Expected Results Expected Results
---------------- ----------------
* Success: The test will pass if the timers of the IPv4 BFD peers are correct in the specified VRF. * Success: If all of the following conditions are met:
* Failure: The test will fail if the IPv4 BFD peers are not found or their timers are incorrect in the specified VRF. - 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 Examples
-------- --------
@ -123,91 +128,73 @@ class VerifyBFDPeersIntervals(AntaTest):
``` ```
""" """
name = "VerifyBFDPeersIntervals"
description = "Verifies the timers of the IPv4 BFD peers in the specified VRF."
categories: ClassVar[list[str]] = ["bfd"] categories: ClassVar[list[str]] = ["bfd"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=4)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=1)]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyBFDPeersIntervals test.""" """Input model for the VerifyBFDPeersIntervals test."""
bfd_peers: list[BFDPeer] bfd_peers: list[BFDPeer]
"""List of BFD peers.""" """List of IPv4 BFD"""
BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer
class BFDPeer(BaseModel): """To maintain backward compatibility"""
"""Model for an IPv4 BFD peer."""
peer_address: IPv4Address
"""IPv4 address of a BFD peer."""
vrf: str = "default"
"""Optional VRF for BFD peer. If not provided, it defaults to `default`."""
tx_interval: BfdInterval
"""Tx interval of BFD peer in milliseconds."""
rx_interval: BfdInterval
"""Rx interval of BFD peer in milliseconds."""
multiplier: BfdMultiplier
"""Multiplier of BFD peer."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyBFDPeersIntervals.""" """Main test function for VerifyBFDPeersIntervals."""
failures: dict[Any, Any] = {} self.result.is_success()
# Iterating over BFD peers # Iterating over BFD peers
for bfd_peers in self.inputs.bfd_peers: for bfd_peer in self.inputs.bfd_peers:
peer = str(bfd_peers.peer_address) peer = str(bfd_peer.peer_address)
vrf = bfd_peers.vrf vrf = bfd_peer.vrf
tx_interval = bfd_peer.tx_interval
rx_interval = bfd_peer.rx_interval
multiplier = bfd_peer.multiplier
# Converting milliseconds intervals into actual value # Check if BFD peer configured
tx_interval = bfd_peers.tx_interval * 1000
rx_interval = bfd_peers.rx_interval * 1000
multiplier = bfd_peers.multiplier
bfd_output = get_value( bfd_output = get_value(
self.instance_commands[0].json_output, self.instance_commands[0].json_output,
f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..", f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..",
separator="..", separator="..",
) )
# Check if BFD peer configured
if not bfd_output: if not bfd_output:
failures[peer] = {vrf: "Not Configured"} self.result.is_failure(f"{bfd_peer} - Not found")
continue continue
# Convert interval timer(s) into milliseconds to be consistent with the inputs.
bfd_details = bfd_output.get("peerStatsDetail", {}) bfd_details = bfd_output.get("peerStatsDetail", {})
intervals_ok = ( op_tx_interval = bfd_details.get("operTxInterval") // 1000
bfd_details.get("operTxInterval") == tx_interval and bfd_details.get("operRxInterval") == rx_interval and bfd_details.get("detectMult") == multiplier op_rx_interval = bfd_details.get("operRxInterval") // 1000
) detect_multiplier = bfd_details.get("detectMult")
# Check timers of BFD peer if op_tx_interval != tx_interval:
if not intervals_ok: self.result.is_failure(f"{bfd_peer} - Incorrect Transmit interval - Expected: {tx_interval} Actual: {op_tx_interval}")
failures[peer] = {
vrf: {
"tx_interval": bfd_details.get("operTxInterval"),
"rx_interval": bfd_details.get("operRxInterval"),
"multiplier": bfd_details.get("detectMult"),
}
}
# Check if any failures if op_rx_interval != rx_interval:
if not failures: self.result.is_failure(f"{bfd_peer} - Incorrect Receive interval - Expected: {rx_interval} Actual: {op_rx_interval}")
self.result.is_success()
else: if detect_multiplier != multiplier:
self.result.is_failure(f"Following BFD peers are not configured or timers are not correct:\n{failures}") self.result.is_failure(f"{bfd_peer} - Incorrect Multiplier - Expected: {multiplier} Actual: {detect_multiplier}")
class VerifyBFDPeersHealth(AntaTest): class VerifyBFDPeersHealth(AntaTest):
"""Verifies the health of IPv4 BFD peers across all VRFs. """Verifies the health of IPv4 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. This test performs the following checks for BFD peers across all VRFs:
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 Expected Results
---------------- ----------------
* Success: The test will pass if all IPv4 BFD peers are up, the discriminator value of each remote system is non-zero, * Success: If all of the following conditions are met:
and the last downtime of each peer is above the defined threshold. - All BFD peers across the VRFs are up and remote disc is non-zero.
* Failure: The test will fail if any IPv4 BFD peer is down, the discriminator value of any remote system is zero, - Last downtime of each peer is above the defined threshold, if specified.
or the last downtime of any peer is below the defined threshold. * 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 Examples
-------- --------
@ -218,8 +205,6 @@ class VerifyBFDPeersHealth(AntaTest):
``` ```
""" """
name = "VerifyBFDPeersHealth"
description = "Verifies the health of all IPv4 BFD peers."
categories: ClassVar[list[str]] = ["bfd"] categories: ClassVar[list[str]] = ["bfd"]
# revision 1 as later revision introduces additional nesting for type # revision 1 as later revision introduces additional nesting for type
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
@ -236,18 +221,13 @@ class VerifyBFDPeersHealth(AntaTest):
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyBFDPeersHealth.""" """Main test function for VerifyBFDPeersHealth."""
# Initialize failure strings self.result.is_success()
down_failures = []
up_failures = []
# Extract the current timestamp and command output # Extract the current timestamp and command output
clock_output = self.instance_commands[1].json_output clock_output = self.instance_commands[1].json_output
current_timestamp = clock_output["utcTime"] current_timestamp = clock_output["utcTime"]
bfd_output = self.instance_commands[0].json_output bfd_output = self.instance_commands[0].json_output
# set the initial result
self.result.is_success()
# Check if any IPv4 BFD peer is configured # Check if any IPv4 BFD peer is configured
ipv4_neighbors_exist = any(vrf_data["ipv4Neighbors"] for vrf_data in bfd_output["vrfs"].values()) ipv4_neighbors_exist = any(vrf_data["ipv4Neighbors"] for vrf_data in bfd_output["vrfs"].values())
if not ipv4_neighbors_exist: if not ipv4_neighbors_exist:
@ -260,28 +240,88 @@ class VerifyBFDPeersHealth(AntaTest):
for peer_data in neighbor_data["peerStats"].values(): for peer_data in neighbor_data["peerStats"].values():
peer_status = peer_data["status"] peer_status = peer_data["status"]
remote_disc = peer_data["remoteDisc"] remote_disc = peer_data["remoteDisc"]
remote_disc_info = f" with remote disc {remote_disc}" if remote_disc == 0 else ""
last_down = peer_data["lastDown"] last_down = peer_data["lastDown"]
hours_difference = ( hours_difference = (
datetime.fromtimestamp(current_timestamp, tz=timezone.utc) - datetime.fromtimestamp(last_down, tz=timezone.utc) datetime.fromtimestamp(current_timestamp, tz=timezone.utc) - datetime.fromtimestamp(last_down, tz=timezone.utc)
).total_seconds() / 3600 ).total_seconds() / 3600
# Check if peer status is not up if not (peer_status == "up" and remote_disc != 0):
if peer_status != "up": self.result.is_failure(
down_failures.append(f"{peer} is {peer_status} in {vrf} VRF{remote_disc_info}.") f"Peer: {peer} VRF: {vrf} - Session not properly established - State: {peer_status} Remote Discriminator: {remote_disc}"
)
# Check if the last down is within the threshold # Check if the last down is within the threshold
elif self.inputs.down_threshold and hours_difference < self.inputs.down_threshold: if 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}.") self.result.is_failure(
f"Peer: {peer} VRF: {vrf} - Session failure detected within the expected uptime threshold ({round(hours_difference)} hours ago)"
)
# Check if remote disc is 0
elif remote_disc == 0:
up_failures.append(f"{peer} in {vrf} VRF has remote disc {remote_disc}.")
# Check if there are any failures class VerifyBFDPeersRegProtocols(AntaTest):
if down_failures: """Verifies the registered routing protocol of IPv4 BFD peer sessions.
down_failures_str = "\n".join(down_failures)
self.result.is_failure(f"Following BFD peers are not up:\n{down_failures_str}") This test performs the following checks for each specified peer:
if up_failures:
up_failures_str = "\n".join(up_failures) 1. Confirms that the specified VRF is configured.
self.result.is_failure(f"\nFollowing BFD peers were down:\n{up_failures_str}") 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")

View file

@ -33,8 +33,6 @@ class VerifyZeroTouch(AntaTest):
``` ```
""" """
name = "VerifyZeroTouch"
description = "Verifies ZeroTouch is disabled"
categories: ClassVar[list[str]] = ["configuration"] categories: ClassVar[list[str]] = ["configuration"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show zerotouch", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show zerotouch", revision=1)]
@ -64,8 +62,6 @@ class VerifyRunningConfigDiffs(AntaTest):
``` ```
""" """
name = "VerifyRunningConfigDiffs"
description = "Verifies there is no difference between the running-config and the startup-config"
categories: ClassVar[list[str]] = ["configuration"] categories: ClassVar[list[str]] = ["configuration"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config diffs", ofmt="text")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config diffs", ofmt="text")]
@ -104,7 +100,6 @@ class VerifyRunningConfigLines(AntaTest):
``` ```
""" """
name = "VerifyRunningConfigLines"
description = "Search the Running-Config for the given RegEx patterns." description = "Search the Running-Config for the given RegEx patterns."
categories: ClassVar[list[str]] = ["configuration"] categories: ClassVar[list[str]] = ["configuration"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config", ofmt="text")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config", ofmt="text")]

View file

@ -7,12 +7,9 @@
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from ipaddress import IPv4Address
from typing import ClassVar from typing import ClassVar
from pydantic import BaseModel from anta.input_models.connectivity import Host, LLDPNeighbor, Neighbor
from anta.custom_types import Interface
from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.models import AntaCommand, AntaTemplate, AntaTest
@ -33,66 +30,64 @@ class VerifyReachability(AntaTest):
- source: Management0 - source: Management0
destination: 1.1.1.1 destination: 1.1.1.1
vrf: MGMT vrf: MGMT
df_bit: True
size: 100
- source: Management0 - source: Management0
destination: 8.8.8.8 destination: 8.8.8.8
vrf: MGMT vrf: MGMT
df_bit: True
size: 100
``` ```
""" """
name = "VerifyReachability"
description = "Test the network reachability to one or many destination IP(s)."
categories: ClassVar[list[str]] = ["connectivity"] categories: ClassVar[list[str]] = ["connectivity"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="ping vrf {vrf} {destination} source {source} repeat {repeat}", revision=1)] # Template uses '{size}{df_bit}' without space since df_bit includes leading space when enabled
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaTemplate(template="ping vrf {vrf} {destination} source {source} size {size}{df_bit} repeat {repeat}", revision=1)
]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyReachability test.""" """Input model for the VerifyReachability test."""
hosts: list[Host] hosts: list[Host]
"""List of host to ping.""" """List of host to ping."""
Host: ClassVar[type[Host]] = Host
class Host(BaseModel): """To maintain backward compatibility."""
"""Model for a remote host to ping."""
destination: IPv4Address
"""IPv4 address to ping."""
source: IPv4Address | Interface
"""IPv4 address source IP or egress interface to use."""
vrf: str = "default"
"""VRF context. Defaults to `default`."""
repeat: int = 2
"""Number of ping repetition. Defaults to 2."""
def render(self, template: AntaTemplate) -> list[AntaCommand]: def render(self, template: AntaTemplate) -> list[AntaCommand]:
"""Render the template for each host in the input list.""" """Render the template for each host in the input list."""
return [template.render(destination=host.destination, source=host.source, vrf=host.vrf, repeat=host.repeat) for host in self.inputs.hosts] return [
template.render(
destination=host.destination, source=host.source, vrf=host.vrf, repeat=host.repeat, size=host.size, df_bit=" df-bit" if host.df_bit else ""
)
for host in self.inputs.hosts
]
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyReachability.""" """Main test function for VerifyReachability."""
failures = []
for command in self.instance_commands:
src = command.params.source
dst = command.params.destination
repeat = command.params.repeat
if f"{repeat} received" not in command.json_output["messages"][0]:
failures.append((str(src), str(dst)))
if not failures:
self.result.is_success() self.result.is_success()
else:
self.result.is_failure(f"Connectivity test failed for the following source-destination pairs: {failures}") 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")
class VerifyLLDPNeighbors(AntaTest): class VerifyLLDPNeighbors(AntaTest):
"""Verifies that the provided LLDP neighbors are present and connected with the correct configuration. """Verifies the connection status of the specified LLDP (Link Layer Discovery Protocol) neighbors.
This test performs the following checks for each specified LLDP neighbor:
1. Confirming matching ports on both local and neighboring devices.
2. Ensuring compatibility of device names and interface identifiers.
3. Verifying neighbor configurations match expected values per interface; extra neighbors are ignored.
Expected Results Expected Results
---------------- ----------------
* Success: The test will pass if each of the provided LLDP neighbors is present and connected to the specified port and device. * Success: The test will pass if all the provided LLDP neighbors are present and correctly connected to the specified port and device.
* Failure: The test will fail if any of the following conditions are met: * Failure: The test will fail if any of the following conditions are met:
- The provided LLDP neighbor is not found. - The provided LLDP neighbor is not found in the LLDP table.
- The system name or port of the LLDP neighbor does not match the provided information. - The system name or port of the LLDP neighbor does not match the expected information.
Examples Examples
-------- --------
@ -109,60 +104,37 @@ class VerifyLLDPNeighbors(AntaTest):
``` ```
""" """
name = "VerifyLLDPNeighbors"
description = "Verifies that the provided LLDP neighbors are connected properly."
categories: ClassVar[list[str]] = ["connectivity"] categories: ClassVar[list[str]] = ["connectivity"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lldp neighbors detail", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lldp neighbors detail", revision=1)]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyLLDPNeighbors test.""" """Input model for the VerifyLLDPNeighbors test."""
neighbors: list[Neighbor] neighbors: list[LLDPNeighbor]
"""List of LLDP neighbors.""" """List of LLDP neighbors."""
Neighbor: ClassVar[type[Neighbor]] = Neighbor
class Neighbor(BaseModel): """To maintain backward compatibility."""
"""Model for an LLDP neighbor."""
port: Interface
"""LLDP port."""
neighbor_device: str
"""LLDP neighbor device."""
neighbor_port: Interface
"""LLDP neighbor port."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyLLDPNeighbors.""" """Main test function for VerifyLLDPNeighbors."""
failures: dict[str, list[str]] = {} self.result.is_success()
output = self.instance_commands[0].json_output["lldpNeighbors"] output = self.instance_commands[0].json_output["lldpNeighbors"]
for neighbor in self.inputs.neighbors: for neighbor in self.inputs.neighbors:
if neighbor.port not in output: if neighbor.port not in output:
failures.setdefault("Port(s) not configured", []).append(neighbor.port) self.result.is_failure(f"{neighbor} - Port not found")
continue continue
if len(lldp_neighbor_info := output[neighbor.port]["lldpNeighborInfo"]) == 0: if len(lldp_neighbor_info := output[neighbor.port]["lldpNeighborInfo"]) == 0:
failures.setdefault("No LLDP neighbor(s) on port(s)", []).append(neighbor.port) self.result.is_failure(f"{neighbor} - No LLDP neighbors")
continue continue
if not any( # Check if the system name and neighbor port matches
match_found = any(
info["systemName"] == neighbor.neighbor_device and info["neighborInterfaceInfo"]["interfaceId_v2"] == neighbor.neighbor_port info["systemName"] == neighbor.neighbor_device and info["neighborInterfaceInfo"]["interfaceId_v2"] == neighbor.neighbor_port
for info in lldp_neighbor_info for info in lldp_neighbor_info
):
neighbors = "\n ".join(
[
f"{neighbor[0]}_{neighbor[1]}"
for neighbor in [(info["systemName"], info["neighborInterfaceInfo"]["interfaceId_v2"]) for info in lldp_neighbor_info]
]
) )
failures.setdefault("Wrong LLDP neighbor(s) on port(s)", []).append(f"{neighbor.port}\n {neighbors}") if not match_found:
failure_msg = [f"{info['systemName']}/{info['neighborInterfaceInfo']['interfaceId_v2']}" for info in lldp_neighbor_info]
if not failures: self.result.is_failure(f"{neighbor} - Wrong LLDP neighbors: {', '.join(failure_msg)}")
self.result.is_success()
else:
failure_messages = []
for failure_type, ports in failures.items():
ports_str = "\n ".join(ports)
failure_messages.append(f"{failure_type}:\n {ports_str}")
self.result.is_failure("\n".join(failure_messages))

283
anta/tests/cvx.py Normal file
View file

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

@ -34,7 +34,6 @@ class VerifyFieldNotice44Resolution(AntaTest):
``` ```
""" """
name = "VerifyFieldNotice44Resolution"
description = "Verifies that the device is using the correct Aboot version per FN0044." description = "Verifies that the device is using the correct Aboot version per FN0044."
categories: ClassVar[list[str]] = ["field notices"] categories: ClassVar[list[str]] = ["field notices"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)]
@ -110,15 +109,11 @@ class VerifyFieldNotice44Resolution(AntaTest):
self.result.is_success() self.result.is_success()
incorrect_aboot_version = ( incorrect_aboot_version = (
aboot_version.startswith("4.0.") (aboot_version.startswith("4.0.") and int(aboot_version.split(".")[2]) < 7)
and int(aboot_version.split(".")[2]) < 7 or (aboot_version.startswith("4.1.") and int(aboot_version.split(".")[2]) < 1)
or aboot_version.startswith("4.1.")
and int(aboot_version.split(".")[2]) < 1
or ( or (
aboot_version.startswith("6.0.") (aboot_version.startswith("6.0.") and int(aboot_version.split(".")[2]) < 9)
and int(aboot_version.split(".")[2]) < 9 or (aboot_version.startswith("6.1.") and int(aboot_version.split(".")[2]) < 7)
or aboot_version.startswith("6.1.")
and int(aboot_version.split(".")[2]) < 7
) )
) )
if incorrect_aboot_version: if incorrect_aboot_version:
@ -143,7 +138,6 @@ class VerifyFieldNotice72Resolution(AntaTest):
``` ```
""" """
name = "VerifyFieldNotice72Resolution"
description = "Verifies if the device is exposed to FN0072, and if the issue has been mitigated." description = "Verifies if the device is exposed to FN0072, and if the issue has been mitigated."
categories: ClassVar[list[str]] = ["field notices"] categories: ClassVar[list[str]] = ["field notices"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)]
@ -196,4 +190,4 @@ class VerifyFieldNotice72Resolution(AntaTest):
self.result.is_success("FN72 is mitigated") self.result.is_success("FN72 is mitigated")
return return
# We should never hit this point # We should never hit this point
self.result.is_error("Error in running test - FixedSystemvrm1 not found") self.result.is_failure("Error in running test - Component FixedSystemvrm1 not found in 'show version'")

192
anta/tests/flow_tracking.py Normal file
View file

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

@ -25,11 +25,11 @@ class VerifyGreenTCounters(AntaTest):
-------- --------
```yaml ```yaml
anta.tests.greent: anta.tests.greent:
- VerifyGreenT: - VerifyGreenTCounters:
``` ```
""" """
name = "VerifyGreenTCounters"
description = "Verifies if the GreenT counters are incremented." description = "Verifies if the GreenT counters are incremented."
categories: ClassVar[list[str]] = ["greent"] categories: ClassVar[list[str]] = ["greent"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard counters", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard counters", revision=1)]
@ -57,12 +57,12 @@ class VerifyGreenT(AntaTest):
-------- --------
```yaml ```yaml
anta.tests.greent: anta.tests.greent:
- VerifyGreenTCounters: - VerifyGreenT:
``` ```
""" """
name = "VerifyGreenT" description = "Verifies if a GreenT policy other than the default is created."
description = "Verifies if a GreenT policy is created."
categories: ClassVar[list[str]] = ["greent"] categories: ClassVar[list[str]] = ["greent"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard policy profile", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard policy profile", revision=1)]

View file

@ -36,8 +36,6 @@ class VerifyTransceiversManufacturers(AntaTest):
``` ```
""" """
name = "VerifyTransceiversManufacturers"
description = "Verifies if all transceivers come from approved manufacturers."
categories: ClassVar[list[str]] = ["hardware"] categories: ClassVar[list[str]] = ["hardware"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show inventory", revision=2)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show inventory", revision=2)]
@ -77,8 +75,6 @@ class VerifyTemperature(AntaTest):
``` ```
""" """
name = "VerifyTemperature"
description = "Verifies the device temperature."
categories: ClassVar[list[str]] = ["hardware"] categories: ClassVar[list[str]] = ["hardware"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature", revision=1)]
@ -110,8 +106,6 @@ class VerifyTransceiversTemperature(AntaTest):
``` ```
""" """
name = "VerifyTransceiversTemperature"
description = "Verifies the transceivers temperature."
categories: ClassVar[list[str]] = ["hardware"] categories: ClassVar[list[str]] = ["hardware"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature transceiver", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature transceiver", revision=1)]
@ -151,8 +145,6 @@ class VerifyEnvironmentSystemCooling(AntaTest):
``` ```
""" """
name = "VerifyEnvironmentSystemCooling"
description = "Verifies the system cooling status."
categories: ClassVar[list[str]] = ["hardware"] categories: ClassVar[list[str]] = ["hardware"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", revision=1)]
@ -232,8 +224,6 @@ class VerifyEnvironmentPower(AntaTest):
``` ```
""" """
name = "VerifyEnvironmentPower"
description = "Verifies the power supplies status."
categories: ClassVar[list[str]] = ["hardware"] categories: ClassVar[list[str]] = ["hardware"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment power", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment power", revision=1)]
@ -274,7 +264,6 @@ class VerifyAdverseDrops(AntaTest):
``` ```
""" """
name = "VerifyAdverseDrops"
description = "Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches." description = "Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches."
categories: ClassVar[list[str]] = ["hardware"] categories: ClassVar[list[str]] = ["hardware"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware counter drop", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware counter drop", revision=1)]

View file

@ -8,8 +8,8 @@
from __future__ import annotations from __future__ import annotations
import re import re
from ipaddress import IPv4Network from ipaddress import IPv4Interface
from typing import Any, ClassVar, Literal from typing import Any, ClassVar
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pydantic_extra_types.mac_address import MacAddress from pydantic_extra_types.mac_address import MacAddress
@ -17,8 +17,9 @@ from pydantic_extra_types.mac_address import MacAddress
from anta import GITHUB_SUGGESTION from anta import GITHUB_SUGGESTION
from anta.custom_types import EthernetInterface, Interface, Percent, PositiveInteger from anta.custom_types import EthernetInterface, Interface, Percent, PositiveInteger
from anta.decorators import skip_on_platforms from anta.decorators import skip_on_platforms
from anta.input_models.interfaces import InterfaceState
from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import custom_division, get_failed_logs, get_item, get_value from anta.tools import custom_division, format_data, get_failed_logs, get_item, get_value
BPS_GBPS_CONVERSIONS = 1000000000 BPS_GBPS_CONVERSIONS = 1000000000
@ -44,8 +45,6 @@ class VerifyInterfaceUtilization(AntaTest):
``` ```
""" """
name = "VerifyInterfaceUtilization"
description = "Verifies that the utilization of interfaces is below a certain threshold."
categories: ClassVar[list[str]] = ["interfaces"] categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaCommand(command="show interfaces counters rates", revision=1), AntaCommand(command="show interfaces counters rates", revision=1),
@ -71,7 +70,7 @@ class VerifyInterfaceUtilization(AntaTest):
if ((duplex := (interface := interfaces["interfaces"][intf]).get("duplex", None)) is not None and duplex != duplex_full) or ( if ((duplex := (interface := interfaces["interfaces"][intf]).get("duplex", None)) is not None and duplex != duplex_full) or (
(members := interface.get("memberInterfaces", None)) is not None and any(stats["duplex"] != duplex_full for stats in members.values()) (members := interface.get("memberInterfaces", None)) is not None and any(stats["duplex"] != duplex_full for stats in members.values())
): ):
self.result.is_error(f"Interface {intf} or one of its member interfaces is not Full-Duplex. VerifyInterfaceUtilization has not been implemented.") self.result.is_failure(f"Interface {intf} or one of its member interfaces is not Full-Duplex. VerifyInterfaceUtilization has not been implemented.")
return return
if (bandwidth := interfaces["interfaces"][intf]["bandwidth"]) == 0: if (bandwidth := interfaces["interfaces"][intf]["bandwidth"]) == 0:
@ -105,8 +104,6 @@ class VerifyInterfaceErrors(AntaTest):
``` ```
""" """
name = "VerifyInterfaceErrors"
description = "Verifies there are no interface error counters."
categories: ClassVar[list[str]] = ["interfaces"] categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces counters errors", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces counters errors", revision=1)]
@ -140,8 +137,6 @@ class VerifyInterfaceDiscards(AntaTest):
``` ```
""" """
name = "VerifyInterfaceDiscards"
description = "Verifies there are no interface discard counters."
categories: ClassVar[list[str]] = ["interfaces"] categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces counters discards", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces counters discards", revision=1)]
@ -174,8 +169,6 @@ class VerifyInterfaceErrDisabled(AntaTest):
``` ```
""" """
name = "VerifyInterfaceErrDisabled"
description = "Verifies there are no interfaces in the errdisabled state."
categories: ClassVar[list[str]] = ["interfaces"] categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces status", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces status", revision=1)]
@ -191,16 +184,20 @@ class VerifyInterfaceErrDisabled(AntaTest):
class VerifyInterfacesStatus(AntaTest): class VerifyInterfacesStatus(AntaTest):
"""Verifies if the provided list of interfaces are all in the expected state. """Verifies the operational states of specified interfaces to ensure they match expected configurations.
- If line protocol status is provided, prioritize checking against both status and line protocol status This test performs the following checks for each specified interface:
- If line protocol status is not provided and interface status is "up", expect both status and line protocol to be "up"
- If interface status is not "up", check only the interface status without considering line protocol status 1. If `line_protocol_status` is defined, both `status` and `line_protocol_status` are verified for the specified interface.
2. If `line_protocol_status` is not provided but the `status` is "up", it is assumed that both the status and line protocol should be "up".
3. If the interface `status` is not "up", only the interface's status is validated, with no line protocol check performed.
Expected Results Expected Results
---------------- ----------------
* Success: The test will pass if the provided interfaces are all in the expected state. * Success: If the interface status and line protocol status matches the expected operational state for all specified interfaces.
* Failure: The test will fail if any interface is not in the expected state. * Failure: If any of the following occur:
- The specified interface is not configured.
- The specified interface status and line protocol status does not match the expected operational state for any interface.
Examples Examples
-------- --------
@ -219,8 +216,6 @@ class VerifyInterfacesStatus(AntaTest):
``` ```
""" """
name = "VerifyInterfacesStatus"
description = "Verifies the status of the provided interfaces."
categories: ClassVar[list[str]] = ["interfaces"] categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces description", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces description", revision=1)]
@ -229,30 +224,17 @@ class VerifyInterfacesStatus(AntaTest):
interfaces: list[InterfaceState] interfaces: list[InterfaceState]
"""List of interfaces with their expected state.""" """List of interfaces with their expected state."""
InterfaceState: ClassVar[type[InterfaceState]] = InterfaceState
class InterfaceState(BaseModel):
"""Model for an interface state."""
name: Interface
"""Interface to validate."""
status: Literal["up", "down", "adminDown"]
"""Expected status of the interface."""
line_protocol_status: Literal["up", "down", "testing", "unknown", "dormant", "notPresent", "lowerLayerDown"] | None = None
"""Expected line protocol status of the interface."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyInterfacesStatus.""" """Main test function for VerifyInterfacesStatus."""
command_output = self.instance_commands[0].json_output
self.result.is_success() self.result.is_success()
intf_not_configured = [] command_output = self.instance_commands[0].json_output
intf_wrong_state = []
for interface in self.inputs.interfaces: for interface in self.inputs.interfaces:
if (intf_status := get_value(command_output["interfaceDescriptions"], interface.name, separator="..")) is None: if (intf_status := get_value(command_output["interfaceDescriptions"], interface.name, separator="..")) is None:
intf_not_configured.append(interface.name) self.result.is_failure(f"{interface.name} - Not configured")
continue continue
status = "up" if intf_status["interfaceStatus"] in {"up", "connected"} else intf_status["interfaceStatus"] status = "up" if intf_status["interfaceStatus"] in {"up", "connected"} else intf_status["interfaceStatus"]
@ -261,18 +243,15 @@ class VerifyInterfacesStatus(AntaTest):
# If line protocol status is provided, prioritize checking against both status and line protocol status # If line protocol status is provided, prioritize checking against both status and line protocol status
if interface.line_protocol_status: if interface.line_protocol_status:
if interface.status != status or interface.line_protocol_status != proto: if interface.status != status or interface.line_protocol_status != proto:
intf_wrong_state.append(f"{interface.name} is {status}/{proto}") actual_state = f"Expected: {interface.status}/{interface.line_protocol_status}, Actual: {status}/{proto}"
self.result.is_failure(f"{interface.name} - {actual_state}")
# If line protocol status is not provided and interface status is "up", expect both status and proto to be "up" # If line protocol status is not provided and interface status is "up", expect both status and proto to be "up"
# If interface status is not "up", check only the interface status without considering line protocol status # If interface status is not "up", check only the interface status without considering line protocol status
elif (interface.status == "up" and (status != "up" or proto != "up")) or (interface.status != status): elif interface.status == "up" and (status != "up" or proto != "up"):
intf_wrong_state.append(f"{interface.name} is {status}/{proto}") self.result.is_failure(f"{interface.name} - Expected: up/up, Actual: {status}/{proto}")
elif interface.status != status:
if intf_not_configured: self.result.is_failure(f"{interface.name} - Expected: {interface.status}, Actual: {status}")
self.result.is_failure(f"The following interface(s) are not configured: {intf_not_configured}")
if intf_wrong_state:
self.result.is_failure(f"The following interface(s) are not in the expected state: {intf_wrong_state}")
class VerifyStormControlDrops(AntaTest): class VerifyStormControlDrops(AntaTest):
@ -291,8 +270,6 @@ class VerifyStormControlDrops(AntaTest):
``` ```
""" """
name = "VerifyStormControlDrops"
description = "Verifies there are no interface storm-control drop counters."
categories: ClassVar[list[str]] = ["interfaces"] categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show storm-control", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show storm-control", revision=1)]
@ -329,8 +306,6 @@ class VerifyPortChannels(AntaTest):
``` ```
""" """
name = "VerifyPortChannels"
description = "Verifies there are no inactive ports in all port channels."
categories: ClassVar[list[str]] = ["interfaces"] categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show port-channel", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show port-channel", revision=1)]
@ -364,8 +339,6 @@ class VerifyIllegalLACP(AntaTest):
``` ```
""" """
name = "VerifyIllegalLACP"
description = "Verifies there are no illegal LACP packets in all port channels."
categories: ClassVar[list[str]] = ["interfaces"] categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lacp counters all-ports", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lacp counters all-ports", revision=1)]
@ -401,7 +374,6 @@ class VerifyLoopbackCount(AntaTest):
``` ```
""" """
name = "VerifyLoopbackCount"
description = "Verifies the number of loopback interfaces and their status." description = "Verifies the number of loopback interfaces and their status."
categories: ClassVar[list[str]] = ["interfaces"] categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface brief", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface brief", revision=1)]
@ -450,8 +422,6 @@ class VerifySVI(AntaTest):
``` ```
""" """
name = "VerifySVI"
description = "Verifies the status of all SVIs."
categories: ClassVar[list[str]] = ["interfaces"] categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface brief", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface brief", revision=1)]
@ -495,7 +465,6 @@ class VerifyL3MTU(AntaTest):
``` ```
""" """
name = "VerifyL3MTU"
description = "Verifies the global L3 MTU of all L3 interfaces." description = "Verifies the global L3 MTU of all L3 interfaces."
categories: ClassVar[list[str]] = ["interfaces"] categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces", revision=1)]
@ -553,7 +522,6 @@ class VerifyIPProxyARP(AntaTest):
``` ```
""" """
name = "VerifyIPProxyARP"
description = "Verifies if Proxy ARP is enabled." description = "Verifies if Proxy ARP is enabled."
categories: ClassVar[list[str]] = ["interfaces"] categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {intf}", revision=2)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {intf}", revision=2)]
@ -607,7 +575,6 @@ class VerifyL2MTU(AntaTest):
``` ```
""" """
name = "VerifyL2MTU"
description = "Verifies the global L2 MTU of all L2 interfaces." description = "Verifies the global L2 MTU of all L2 interfaces."
categories: ClassVar[list[str]] = ["interfaces"] categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces", revision=1)]
@ -662,14 +629,13 @@ class VerifyInterfaceIPv4(AntaTest):
- VerifyInterfaceIPv4: - VerifyInterfaceIPv4:
interfaces: interfaces:
- name: Ethernet2 - name: Ethernet2
primary_ip: 172.30.11.0/31 primary_ip: 172.30.11.1/31
secondary_ips: secondary_ips:
- 10.10.10.0/31 - 10.10.10.1/31
- 10.10.10.10/31 - 10.10.10.10/31
``` ```
""" """
name = "VerifyInterfaceIPv4"
description = "Verifies the interface IPv4 addresses." description = "Verifies the interface IPv4 addresses."
categories: ClassVar[list[str]] = ["interfaces"] categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {interface}", revision=2)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {interface}", revision=2)]
@ -685,9 +651,9 @@ class VerifyInterfaceIPv4(AntaTest):
name: Interface name: Interface
"""Name of the interface.""" """Name of the interface."""
primary_ip: IPv4Network primary_ip: IPv4Interface
"""Primary IPv4 address in CIDR notation.""" """Primary IPv4 address in CIDR notation."""
secondary_ips: list[IPv4Network] | None = None secondary_ips: list[IPv4Interface] | None = None
"""Optional list of secondary IPv4 addresses in CIDR notation.""" """Optional list of secondary IPv4 addresses in CIDR notation."""
def render(self, template: AntaTemplate) -> list[AntaCommand]: def render(self, template: AntaTemplate) -> list[AntaCommand]:
@ -705,7 +671,7 @@ class VerifyInterfaceIPv4(AntaTest):
input_interface_detail = interface input_interface_detail = interface
break break
else: else:
self.result.is_error(f"Could not find `{intf}` in the input interfaces. {GITHUB_SUGGESTION}") self.result.is_failure(f"Could not find `{intf}` in the input interfaces. {GITHUB_SUGGESTION}")
continue continue
input_primary_ip = str(input_interface_detail.primary_ip) input_primary_ip = str(input_interface_detail.primary_ip)
@ -765,8 +731,6 @@ class VerifyIpVirtualRouterMac(AntaTest):
``` ```
""" """
name = "VerifyIpVirtualRouterMac"
description = "Verifies the IP virtual router MAC address."
categories: ClassVar[list[str]] = ["interfaces"] categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip virtual-router", revision=2)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip virtual-router", revision=2)]
@ -818,8 +782,6 @@ class VerifyInterfacesSpeed(AntaTest):
``` ```
""" """
name = "VerifyInterfacesSpeed"
description = "Verifies the speed, lanes, auto-negotiation status, and mode as full duplex for interfaces."
categories: ClassVar[list[str]] = ["interfaces"] categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces")]
@ -883,3 +845,91 @@ class VerifyInterfacesSpeed(AntaTest):
output["speed"] = f"{custom_division(output['speed'], BPS_GBPS_CONVERSIONS)}Gbps" output["speed"] = f"{custom_division(output['speed'], BPS_GBPS_CONVERSIONS)}Gbps"
failed_log = get_failed_logs(expected_interface_output, actual_interface_output) failed_log = get_failed_logs(expected_interface_output, actual_interface_output)
self.result.is_failure(f"For interface {intf}:{failed_log}\n") self.result.is_failure(f"For interface {intf}:{failed_log}\n")
class VerifyLACPInterfacesStatus(AntaTest):
"""Verifies the Link Aggregation Control Protocol (LACP) status of the interface.
This test performs the following checks for each specified interface:
1. Verifies that the interface is a member of the LACP port channel.
2. Verifies LACP port states and operational status:
- Activity: Active LACP mode (initiates)
- Timeout: Short (Fast Mode), Long (Slow Mode - default)
- Aggregation: Port aggregable
- Synchronization: Port in sync with partner
- Collecting: Incoming frames aggregating
- Distributing: Outgoing frames aggregating
Expected Results
----------------
* Success: Interface is bundled and all LACP states match expected values for both actor and partner
* Failure: If any of the following occur:
- Interface or port channel is not configured.
- Interface is not bundled in port channel.
- Actor or partner port LACP states don't match expected configuration.
- LACP rate (timeout) mismatch when fast mode is configured.
Examples
--------
```yaml
anta.tests.interfaces:
- VerifyLACPInterfacesStatus:
interfaces:
- name: Ethernet1
portchannel: Port-Channel100
```
"""
categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lacp interface", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifyLACPInterfacesStatus test."""
interfaces: list[InterfaceState]
"""List of interfaces with their expected state."""
InterfaceState: ClassVar[type[InterfaceState]] = InterfaceState
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyLACPInterfacesStatus."""
self.result.is_success()
# Member port verification parameters.
member_port_details = ["activity", "aggregation", "synchronization", "collecting", "distributing", "timeout"]
command_output = self.instance_commands[0].json_output
for interface in self.inputs.interfaces:
# Verify if a PortChannel is configured with the provided interface
if not (interface_details := get_value(command_output, f"portChannels..{interface.portchannel}..interfaces..{interface.name}", separator="..")):
self.result.is_failure(f"{interface} - Not configured")
continue
# Verify the interface is bundled in port channel.
actor_port_status = interface_details.get("actorPortStatus")
if actor_port_status != "bundled":
self.result.is_failure(f"{interface} - Not bundled - Port Status: {actor_port_status}")
continue
# Collecting actor and partner port details
actor_port_details = interface_details.get("actorPortState", {})
partner_port_details = interface_details.get("partnerPortState", {})
# Collecting actual interface details
actual_interface_output = {
"actor_port_details": {param: actor_port_details.get(param, "NotFound") for param in member_port_details},
"partner_port_details": {param: partner_port_details.get(param, "NotFound") for param in member_port_details},
}
# Forming expected interface details
expected_details = {param: param != "timeout" for param in member_port_details}
# Updating the short LACP timeout, if expected.
if interface.lacp_rate_fast:
expected_details["timeout"] = True
if (act_port_details := actual_interface_output["actor_port_details"]) != expected_details:
self.result.is_failure(f"{interface} - Actor port details mismatch - {format_data(act_port_details)}")
if (part_port_details := actual_interface_output["partner_port_details"]) != expected_details:
self.result.is_failure(f"{interface} - Partner port details mismatch - {format_data(part_port_details)}")

View file

@ -30,7 +30,6 @@ class VerifyLANZ(AntaTest):
``` ```
""" """
name = "VerifyLANZ"
description = "Verifies if LANZ is enabled." description = "Verifies if LANZ is enabled."
categories: ClassVar[list[str]] = ["lanz"] categories: ClassVar[list[str]] = ["lanz"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show queue-monitor length status", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show queue-monitor length status", revision=1)]

View file

@ -25,14 +25,17 @@ if TYPE_CHECKING:
def _get_logging_states(logger: logging.Logger, command_output: str) -> str: def _get_logging_states(logger: logging.Logger, command_output: str) -> str:
"""Parse `show logging` output and gets operational logging states used in the tests in this module. """Parse `show logging` output and gets operational logging states used in the tests in this module.
Args: Parameters
---- ----------
logger: The logger object. logger
command_output: The `show logging` output. The logger object.
command_output
The `show logging` output.
Returns Returns
------- -------
str: The operational logging states. str
The operational logging states.
""" """
log_states = command_output.partition("\n\nExternal configuration:")[0] log_states = command_output.partition("\n\nExternal configuration:")[0]
@ -56,8 +59,6 @@ class VerifyLoggingPersistent(AntaTest):
``` ```
""" """
name = "VerifyLoggingPersistent"
description = "Verifies if logging persistent is enabled and logs are saved in flash."
categories: ClassVar[list[str]] = ["logging"] categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaCommand(command="show logging", ofmt="text"), AntaCommand(command="show logging", ofmt="text"),
@ -97,13 +98,11 @@ class VerifyLoggingSourceIntf(AntaTest):
``` ```
""" """
name = "VerifyLoggingSourceInt"
description = "Verifies logging source-interface for a specified VRF."
categories: ClassVar[list[str]] = ["logging"] categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyLoggingSourceInt test.""" """Input model for the VerifyLoggingSourceIntf test."""
interface: str interface: str
"""Source-interface to use as source IP of log messages.""" """Source-interface to use as source IP of log messages."""
@ -112,7 +111,7 @@ class VerifyLoggingSourceIntf(AntaTest):
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyLoggingSourceInt.""" """Main test function for VerifyLoggingSourceIntf."""
output = self.instance_commands[0].text_output output = self.instance_commands[0].text_output
pattern = rf"Logging source-interface '{self.inputs.interface}'.*VRF {self.inputs.vrf}" pattern = rf"Logging source-interface '{self.inputs.interface}'.*VRF {self.inputs.vrf}"
if re.search(pattern, _get_logging_states(self.logger, output)): if re.search(pattern, _get_logging_states(self.logger, output)):
@ -141,8 +140,6 @@ class VerifyLoggingHosts(AntaTest):
``` ```
""" """
name = "VerifyLoggingHosts"
description = "Verifies logging hosts (syslog servers) for a specified VRF."
categories: ClassVar[list[str]] = ["logging"] categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")]
@ -173,10 +170,22 @@ class VerifyLoggingHosts(AntaTest):
class VerifyLoggingLogsGeneration(AntaTest): class VerifyLoggingLogsGeneration(AntaTest):
"""Verifies if logs are generated. """Verifies if logs are generated.
This test performs the following checks:
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 Expected Results
---------------- ----------------
* Success: The test will pass if logs are generated. * Success: If logs are being generated and the test message is found in recent logs.
* Failure: The test will fail if logs are NOT generated. * 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 Examples
-------- --------
@ -186,8 +195,6 @@ class VerifyLoggingLogsGeneration(AntaTest):
``` ```
""" """
name = "VerifyLoggingLogsGeneration"
description = "Verifies if logs are generated."
categories: ClassVar[list[str]] = ["logging"] categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaCommand(command="send log level informational message ANTA VerifyLoggingLogsGeneration validation", ofmt="text"), AntaCommand(command="send log level informational message ANTA VerifyLoggingLogsGeneration validation", ofmt="text"),
@ -210,10 +217,23 @@ class VerifyLoggingLogsGeneration(AntaTest):
class VerifyLoggingHostname(AntaTest): class VerifyLoggingHostname(AntaTest):
"""Verifies if logs are generated with the device FQDN. """Verifies if logs are generated with the device FQDN.
This test performs the following checks:
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 Expected Results
---------------- ----------------
* Success: The test will pass if logs are generated with the device FQDN. * Success: If logs are generated with the device's complete FQDN.
* Failure: The test will fail if logs are NOT generated with the device 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 Examples
-------- --------
@ -223,8 +243,6 @@ class VerifyLoggingHostname(AntaTest):
``` ```
""" """
name = "VerifyLoggingHostname"
description = "Verifies if logs are generated with the device FQDN."
categories: ClassVar[list[str]] = ["logging"] categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaCommand(command="show hostname", revision=1), AntaCommand(command="show hostname", revision=1),
@ -254,10 +272,24 @@ class VerifyLoggingHostname(AntaTest):
class VerifyLoggingTimestamp(AntaTest): class VerifyLoggingTimestamp(AntaTest):
"""Verifies if logs are generated with the appropriate timestamp. """Verifies if logs are generated with the appropriate 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 Expected Results
---------------- ----------------
* Success: The test will pass if logs are generated with the appropriate timestamp. * Success: If logs are generated with the correct high-resolution RFC3339 timestamp format.
* Failure: The test will fail if logs are NOT generated with the appropriate timestamp. * 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 Examples
-------- --------
@ -267,8 +299,6 @@ class VerifyLoggingTimestamp(AntaTest):
``` ```
""" """
name = "VerifyLoggingTimestamp"
description = "Verifies if logs are generated with the riate timestamp."
categories: ClassVar[list[str]] = ["logging"] categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation", ofmt="text"), AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation", ofmt="text"),
@ -279,7 +309,7 @@ class VerifyLoggingTimestamp(AntaTest):
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyLoggingTimestamp.""" """Main test function for VerifyLoggingTimestamp."""
log_pattern = r"ANTA VerifyLoggingTimestamp validation" log_pattern = r"ANTA VerifyLoggingTimestamp validation"
timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}-\d{2}:\d{2}" timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}[+-]\d{2}:\d{2}"
output = self.instance_commands[1].text_output output = self.instance_commands[1].text_output
lines = output.strip().split("\n")[::-1] lines = output.strip().split("\n")[::-1]
last_line_with_pattern = "" last_line_with_pattern = ""
@ -309,8 +339,6 @@ class VerifyLoggingAccounting(AntaTest):
``` ```
""" """
name = "VerifyLoggingAccounting"
description = "Verifies if AAA accounting logs are generated."
categories: ClassVar[list[str]] = ["logging"] categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")]
@ -341,8 +369,6 @@ class VerifyLoggingErrors(AntaTest):
``` ```
""" """
name = "VerifyLoggingErrors"
description = "Verifies there are no syslog messages with a severity of ERRORS or higher."
categories: ClassVar[list[str]] = ["logging"] categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging threshold errors", ofmt="text")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging threshold errors", ofmt="text")]

View file

@ -36,8 +36,6 @@ class VerifyMlagStatus(AntaTest):
``` ```
""" """
name = "VerifyMlagStatus"
description = "Verifies the health status of the MLAG configuration."
categories: ClassVar[list[str]] = ["mlag"] categories: ClassVar[list[str]] = ["mlag"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)]
@ -78,8 +76,6 @@ class VerifyMlagInterfaces(AntaTest):
``` ```
""" """
name = "VerifyMlagInterfaces"
description = "Verifies there are no inactive or active-partial MLAG ports."
categories: ClassVar[list[str]] = ["mlag"] categories: ClassVar[list[str]] = ["mlag"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)]
@ -114,8 +110,6 @@ class VerifyMlagConfigSanity(AntaTest):
``` ```
""" """
name = "VerifyMlagConfigSanity"
description = "Verifies there are no MLAG config-sanity inconsistencies."
categories: ClassVar[list[str]] = ["mlag"] categories: ClassVar[list[str]] = ["mlag"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag config-sanity", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag config-sanity", revision=1)]
@ -123,10 +117,7 @@ class VerifyMlagConfigSanity(AntaTest):
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyMlagConfigSanity.""" """Main test function for VerifyMlagConfigSanity."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if (mlag_status := get_value(command_output, "mlagActive")) is None: if command_output["mlagActive"] is False:
self.result.is_error(message="Incorrect JSON response - 'mlagActive' state was not found")
return
if mlag_status is False:
self.result.is_skipped("MLAG is disabled") self.result.is_skipped("MLAG is disabled")
return return
keys_to_verify = ["globalConfiguration", "interfaceConfiguration"] keys_to_verify = ["globalConfiguration", "interfaceConfiguration"]
@ -156,8 +147,6 @@ class VerifyMlagReloadDelay(AntaTest):
``` ```
""" """
name = "VerifyMlagReloadDelay"
description = "Verifies the MLAG reload-delay parameters."
categories: ClassVar[list[str]] = ["mlag"] categories: ClassVar[list[str]] = ["mlag"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)]
@ -206,7 +195,6 @@ class VerifyMlagDualPrimary(AntaTest):
``` ```
""" """
name = "VerifyMlagDualPrimary"
description = "Verifies the MLAG dual-primary detection parameters." description = "Verifies the MLAG dual-primary detection parameters."
categories: ClassVar[list[str]] = ["mlag"] categories: ClassVar[list[str]] = ["mlag"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)]
@ -265,7 +253,6 @@ class VerifyMlagPrimaryPriority(AntaTest):
``` ```
""" """
name = "VerifyMlagPrimaryPriority"
description = "Verifies the configuration of the MLAG primary priority." description = "Verifies the configuration of the MLAG primary priority."
categories: ClassVar[list[str]] = ["mlag"] categories: ClassVar[list[str]] = ["mlag"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)]

View file

@ -35,8 +35,6 @@ class VerifyIGMPSnoopingVlans(AntaTest):
``` ```
""" """
name = "VerifyIGMPSnoopingVlans"
description = "Verifies the IGMP snooping status for the provided VLANs."
categories: ClassVar[list[str]] = ["multicast"] categories: ClassVar[list[str]] = ["multicast"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)]
@ -78,8 +76,6 @@ class VerifyIGMPSnoopingGlobal(AntaTest):
``` ```
""" """
name = "VerifyIGMPSnoopingGlobal"
description = "Verifies the IGMP snooping global configuration."
categories: ClassVar[list[str]] = ["multicast"] categories: ClassVar[list[str]] = ["multicast"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)]

View file

@ -18,8 +18,7 @@ from anta.tools import get_value
class VerifyPathsHealth(AntaTest): class VerifyPathsHealth(AntaTest):
""" """Verifies the path and telemetry state of all paths under router path-selection.
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. The expected states are 'IPsec established', 'Resolved' for path and 'active' for telemetry.
@ -38,8 +37,6 @@ class VerifyPathsHealth(AntaTest):
``` ```
""" """
name = "VerifyPathsHealth"
description = "Verifies the path and telemetry state of all paths under router path-selection."
categories: ClassVar[list[str]] = ["path-selection"] categories: ClassVar[list[str]] = ["path-selection"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show path-selection paths", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show path-selection paths", revision=1)]
@ -73,8 +70,7 @@ class VerifyPathsHealth(AntaTest):
class VerifySpecificPath(AntaTest): class VerifySpecificPath(AntaTest):
""" """Verifies the path and telemetry state of a specific path for an IPv4 peer under router path-selection.
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. The expected states are 'IPsec established', 'Resolved' for path and 'active' for telemetry.
@ -98,8 +94,6 @@ class VerifySpecificPath(AntaTest):
``` ```
""" """
name = "VerifySpecificPath"
description = "Verifies the path and telemetry state of a specific path under router path-selection."
categories: ClassVar[list[str]] = ["path-selection"] categories: ClassVar[list[str]] = ["path-selection"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaTemplate(template="show path-selection paths peer {peer} path-group {group} source {source} destination {destination}", revision=1) AntaTemplate(template="show path-selection paths peer {peer} path-group {group} source {source} destination {destination}", revision=1)

View file

@ -33,7 +33,6 @@ class VerifyUnifiedForwardingTableMode(AntaTest):
``` ```
""" """
name = "VerifyUnifiedForwardingTableMode"
description = "Verifies the device is using the expected UFT mode." description = "Verifies the device is using the expected UFT mode."
categories: ClassVar[list[str]] = ["profiles"] categories: ClassVar[list[str]] = ["profiles"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show platform trident forwarding-table partition", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show platform trident forwarding-table partition", revision=1)]
@ -72,7 +71,6 @@ class VerifyTcamProfile(AntaTest):
``` ```
""" """
name = "VerifyTcamProfile"
description = "Verifies the device TCAM profile." description = "Verifies the device TCAM profile."
categories: ClassVar[list[str]] = ["profiles"] categories: ClassVar[list[str]] = ["profiles"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware tcam profile", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware tcam profile", revision=1)]

View file

@ -33,7 +33,6 @@ class VerifyPtpModeStatus(AntaTest):
``` ```
""" """
name = "VerifyPtpModeStatus"
description = "Verifies that the device is configured as a PTP Boundary Clock." description = "Verifies that the device is configured as a PTP Boundary Clock."
categories: ClassVar[list[str]] = ["ptp"] categories: ClassVar[list[str]] = ["ptp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)]
@ -80,7 +79,6 @@ class VerifyPtpGMStatus(AntaTest):
gmid: str gmid: str
"""Identifier of the Grandmaster to which the device should be locked.""" """Identifier of the Grandmaster to which the device should be locked."""
name = "VerifyPtpGMStatus"
description = "Verifies that the device is locked to a valid PTP Grandmaster." description = "Verifies that the device is locked to a valid PTP Grandmaster."
categories: ClassVar[list[str]] = ["ptp"] categories: ClassVar[list[str]] = ["ptp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)]
@ -120,7 +118,6 @@ class VerifyPtpLockStatus(AntaTest):
``` ```
""" """
name = "VerifyPtpLockStatus"
description = "Verifies that the device was locked to the upstream PTP GM in the last minute." description = "Verifies that the device was locked to the upstream PTP GM in the last minute."
categories: ClassVar[list[str]] = ["ptp"] categories: ClassVar[list[str]] = ["ptp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)]
@ -161,7 +158,6 @@ class VerifyPtpOffset(AntaTest):
``` ```
""" """
name = "VerifyPtpOffset"
description = "Verifies that the PTP timing offset is within +/- 1000ns from the master clock." description = "Verifies that the PTP timing offset is within +/- 1000ns from the master clock."
categories: ClassVar[list[str]] = ["ptp"] categories: ClassVar[list[str]] = ["ptp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp monitor", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp monitor", revision=1)]
@ -206,7 +202,6 @@ class VerifyPtpPortModeStatus(AntaTest):
``` ```
""" """
name = "VerifyPtpPortModeStatus"
description = "Verifies the PTP interfaces state." description = "Verifies the PTP interfaces state."
categories: ClassVar[list[str]] = ["ptp"] categories: ClassVar[list[str]] = ["ptp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)]

File diff suppressed because it is too large Load diff

View file

@ -7,16 +7,28 @@
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from ipaddress import IPv4Address, ip_interface from functools import cache
from typing import ClassVar, Literal from ipaddress import IPv4Address, IPv4Interface
from typing import TYPE_CHECKING, ClassVar, Literal
from pydantic import model_validator from pydantic import model_validator
from anta.custom_types import PositiveInteger
from anta.input_models.routing.generic import IPv4Routes
from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_value
if TYPE_CHECKING:
import sys
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
class VerifyRoutingProtocolModel(AntaTest): class VerifyRoutingProtocolModel(AntaTest):
"""Verifies the configured routing protocol model is the one we expect. """Verifies the configured routing protocol model.
Expected Results Expected Results
---------------- ----------------
@ -33,8 +45,6 @@ class VerifyRoutingProtocolModel(AntaTest):
``` ```
""" """
name = "VerifyRoutingProtocolModel"
description = "Verifies the configured routing protocol model."
categories: ClassVar[list[str]] = ["routing"] categories: ClassVar[list[str]] = ["routing"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)]
@ -75,21 +85,19 @@ class VerifyRoutingTableSize(AntaTest):
``` ```
""" """
name = "VerifyRoutingTableSize"
description = "Verifies the size of the IP routing table of the default VRF."
categories: ClassVar[list[str]] = ["routing"] categories: ClassVar[list[str]] = ["routing"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyRoutingTableSize test.""" """Input model for the VerifyRoutingTableSize test."""
minimum: int minimum: PositiveInteger
"""Expected minimum routing table size.""" """Expected minimum routing table size."""
maximum: int maximum: PositiveInteger
"""Expected maximum routing table size.""" """Expected maximum routing table size."""
@model_validator(mode="after") # type: ignore[misc] @model_validator(mode="after")
def check_min_max(self) -> AntaTest.Input: def check_min_max(self) -> Self:
"""Validate that maximum is greater than minimum.""" """Validate that maximum is greater than minimum."""
if self.minimum > self.maximum: if self.minimum > self.maximum:
msg = f"Minimum {self.minimum} is greater than maximum {self.maximum}" msg = f"Minimum {self.minimum} is greater than maximum {self.maximum}"
@ -128,10 +136,11 @@ class VerifyRoutingTableEntry(AntaTest):
``` ```
""" """
name = "VerifyRoutingTableEntry"
description = "Verifies that the provided routes are present in the routing table of a specified VRF."
categories: ClassVar[list[str]] = ["routing"] categories: ClassVar[list[str]] = ["routing"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip route vrf {vrf} {route}", revision=4)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaTemplate(template="show ip route vrf {vrf} {route}", revision=4),
AntaTemplate(template="show ip route vrf {vrf}", revision=4),
]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyRoutingTableEntry test.""" """Input model for the VerifyRoutingTableEntry test."""
@ -140,22 +149,110 @@ class VerifyRoutingTableEntry(AntaTest):
"""VRF context. Defaults to `default` VRF.""" """VRF context. Defaults to `default` VRF."""
routes: list[IPv4Address] routes: list[IPv4Address]
"""List of routes to verify.""" """List of routes to verify."""
collect: Literal["one", "all"] = "one"
"""Route collect behavior: one=one route per command, all=all routes in vrf per command. Defaults to `one`"""
def render(self, template: AntaTemplate) -> list[AntaCommand]: def render(self, template: AntaTemplate) -> list[AntaCommand]:
"""Render the template for each route in the input list.""" """Render the template for the input vrf."""
if template == VerifyRoutingTableEntry.commands[0] and self.inputs.collect == "one":
return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes] return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes]
if template == VerifyRoutingTableEntry.commands[1] and self.inputs.collect == "all":
return [template.render(vrf=self.inputs.vrf)]
return []
@staticmethod
@cache
def ip_interface_ip(route: str) -> IPv4Address:
"""Return the IP address of the provided ip route with mask."""
return IPv4Interface(route).ip
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyRoutingTableEntry.""" """Main test function for VerifyRoutingTableEntry."""
missing_routes = [] commands_output_route_ips = set()
for command in self.instance_commands: for command in self.instance_commands:
vrf, route = command.params.vrf, command.params.route command_output_vrf = command.json_output["vrfs"][self.inputs.vrf]
if len(routes := command.json_output["vrfs"][vrf]["routes"]) == 0 or route != ip_interface(next(iter(routes))).ip: commands_output_route_ips |= {self.ip_interface_ip(route) for route in command_output_vrf["routes"]}
missing_routes.append(str(route))
missing_routes = [str(route) for route in self.inputs.routes if route not in commands_output_route_ips]
if not missing_routes: if not missing_routes:
self.result.is_success() self.result.is_success()
else: else:
self.result.is_failure(f"The following route(s) are missing from the routing table of VRF {self.inputs.vrf}: {missing_routes}") self.result.is_failure(f"The following route(s) are missing from the routing table of VRF {self.inputs.vrf}: {missing_routes}")
class VerifyIPv4RouteType(AntaTest):
"""Verifies the route-type of the IPv4 prefixes.
This test performs the following checks for each IPv4 route:
1. Verifies that the specified VRF is configured.
2. Verifies that the specified IPv4 route is exists in the configuration.
3. Verifies that the the specified IPv4 route is of the expected type.
Expected Results
----------------
* Success: If all of the following conditions are met:
- All the specified VRFs are configured.
- All the specified IPv4 routes are found.
- All the specified IPv4 routes are of the expected type.
* Failure: If any of the following occur:
- A specified VRF is not configured.
- A specified IPv4 route is not found.
- Any specified IPv4 route is not of the expected type.
Examples
--------
```yaml
anta.tests.routing:
generic:
- VerifyIPv4RouteType:
routes_entries:
- prefix: 10.10.0.1/32
vrf: default
route_type: eBGP
- prefix: 10.100.0.12/31
vrf: default
route_type: connected
- prefix: 10.100.1.5/32
vrf: default
route_type: iBGP
```
"""
categories: ClassVar[list[str]] = ["routing"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route vrf all", revision=4)]
class Input(AntaTest.Input):
"""Input model for the VerifyIPv4RouteType test."""
routes_entries: list[IPv4Routes]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyIPv4RouteType."""
self.result.is_success()
output = self.instance_commands[0].json_output
# Iterating over the all routes entries mentioned in the inputs.
for entry in self.inputs.routes_entries:
prefix = str(entry.prefix)
vrf = entry.vrf
expected_route_type = entry.route_type
# Verifying that on device, expected VRF is configured.
if (routes_details := get_value(output, f"vrfs.{vrf}.routes")) is None:
self.result.is_failure(f"{entry} - VRF not configured")
continue
# Verifying that the expected IPv4 route is present or not on the device
if (route_data := routes_details.get(prefix)) is None:
self.result.is_failure(f"{entry} - Route not found")
continue
# Verifying that the specified IPv4 routes are of the expected type.
if expected_route_type != (actual_route_type := route_data.get("routeType")):
self.result.is_failure(f"{entry} - Incorrect route type - Expected: {expected_route_type} Actual: {actual_route_type}")

View file

@ -7,6 +7,7 @@
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from ipaddress import IPv4Address, IPv4Network
from typing import Any, ClassVar, Literal from typing import Any, ClassVar, Literal
from pydantic import BaseModel from pydantic import BaseModel
@ -19,13 +20,15 @@ from anta.tools import get_value
def _count_isis_neighbor(isis_neighbor_json: dict[str, Any]) -> int: def _count_isis_neighbor(isis_neighbor_json: dict[str, Any]) -> int:
"""Count the number of isis neighbors. """Count the number of isis neighbors.
Args Parameters
---- ----------
isis_neighbor_json: The JSON output of the `show isis neighbors` command. isis_neighbor_json
The JSON output of the `show isis neighbors` command.
Returns Returns
------- -------
int: The number of isis neighbors. int
The number of isis neighbors.
""" """
count = 0 count = 0
@ -38,13 +41,15 @@ def _count_isis_neighbor(isis_neighbor_json: dict[str, Any]) -> int:
def _get_not_full_isis_neighbors(isis_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]: 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`. """Return the isis neighbors whose adjacency state is not `up`.
Args Parameters
---- ----------
isis_neighbor_json: The JSON output of the `show isis neighbors` command. isis_neighbor_json
The JSON output of the `show isis neighbors` command.
Returns Returns
------- -------
list[dict[str, Any]]: A list of isis neighbors whose adjacency state is not `UP`. list[dict[str, Any]]
A list of isis neighbors whose adjacency state is not `UP`.
""" """
return [ return [
@ -65,14 +70,17 @@ def _get_not_full_isis_neighbors(isis_neighbor_json: dict[str, Any]) -> list[dic
def _get_full_isis_neighbors(isis_neighbor_json: dict[str, Any], neighbor_state: Literal["up", "down"] = "up") -> list[dict[str, Any]]: 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`. """Return the isis neighbors whose adjacency state is `up`.
Args Parameters
---- ----------
isis_neighbor_json: The JSON output of the `show isis neighbors` command. isis_neighbor_json
neighbor_state: Value of the neihbor state we are looking for. Default up The JSON output of the `show isis neighbors` command.
neighbor_state
Value of the neihbor state we are looking for. Defaults to `up`.
Returns Returns
------- -------
list[dict[str, Any]]: A list of isis neighbors whose adjacency state is not `UP`. list[dict[str, Any]]
A list of isis neighbors whose adjacency state is not `UP`.
""" """
return [ return [
@ -118,6 +126,20 @@ def _get_interface_data(interface: str, vrf: str, command_output: dict[str, Any]
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): class VerifyISISNeighborState(AntaTest):
"""Verifies all IS-IS neighbors are in UP state. """Verifies all IS-IS neighbors are in UP state.
@ -136,8 +158,6 @@ class VerifyISISNeighborState(AntaTest):
``` ```
""" """
name = "VerifyISISNeighborState"
description = "Verifies all IS-IS neighbors are in UP state."
categories: ClassVar[list[str]] = ["isis"] categories: ClassVar[list[str]] = ["isis"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis neighbors", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis neighbors", revision=1)]
@ -182,8 +202,6 @@ class VerifyISISNeighborCount(AntaTest):
``` ```
""" """
name = "VerifyISISNeighborCount"
description = "Verifies count of IS-IS interface per level"
categories: ClassVar[list[str]] = ["isis"] categories: ClassVar[list[str]] = ["isis"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief", revision=1)]
@ -211,11 +229,12 @@ class VerifyISISNeighborCount(AntaTest):
isis_neighbor_count = _get_isis_neighbors_count(command_output) isis_neighbor_count = _get_isis_neighbors_count(command_output)
if len(isis_neighbor_count) == 0: if len(isis_neighbor_count) == 0:
self.result.is_skipped("No IS-IS neighbor detected") self.result.is_skipped("No IS-IS neighbor detected")
return
for interface in self.inputs.interfaces: 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] 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: if not eos_data:
self.result.is_failure(f"No neighbor detected for interface {interface.name}") self.result.is_failure(f"No neighbor detected for interface {interface.name}")
return continue
if eos_data[0]["count"] != interface.count: if eos_data[0]["count"] != interface.count:
self.result.is_failure( self.result.is_failure(
f"Interface {interface.name}: " f"Interface {interface.name}: "
@ -254,7 +273,6 @@ class VerifyISISInterfaceMode(AntaTest):
``` ```
""" """
name = "VerifyISISInterfaceMode"
description = "Verifies interface mode for IS-IS" description = "Verifies interface mode for IS-IS"
categories: ClassVar[list[str]] = ["isis"] categories: ClassVar[list[str]] = ["isis"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief", revision=1)]
@ -284,7 +302,8 @@ class VerifyISISInterfaceMode(AntaTest):
self.result.is_success() self.result.is_success()
if len(command_output["vrfs"]) == 0: if len(command_output["vrfs"]) == 0:
self.result.is_failure("IS-IS is not configured on device") self.result.is_skipped("IS-IS is not configured on device")
return
# Check for p2p interfaces # Check for p2p interfaces
for interface in self.inputs.interfaces: for interface in self.inputs.interfaces:
@ -306,3 +325,406 @@ class VerifyISISInterfaceMode(AntaTest):
self.result.is_failure(f"Interface {interface.name} in VRF {interface.vrf} is not running in passive mode") self.result.is_failure(f"Interface {interface.name} in VRF {interface.vrf} is not running in passive mode")
else: else:
self.result.is_failure(f"Interface {interface.name} not found in VRF {interface.vrf}") 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

@ -18,13 +18,15 @@ if TYPE_CHECKING:
def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int: def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int:
"""Count the number of OSPF neighbors. """Count the number of OSPF neighbors.
Args: Parameters
---- ----------
ospf_neighbor_json: The JSON output of the `show ip ospf neighbor` command. ospf_neighbor_json
The JSON output of the `show ip ospf neighbor` command.
Returns Returns
------- -------
int: The number of OSPF neighbors. int
The number of OSPF neighbors.
""" """
count = 0 count = 0
@ -37,13 +39,15 @@ def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int:
def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]: def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]:
"""Return the OSPF neighbors whose adjacency state is not `full`. """Return the OSPF neighbors whose adjacency state is not `full`.
Args: Parameters
---- ----------
ospf_neighbor_json: The JSON output of the `show ip ospf neighbor` command. ospf_neighbor_json
The JSON output of the `show ip ospf neighbor` command.
Returns Returns
------- -------
list[dict[str, Any]]: A list of OSPF neighbors whose adjacency state is not `full`. list[dict[str, Any]]
A list of OSPF neighbors whose adjacency state is not `full`.
""" """
return [ return [
@ -63,13 +67,15 @@ def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dic
def _get_ospf_max_lsa_info(ospf_process_json: dict[str, Any]) -> list[dict[str, Any]]: def _get_ospf_max_lsa_info(ospf_process_json: dict[str, Any]) -> list[dict[str, Any]]:
"""Return information about OSPF instances and their LSAs. """Return information about OSPF instances and their LSAs.
Args: Parameters
---- ----------
ospf_process_json: OSPF process information in JSON format. ospf_process_json
OSPF process information in JSON format.
Returns Returns
------- -------
list[dict[str, Any]]: A list of dictionaries containing OSPF LSAs information. list[dict[str, Any]]
A list of dictionaries containing OSPF LSAs information.
""" """
return [ return [
@ -103,8 +109,6 @@ class VerifyOSPFNeighborState(AntaTest):
``` ```
""" """
name = "VerifyOSPFNeighborState"
description = "Verifies all OSPF neighbors are in FULL state."
categories: ClassVar[list[str]] = ["ospf"] categories: ClassVar[list[str]] = ["ospf"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)]
@ -140,8 +144,6 @@ class VerifyOSPFNeighborCount(AntaTest):
``` ```
""" """
name = "VerifyOSPFNeighborCount"
description = "Verifies the number of OSPF neighbors in FULL state is the one we expect."
categories: ClassVar[list[str]] = ["ospf"] categories: ClassVar[list[str]] = ["ospf"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)]
@ -184,7 +186,6 @@ class VerifyOSPFMaxLSA(AntaTest):
``` ```
""" """
name = "VerifyOSPFMaxLSA"
description = "Verifies all OSPF instances did not cross the maximum LSA threshold." description = "Verifies all OSPF instances did not cross the maximum LSA threshold."
categories: ClassVar[list[str]] = ["ospf"] categories: ClassVar[list[str]] = ["ospf"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf", revision=1)]

View file

@ -8,15 +8,23 @@ from __future__ import annotations
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from datetime import datetime, timezone from datetime import datetime, timezone
from ipaddress import IPv4Address from typing import TYPE_CHECKING, ClassVar, get_args
from typing import ClassVar
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from anta.custom_types import EcdsaKeySize, EncryptionAlgorithm, PositiveInteger, RsaKeySize from anta.custom_types import EcdsaKeySize, EncryptionAlgorithm, PositiveInteger, RsaKeySize
from anta.input_models.security import IPSecPeer, IPSecPeers
from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_failed_logs, get_item, get_value from anta.tools 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
class VerifySSHStatus(AntaTest): class VerifySSHStatus(AntaTest):
"""Verifies if the SSHD agent is disabled in the default VRF. """Verifies if the SSHD agent is disabled in the default VRF.
@ -34,8 +42,6 @@ class VerifySSHStatus(AntaTest):
``` ```
""" """
name = "VerifySSHStatus"
description = "Verifies if the SSHD agent is disabled in the default VRF."
categories: ClassVar[list[str]] = ["security"] categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh", ofmt="text")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh", ofmt="text")]
@ -47,9 +53,9 @@ class VerifySSHStatus(AntaTest):
try: try:
line = next(line for line in command_output.split("\n") if line.startswith("SSHD status")) line = next(line for line in command_output.split("\n") if line.startswith("SSHD status"))
except StopIteration: except StopIteration:
self.result.is_error("Could not find SSH status in returned output.") self.result.is_failure("Could not find SSH status in returned output.")
return return
status = line.split("is ")[1] status = line.split()[-1]
if status == "disabled": if status == "disabled":
self.result.is_success() self.result.is_success()
@ -75,7 +81,6 @@ class VerifySSHIPv4Acl(AntaTest):
``` ```
""" """
name = "VerifySSHIPv4Acl"
description = "Verifies if the SSHD agent has IPv4 ACL(s) configured." description = "Verifies if the SSHD agent has IPv4 ACL(s) configured."
categories: ClassVar[list[str]] = ["security"] categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ip access-list summary", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ip access-list summary", revision=1)]
@ -124,7 +129,6 @@ class VerifySSHIPv6Acl(AntaTest):
``` ```
""" """
name = "VerifySSHIPv6Acl"
description = "Verifies if the SSHD agent has IPv6 ACL(s) configured." description = "Verifies if the SSHD agent has IPv6 ACL(s) configured."
categories: ClassVar[list[str]] = ["security"] categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ipv6 access-list summary", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ipv6 access-list summary", revision=1)]
@ -171,8 +175,6 @@ class VerifyTelnetStatus(AntaTest):
``` ```
""" """
name = "VerifyTelnetStatus"
description = "Verifies if Telnet is disabled in the default VRF."
categories: ClassVar[list[str]] = ["security"] categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management telnet", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management telnet", revision=1)]
@ -202,8 +204,6 @@ class VerifyAPIHttpStatus(AntaTest):
``` ```
""" """
name = "VerifyAPIHttpStatus"
description = "Verifies if eAPI HTTP server is disabled globally."
categories: ClassVar[list[str]] = ["security"] categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)]
@ -234,7 +234,6 @@ class VerifyAPIHttpsSSL(AntaTest):
``` ```
""" """
name = "VerifyAPIHttpsSSL"
description = "Verifies if the eAPI has a valid SSL profile." description = "Verifies if the eAPI has a valid SSL profile."
categories: ClassVar[list[str]] = ["security"] categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)]
@ -277,8 +276,6 @@ class VerifyAPIIPv4Acl(AntaTest):
``` ```
""" """
name = "VerifyAPIIPv4Acl"
description = "Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF."
categories: ClassVar[list[str]] = ["security"] categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ip access-list summary", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ip access-list summary", revision=1)]
@ -327,8 +324,6 @@ class VerifyAPIIPv6Acl(AntaTest):
``` ```
""" """
name = "VerifyAPIIPv6Acl"
description = "Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF."
categories: ClassVar[list[str]] = ["security"] categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ipv6 access-list summary", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ipv6 access-list summary", revision=1)]
@ -387,8 +382,6 @@ class VerifyAPISSLCertificate(AntaTest):
``` ```
""" """
name = "VerifyAPISSLCertificate"
description = "Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size."
categories: ClassVar[list[str]] = ["security"] categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaCommand(command="show management security ssl certificate", revision=1), AntaCommand(command="show management security ssl certificate", revision=1),
@ -416,19 +409,19 @@ class VerifyAPISSLCertificate(AntaTest):
"""The encryption algorithm key size of the certificate.""" """The encryption algorithm key size of the certificate."""
@model_validator(mode="after") @model_validator(mode="after")
def validate_inputs(self: BaseModel) -> BaseModel: def validate_inputs(self) -> Self:
"""Validate the key size provided to the APISSLCertificates class. """Validate the key size provided to the APISSLCertificates class.
If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}. If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}.
If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}. If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}.
""" """
if self.encryption_algorithm == "RSA" and self.key_size not in RsaKeySize.__args__: 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 {RsaKeySize.__args__}." msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {get_args(RsaKeySize)}."
raise ValueError(msg) raise ValueError(msg)
if self.encryption_algorithm == "ECDSA" and self.key_size not in EcdsaKeySize.__args__: 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 {EcdsaKeySize.__args__}." msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {get_args(EcdsaKeySize)}."
raise ValueError(msg) raise ValueError(msg)
return self return self
@ -497,8 +490,6 @@ class VerifyBannerLogin(AntaTest):
``` ```
""" """
name = "VerifyBannerLogin"
description = "Verifies the login banner of a device."
categories: ClassVar[list[str]] = ["security"] categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner login", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner login", revision=1)]
@ -541,8 +532,6 @@ class VerifyBannerMotd(AntaTest):
``` ```
""" """
name = "VerifyBannerMotd"
description = "Verifies the motd banner of a device."
categories: ClassVar[list[str]] = ["security"] categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner motd", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner motd", revision=1)]
@ -596,8 +585,6 @@ class VerifyIPv4ACL(AntaTest):
``` ```
""" """
name = "VerifyIPv4ACL"
description = "Verifies the configuration of IPv4 ACLs."
categories: ClassVar[list[str]] = ["security"] categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip access-lists {acl}", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip access-lists {acl}", revision=1)]
@ -661,8 +648,7 @@ class VerifyIPv4ACL(AntaTest):
class VerifyIPSecConnHealth(AntaTest): class VerifyIPSecConnHealth(AntaTest):
""" """Verifies all IPv4 security connections.
Verifies all IPv4 security connections.
Expected Results Expected Results
---------------- ----------------
@ -677,8 +663,6 @@ class VerifyIPSecConnHealth(AntaTest):
``` ```
""" """
name = "VerifyIPSecConnHealth"
description = "Verifies all IPv4 security connections."
categories: ClassVar[list[str]] = ["security"] categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip security connection vrf all")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip security connection vrf all")]
@ -708,16 +692,22 @@ class VerifyIPSecConnHealth(AntaTest):
class VerifySpecificIPSecConn(AntaTest): class VerifySpecificIPSecConn(AntaTest):
""" """Verifies the IPv4 security connections.
Verifies the state of IPv4 security connections for a specified peer.
It optionally allows for the verification of a specific path for a peer by providing source and destination addresses. This test performs the following checks for each peer:
If these addresses are not provided, it will verify all paths for the specified 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 Expected Results
---------------- ----------------
* Success: The test passes if the IPv4 security connection for a peer is established in the specified VRF. * Success: If all checks pass for all specified IPv4 security connections.
* Failure: The test fails if IPv4 security is not configured, a connection is not found for a peer, or the connection is not established in the specified VRF. * 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 Examples
-------- --------
@ -736,36 +726,16 @@ class VerifySpecificIPSecConn(AntaTest):
``` ```
""" """
name = "VerifySpecificIPSecConn"
description = "Verifies IPv4 security connections for a peer."
categories: ClassVar[list[str]] = ["security"] categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip security connection vrf {vrf} path peer {peer}")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip security connection vrf {vrf} path peer {peer}", revision=2)]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifySpecificIPSecConn test.""" """Input model for the VerifySpecificIPSecConn test."""
ip_security_connections: list[IPSecPeers] ip_security_connections: list[IPSecPeer]
"""List of IP4v security peers.""" """List of IP4v security peers."""
IPSecPeers: ClassVar[type[IPSecPeers]] = IPSecPeers
class IPSecPeers(BaseModel): """To maintain backward compatibility."""
"""Details of IPv4 security peers."""
peer: IPv4Address
"""IPv4 address of the peer."""
vrf: str = "default"
"""Optional VRF for the IP security peer."""
connections: list[IPSecConn] | None = None
"""Optional list of IPv4 security connections of a peer."""
class IPSecConn(BaseModel):
"""Details of IPv4 security connections for a peer."""
source_address: IPv4Address
"""Source IPv4 address of the connection."""
destination_address: IPv4Address
"""Destination IPv4 address of the connection."""
def render(self, template: AntaTemplate) -> list[AntaCommand]: def render(self, template: AntaTemplate) -> list[AntaCommand]:
"""Render the template for each input IP Sec connection.""" """Render the template for each input IP Sec connection."""
@ -775,15 +745,15 @@ class VerifySpecificIPSecConn(AntaTest):
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifySpecificIPSecConn.""" """Main test function for VerifySpecificIPSecConn."""
self.result.is_success() self.result.is_success()
for command_output, input_peer in zip(self.instance_commands, self.inputs.ip_security_connections): for command_output, input_peer in zip(self.instance_commands, self.inputs.ip_security_connections):
conn_output = command_output.json_output["connections"] conn_output = command_output.json_output["connections"]
peer = command_output.params.peer
vrf = command_output.params.vrf
conn_input = input_peer.connections conn_input = input_peer.connections
vrf = input_peer.vrf
# Check if IPv4 security connection is configured # Check if IPv4 security connection is configured
if not conn_output: if not conn_output:
self.result.is_failure(f"No IPv4 security connection configured for peer `{peer}`.") self.result.is_failure(f"{input_peer} - Not configured")
continue continue
# If connection details are not provided then check all connections of a peer # If connection details are not provided then check all connections of a peer
@ -793,10 +763,8 @@ class VerifySpecificIPSecConn(AntaTest):
if state != "Established": if state != "Established":
source = conn_data.get("saddr") source = conn_data.get("saddr")
destination = conn_data.get("daddr") destination = conn_data.get("daddr")
vrf = conn_data.get("tunnelNs")
self.result.is_failure( self.result.is_failure(
f"Expected state of IPv4 security connection `source:{source} destination:{destination} vrf:{vrf}` for peer `{peer}` is `Established` " f"{input_peer} Source: {source} Destination: {destination} - Connection down - Expected: Established, Actual: {state}"
f"but found `{state}` instead."
) )
continue continue
@ -812,11 +780,38 @@ class VerifySpecificIPSecConn(AntaTest):
if (source_input, destination_input, vrf) in existing_connections: if (source_input, destination_input, vrf) in existing_connections:
existing_state = existing_connections[(source_input, destination_input, vrf)] existing_state = existing_connections[(source_input, destination_input, vrf)]
if existing_state != "Established": if existing_state != "Established":
self.result.is_failure( failure = f"Expected: Established, Actual: {existing_state}"
f"Expected state of IPv4 security connection `source:{source_input} destination:{destination_input} vrf:{vrf}` " self.result.is_failure(f"{input_peer} Source: {source_input} Destination: {destination_input} - Connection down - {failure}")
f"for peer `{peer}` is `Established` but found `{existing_state}` instead."
)
else: else:
self.result.is_failure( self.result.is_failure(f"{input_peer} Source: {source_input} Destination: {destination_input} - Connection not found.")
f"IPv4 security connection `source:{source_input} destination:{destination_input} vrf:{vrf}` for peer `{peer}` is 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

@ -7,14 +7,14 @@ from __future__ import annotations
# Mypy does not understand AntaTest.Input typing # Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from ipaddress import IPv4Address, IPv6Address
from typing import ClassVar from typing import ClassVar
from pydantic import BaseModel, Field from pydantic import BaseModel
from anta.custom_types import ErrDisableInterval, ErrDisableReasons from anta.custom_types import ErrDisableInterval, ErrDisableReasons
from anta.input_models.services import DnsServer
from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_dict_superset, get_failed_logs, get_item from anta.tools import get_dict_superset, get_failed_logs
class VerifyHostname(AntaTest): class VerifyHostname(AntaTest):
@ -34,8 +34,6 @@ class VerifyHostname(AntaTest):
``` ```
""" """
name = "VerifyHostname"
description = "Verifies the hostname of a device."
categories: ClassVar[list[str]] = ["services"] categories: ClassVar[list[str]] = ["services"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hostname", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hostname", revision=1)]
@ -77,7 +75,6 @@ class VerifyDNSLookup(AntaTest):
``` ```
""" """
name = "VerifyDNSLookup"
description = "Verifies the DNS name to IP address resolution." description = "Verifies the DNS name to IP address resolution."
categories: ClassVar[list[str]] = ["services"] categories: ClassVar[list[str]] = ["services"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="bash timeout 10 nslookup {domain}", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="bash timeout 10 nslookup {domain}", revision=1)]
@ -109,10 +106,17 @@ class VerifyDNSLookup(AntaTest):
class VerifyDNSServers(AntaTest): class VerifyDNSServers(AntaTest):
"""Verifies if the DNS (Domain Name Service) servers are correctly configured. """Verifies if the DNS (Domain Name Service) servers are correctly configured.
This test performs the following checks for each specified DNS Server:
1. Confirming correctly registered with a valid IPv4 or IPv6 address with the designated VRF.
2. Ensuring an appropriate priority level.
Expected Results Expected Results
---------------- ----------------
* Success: The test will pass if the DNS server specified in the input is configured with the correct VRF and priority. * Success: The test will pass if the DNS server specified in the input is configured with the correct VRF and priority.
* Failure: The test will fail if the DNS server is not configured or if the VRF and priority of the DNS server do not match the input. * Failure: The test will fail if any of the following conditions are met:
- The provided DNS server is not configured.
- The provided DNS server with designated VRF and priority does not match the expected information.
Examples Examples
-------- --------
@ -129,8 +133,6 @@ class VerifyDNSServers(AntaTest):
``` ```
""" """
name = "VerifyDNSServers"
description = "Verifies if the DNS servers are correctly configured."
categories: ClassVar[list[str]] = ["services"] categories: ClassVar[list[str]] = ["services"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip name-server", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip name-server", revision=1)]
@ -139,38 +141,28 @@ class VerifyDNSServers(AntaTest):
dns_servers: list[DnsServer] dns_servers: list[DnsServer]
"""List of DNS servers to verify.""" """List of DNS servers to verify."""
DnsServer: ClassVar[type[DnsServer]] = DnsServer
class DnsServer(BaseModel):
"""Model for a DNS server."""
server_address: IPv4Address | IPv6Address
"""The IPv4/IPv6 address of the DNS server."""
vrf: str = "default"
"""The VRF for the DNS server. Defaults to 'default' if not provided."""
priority: int = Field(ge=0, le=4)
"""The priority of the DNS server from 0 to 4, lower is first."""
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyDNSServers.""" """Main test function for VerifyDNSServers."""
command_output = self.instance_commands[0].json_output["nameServerConfigs"]
self.result.is_success() self.result.is_success()
command_output = self.instance_commands[0].json_output["nameServerConfigs"]
for server in self.inputs.dns_servers: for server in self.inputs.dns_servers:
address = str(server.server_address) address = str(server.server_address)
vrf = server.vrf vrf = server.vrf
priority = server.priority priority = server.priority
input_dict = {"ipAddr": address, "vrf": vrf} input_dict = {"ipAddr": address, "vrf": vrf}
if get_item(command_output, "ipAddr", address) is None: # Check if the DNS server is configured with specified VRF.
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: if (output := get_dict_superset(command_output, input_dict)) is None:
self.result.is_failure(f"DNS server `{address}` is not configured with VRF `{vrf}`.") self.result.is_failure(f"{server} - Not configured")
continue continue
# Check if the DNS server priority matches with expected.
if output["priority"] != priority: if output["priority"] != priority:
self.result.is_failure(f"For DNS server `{address}`, the expected priority is `{priority}`, but `{output['priority']}` was found instead.") self.result.is_failure(f"{server} - Incorrect priority - Priority: {output['priority']}")
class VerifyErrdisableRecovery(AntaTest): class VerifyErrdisableRecovery(AntaTest):
@ -194,8 +186,6 @@ class VerifyErrdisableRecovery(AntaTest):
``` ```
""" """
name = "VerifyErrdisableRecovery"
description = "Verifies the errdisable recovery reason, status, and interval."
categories: ClassVar[list[str]] = ["services"] categories: ClassVar[list[str]] = ["services"]
# NOTE: Only `text` output format is supported for this command # NOTE: Only `text` output format is supported for this command
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show errdisable recovery", ofmt="text")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show errdisable recovery", ofmt="text")]

View file

@ -7,10 +7,11 @@
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar from typing import TYPE_CHECKING, ClassVar, get_args
from anta.custom_types import PositiveInteger from anta.custom_types import PositiveInteger, SnmpErrorCounter, SnmpPdu
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
from anta.tools import get_value
if TYPE_CHECKING: if TYPE_CHECKING:
from anta.models import AntaTemplate from anta.models import AntaTemplate
@ -33,7 +34,6 @@ class VerifySnmpStatus(AntaTest):
``` ```
""" """
name = "VerifySnmpStatus"
description = "Verifies if the SNMP agent is enabled." description = "Verifies if the SNMP agent is enabled."
categories: ClassVar[list[str]] = ["snmp"] categories: ClassVar[list[str]] = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
@ -72,7 +72,6 @@ class VerifySnmpIPv4Acl(AntaTest):
``` ```
""" """
name = "VerifySnmpIPv4Acl"
description = "Verifies if the SNMP agent has IPv4 ACL(s) configured." description = "Verifies if the SNMP agent has IPv4 ACL(s) configured."
categories: ClassVar[list[str]] = ["snmp"] categories: ClassVar[list[str]] = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv4 access-list summary", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv4 access-list summary", revision=1)]
@ -121,7 +120,6 @@ class VerifySnmpIPv6Acl(AntaTest):
``` ```
""" """
name = "VerifySnmpIPv6Acl"
description = "Verifies if the SNMP agent has IPv6 ACL(s) configured." description = "Verifies if the SNMP agent has IPv6 ACL(s) configured."
categories: ClassVar[list[str]] = ["snmp"] categories: ClassVar[list[str]] = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv6 access-list summary", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv6 access-list summary", revision=1)]
@ -169,8 +167,6 @@ class VerifySnmpLocation(AntaTest):
``` ```
""" """
name = "VerifySnmpLocation"
description = "Verifies the SNMP location of a device."
categories: ClassVar[list[str]] = ["snmp"] categories: ClassVar[list[str]] = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
@ -183,8 +179,12 @@ class VerifySnmpLocation(AntaTest):
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifySnmpLocation.""" """Main test function for VerifySnmpLocation."""
location = self.instance_commands[0].json_output["location"]["location"] # Verifies the SNMP location is configured.
if not (location := get_value(self.instance_commands[0].json_output, "location.location")):
self.result.is_failure("SNMP location is not configured.")
return
# Verifies the expected SNMP location.
if location != self.inputs.location: if location != self.inputs.location:
self.result.is_failure(f"Expected `{self.inputs.location}` as the location, but found `{location}` instead.") self.result.is_failure(f"Expected `{self.inputs.location}` as the location, but found `{location}` instead.")
else: else:
@ -208,8 +208,6 @@ class VerifySnmpContact(AntaTest):
``` ```
""" """
name = "VerifySnmpContact"
description = "Verifies the SNMP contact of a device."
categories: ClassVar[list[str]] = ["snmp"] categories: ClassVar[list[str]] = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
@ -222,9 +220,122 @@ class VerifySnmpContact(AntaTest):
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifySnmpContact.""" """Main test function for VerifySnmpContact."""
contact = self.instance_commands[0].json_output["contact"]["contact"] # Verifies the SNMP contact is configured.
if not (contact := get_value(self.instance_commands[0].json_output, "contact.contact")):
self.result.is_failure("SNMP contact is not configured.")
return
# Verifies the expected SNMP contact.
if contact != self.inputs.contact: if contact != self.inputs.contact:
self.result.is_failure(f"Expected `{self.inputs.contact}` as the contact, but found `{contact}` instead.") self.result.is_failure(f"Expected `{self.inputs.contact}` as the contact, but found `{contact}` instead.")
else: else:
self.result.is_success() self.result.is_success()
class VerifySnmpPDUCounters(AntaTest):
"""Verifies the SNMP PDU counters.
By default, all SNMP PDU counters will be checked for any non-zero values.
An optional list of specific SNMP PDU(s) can be provided for granular testing.
Expected Results
----------------
* Success: The test will pass if the SNMP PDU counter(s) are non-zero/greater than zero.
* Failure: The test will fail if the SNMP PDU counter(s) are zero/None/Not Found.
Examples
--------
```yaml
anta.tests.snmp:
- VerifySnmpPDUCounters:
pdus:
- outTrapPdus
- inGetNextPdus
```
"""
categories: ClassVar[list[str]] = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifySnmpPDUCounters test."""
pdus: list[SnmpPdu] | None = None
"""Optional list of SNMP PDU counters to be verified. If not provided, test will verifies all PDU counters."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySnmpPDUCounters."""
snmp_pdus = self.inputs.pdus
command_output = self.instance_commands[0].json_output
# Verify SNMP PDU counters.
if not (pdu_counters := get_value(command_output, "counters")):
self.result.is_failure("SNMP counters not found.")
return
# In case SNMP PDUs not provided, It will check all the update error counters.
if not snmp_pdus:
snmp_pdus = list(get_args(SnmpPdu))
failures = {pdu: value for pdu in snmp_pdus if (value := pdu_counters.get(pdu, "Not Found")) == "Not Found" or value == 0}
# Check if any failures
if not failures:
self.result.is_success()
else:
self.result.is_failure(f"The following SNMP PDU counters are not found or have zero PDU counters:\n{failures}")
class VerifySnmpErrorCounters(AntaTest):
"""Verifies the SNMP error counters.
By default, all error counters will be checked for any non-zero values.
An optional list of specific error counters can be provided for granular testing.
Expected Results
----------------
* Success: The test will pass if the SNMP error counter(s) are zero/None.
* Failure: The test will fail if the SNMP error counter(s) are non-zero/not None/Not Found or is not configured.
Examples
--------
```yaml
anta.tests.snmp:
- VerifySnmpErrorCounters:
error_counters:
- inVersionErrs
- inBadCommunityNames
"""
categories: ClassVar[list[str]] = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifySnmpErrorCounters test."""
error_counters: list[SnmpErrorCounter] | None = None
"""Optional list of SNMP error counters to be verified. If not provided, test will verifies all error counters."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySnmpErrorCounters."""
error_counters = self.inputs.error_counters
command_output = self.instance_commands[0].json_output
# Verify SNMP PDU counters.
if not (snmp_counters := get_value(command_output, "counters")):
self.result.is_failure("SNMP counters not found.")
return
# In case SNMP error counters not provided, It will check all the error counters.
if not error_counters:
error_counters = list(get_args(SnmpErrorCounter))
error_counters_not_ok = {counter: value for counter in error_counters if (value := snmp_counters.get(counter))}
# Check if any failures
if not error_counters_not_ok:
self.result.is_success()
else:
self.result.is_failure(f"The following SNMP error counters are not found or have non-zero error counters:\n{error_counters_not_ok}")

View file

@ -34,7 +34,6 @@ class VerifyEOSVersion(AntaTest):
``` ```
""" """
name = "VerifyEOSVersion"
description = "Verifies the EOS version of the device." description = "Verifies the EOS version of the device."
categories: ClassVar[list[str]] = ["software"] categories: ClassVar[list[str]] = ["software"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)]
@ -74,7 +73,6 @@ class VerifyTerminAttrVersion(AntaTest):
``` ```
""" """
name = "VerifyTerminAttrVersion"
description = "Verifies the TerminAttr version of the device." description = "Verifies the TerminAttr version of the device."
categories: ClassVar[list[str]] = ["software"] categories: ClassVar[list[str]] = ["software"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)]
@ -112,8 +110,6 @@ class VerifyEOSExtensions(AntaTest):
``` ```
""" """
name = "VerifyEOSExtensions"
description = "Verifies that all EOS extensions installed on the device are enabled for boot persistence."
categories: ClassVar[list[str]] = ["software"] categories: ClassVar[list[str]] = ["software"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaCommand(command="show extensions", revision=2), AntaCommand(command="show extensions", revision=2),

View file

@ -7,7 +7,7 @@
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from typing import ClassVar, Literal from typing import Any, ClassVar, Literal
from pydantic import Field from pydantic import Field
@ -36,8 +36,6 @@ class VerifySTPMode(AntaTest):
``` ```
""" """
name = "VerifySTPMode"
description = "Verifies the configured STP mode for a provided list of VLAN(s)."
categories: ClassVar[list[str]] = ["stp"] categories: ClassVar[list[str]] = ["stp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree vlan {vlan}", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree vlan {vlan}", revision=1)]
@ -93,8 +91,6 @@ class VerifySTPBlockedPorts(AntaTest):
``` ```
""" """
name = "VerifySTPBlockedPorts"
description = "Verifies there is no STP blocked ports."
categories: ClassVar[list[str]] = ["stp"] categories: ClassVar[list[str]] = ["stp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree blockedports", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree blockedports", revision=1)]
@ -126,8 +122,6 @@ class VerifySTPCounters(AntaTest):
``` ```
""" """
name = "VerifySTPCounters"
description = "Verifies there is no errors in STP BPDU packets."
categories: ClassVar[list[str]] = ["stp"] categories: ClassVar[list[str]] = ["stp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree counters", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree counters", revision=1)]
@ -163,7 +157,6 @@ class VerifySTPForwardingPorts(AntaTest):
``` ```
""" """
name = "VerifySTPForwardingPorts"
description = "Verifies that all interfaces are forwarding for a provided list of VLAN(s)." description = "Verifies that all interfaces are forwarding for a provided list of VLAN(s)."
categories: ClassVar[list[str]] = ["stp"] categories: ClassVar[list[str]] = ["stp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree topology vlan {vlan} status", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree topology vlan {vlan} status", revision=1)]
@ -222,8 +215,6 @@ class VerifySTPRootPriority(AntaTest):
``` ```
""" """
name = "VerifySTPRootPriority"
description = "Verifies the STP root priority for a provided list of VLAN or MST instance ID(s)."
categories: ClassVar[list[str]] = ["stp"] categories: ClassVar[list[str]] = ["stp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree root detail", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree root detail", revision=1)]
@ -259,3 +250,62 @@ class VerifySTPRootPriority(AntaTest):
self.result.is_failure(f"The following instance(s) have the wrong STP root priority configured: {wrong_priority_instances}") self.result.is_failure(f"The following instance(s) have the wrong STP root priority configured: {wrong_priority_instances}")
else: else:
self.result.is_success() self.result.is_success()
class VerifyStpTopologyChanges(AntaTest):
"""Verifies the number of changes across all interfaces in the Spanning Tree Protocol (STP) topology is below a threshold.
Expected Results
----------------
* Success: The test will pass if the total number of changes across all interfaces is less than the specified threshold.
* Failure: The test will fail if the total number of changes across all interfaces meets or exceeds the specified threshold,
indicating potential instability in the topology.
Examples
--------
```yaml
anta.tests.stp:
- VerifyStpTopologyChanges:
threshold: 10
```
"""
categories: ClassVar[list[str]] = ["stp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree topology status detail", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifyStpTopologyChanges test."""
threshold: int
"""The threshold number of changes in the STP topology."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyStpTopologyChanges."""
failures: dict[str, Any] = {"topologies": {}}
command_output = self.instance_commands[0].json_output
stp_topologies = command_output.get("topologies", {})
# verifies all available topologies except the "NoStp" topology.
stp_topologies.pop("NoStp", None)
# Verify the STP topology(s).
if not stp_topologies:
self.result.is_failure("STP is not configured.")
return
# Verifies the number of changes across all interfaces
for topology, topology_details in stp_topologies.items():
interfaces = {
interface: {"Number of changes": num_of_changes}
for interface, details in topology_details.get("interfaces", {}).items()
if (num_of_changes := details.get("numChanges")) > self.inputs.threshold
}
if interfaces:
failures["topologies"][topology] = interfaces
if failures["topologies"]:
self.result.is_failure(f"The following STP topologies are not configured or number of changes not within the threshold:\n{failures}")
else:
self.result.is_success()

View file

@ -7,32 +7,36 @@
# mypy: disable-error-code=attr-defined # mypy: disable-error-code=attr-defined
from __future__ import annotations from __future__ import annotations
from ipaddress import IPv4Address
from typing import ClassVar from typing import ClassVar
from pydantic import BaseModel from anta.decorators import deprecated_test_class
from anta.input_models.stun import StunClientTranslation
from anta.custom_types import Port
from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_failed_logs, get_value from anta.tools import get_value
class VerifyStunClient(AntaTest): class VerifyStunClientTranslation(AntaTest):
""" """Verifies the translation for a source address on a STUN client.
Verifies the configuration of the STUN client, specifically the IPv4 source address and port.
Optionally, it can also verify the public address and port. 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 Expected Results
---------------- ----------------
* Success: The test will pass if the STUN client is correctly configured with the specified IPv4 source address/port and public address/port. * Success: If all of the following conditions are met:
* Failure: The test will fail if the STUN client is not configured or if the IPv4 source address, public address, or port details are incorrect. - 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 Examples
-------- --------
```yaml ```yaml
anta.tests.stun: anta.tests.stun:
- VerifyStunClient: - VerifyStunClientTranslation:
stun_clients: stun_clients:
- source_address: 172.18.3.2 - source_address: 172.18.3.2
public_address: 172.18.3.21 public_address: 172.18.3.21
@ -45,27 +49,15 @@ class VerifyStunClient(AntaTest):
``` ```
""" """
name = "VerifyStunClient"
description = "Verifies the STUN client is configured with the specified IPv4 source address and port. Validate the public IP and port if provided."
categories: ClassVar[list[str]] = ["stun"] categories: ClassVar[list[str]] = ["stun"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show stun client translations {source_address} {source_port}")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show stun client translations {source_address} {source_port}", revision=1)]
class Input(AntaTest.Input): class Input(AntaTest.Input):
"""Input model for the VerifyStunClient test.""" """Input model for the VerifyStunClientTranslation test."""
stun_clients: list[ClientAddress] stun_clients: list[StunClientTranslation]
"""List of STUN clients."""
class ClientAddress(BaseModel): StunClientTranslation: ClassVar[type[StunClientTranslation]] = StunClientTranslation
"""Source and public address/port details of STUN client."""
source_address: IPv4Address
"""IPv4 source address of STUN client."""
source_port: Port = 4500
"""Source port number for STUN client."""
public_address: IPv4Address | None = None
"""Optional IPv4 public address of STUN client."""
public_port: Port | None = None
"""Optional public port number for STUN client."""
def render(self, template: AntaTemplate) -> list[AntaCommand]: def render(self, template: AntaTemplate) -> list[AntaCommand]:
"""Render the template for each STUN translation.""" """Render the template for each STUN translation."""
@ -73,45 +65,90 @@ class VerifyStunClient(AntaTest):
@AntaTest.anta_test @AntaTest.anta_test
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyStunClient.""" """Main test function for VerifyStunClientTranslation."""
self.result.is_success() self.result.is_success()
# Iterate over each command output and corresponding client input # Iterate over each command output and corresponding client input
for command, client_input in zip(self.instance_commands, self.inputs.stun_clients): for command, client_input in zip(self.instance_commands, self.inputs.stun_clients):
bindings = command.json_output["bindings"] bindings = command.json_output["bindings"]
source_address = str(command.params.source_address) input_public_address = client_input.public_address
source_port = command.params.source_port 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 no bindings are found for the STUN client, mark the test as a failure and continue with the next client
if not bindings: if not bindings:
self.result.is_failure(f"STUN client transaction for source `{source_address}:{source_port}` is not found.") self.result.is_failure(f"{client_input} - STUN client translation not found.")
continue continue
# Extract the public address and port from the client input
public_address = client_input.public_address
public_port = client_input.public_port
# Extract the transaction ID from the bindings # Extract the transaction ID from the bindings
transaction_id = next(iter(bindings.keys())) transaction_id = next(iter(bindings.keys()))
# Prepare the actual and expected STUN data for comparison # Verifying the public address if provided
actual_stun_data = { if input_public_address and str(input_public_address) != (actual_public_address := get_value(bindings, f"{transaction_id}.publicAddress.ip")):
"source ip": get_value(bindings, f"{transaction_id}.sourceAddress.ip"), self.result.is_failure(f"{client_input} - Incorrect public-facing address - Expected: {input_public_address} Actual: {actual_public_address}")
"source port": get_value(bindings, f"{transaction_id}.sourceAddress.port"),
}
expected_stun_data = {"source ip": source_address, "source port": source_port}
# If public address is provided, add it to the actual and expected STUN data # Verifying the public port if provided
if public_address is not None: if input_public_port and input_public_port != (actual_public_port := get_value(bindings, f"{transaction_id}.publicAddress.port")):
actual_stun_data["public ip"] = get_value(bindings, f"{transaction_id}.publicAddress.ip") self.result.is_failure(f"{client_input} - Incorrect public-facing port - Expected: {input_public_port} Actual: {actual_public_port}")
expected_stun_data["public ip"] = str(public_address)
# If public port is provided, add it to the actual and expected STUN data
if public_port is not None:
actual_stun_data["public port"] = get_value(bindings, f"{transaction_id}.publicAddress.port")
expected_stun_data["public port"] = public_port
# If the actual STUN data does not match the expected STUN data, mark the test as failure @deprecated_test_class(new_tests=["VerifyStunClientTranslation"], removal_in_version="v2.0.0")
if actual_stun_data != expected_stun_data: class VerifyStunClient(VerifyStunClientTranslation):
failed_log = get_failed_logs(expected_stun_data, actual_stun_data) """(Deprecated) Verifies the translation for a source address on a STUN client.
self.result.is_failure(f"For STUN source `{source_address}:{source_port}`:{failed_log}")
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()

View file

@ -11,7 +11,9 @@ import re
from typing import TYPE_CHECKING, ClassVar from typing import TYPE_CHECKING, ClassVar
from anta.custom_types import PositiveInteger from anta.custom_types import PositiveInteger
from anta.input_models.system import NTPServer
from anta.models import AntaCommand, AntaTest from anta.models import AntaCommand, AntaTest
from anta.tools import get_value
if TYPE_CHECKING: if TYPE_CHECKING:
from anta.models import AntaTemplate from anta.models import AntaTemplate
@ -38,7 +40,6 @@ class VerifyUptime(AntaTest):
``` ```
""" """
name = "VerifyUptime"
description = "Verifies the device uptime." description = "Verifies the device uptime."
categories: ClassVar[list[str]] = ["system"] categories: ClassVar[list[str]] = ["system"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show uptime", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show uptime", revision=1)]
@ -76,8 +77,6 @@ class VerifyReloadCause(AntaTest):
``` ```
""" """
name = "VerifyReloadCause"
description = "Verifies the last reload cause of the device."
categories: ClassVar[list[str]] = ["system"] categories: ClassVar[list[str]] = ["system"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show reload cause", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show reload cause", revision=1)]
@ -85,9 +84,6 @@ class VerifyReloadCause(AntaTest):
def test(self) -> None: def test(self) -> None:
"""Main test function for VerifyReloadCause.""" """Main test function for VerifyReloadCause."""
command_output = self.instance_commands[0].json_output command_output = self.instance_commands[0].json_output
if "resetCauses" not in command_output:
self.result.is_error(message="No reload causes available")
return
if len(command_output["resetCauses"]) == 0: if len(command_output["resetCauses"]) == 0:
# No reload causes # No reload causes
self.result.is_success() self.result.is_success()
@ -111,19 +107,18 @@ class VerifyCoredump(AntaTest):
* Success: The test will pass if there are NO core dump(s) in /var/core. * Success: The test will pass if there are NO core dump(s) in /var/core.
* Failure: The test will fail if there are core dump(s) in /var/core. * Failure: The test will fail if there are core dump(s) in /var/core.
Info Notes
---- -----
* This test will NOT check for minidump(s) generated by certain agents in /var/core/minidump. * This test will NOT check for minidump(s) generated by certain agents in /var/core/minidump.
Examples Examples
-------- --------
```yaml ```yaml
anta.tests.system: anta.tests.system:
- VerifyCoreDump: - VerifyCoredump:
``` ```
""" """
name = "VerifyCoredump"
description = "Verifies there are no core dump files." description = "Verifies there are no core dump files."
categories: ClassVar[list[str]] = ["system"] categories: ClassVar[list[str]] = ["system"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system coredump", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system coredump", revision=1)]
@ -142,7 +137,7 @@ class VerifyCoredump(AntaTest):
class VerifyAgentLogs(AntaTest): class VerifyAgentLogs(AntaTest):
"""Verifies that no agent crash reports are present on the device. """Verifies there are no agent crash reports.
Expected Results Expected Results
---------------- ----------------
@ -157,8 +152,6 @@ class VerifyAgentLogs(AntaTest):
``` ```
""" """
name = "VerifyAgentLogs"
description = "Verifies there are no agent crash reports."
categories: ClassVar[list[str]] = ["system"] categories: ClassVar[list[str]] = ["system"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show agent logs crash", ofmt="text")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show agent logs crash", ofmt="text")]
@ -190,8 +183,6 @@ class VerifyCPUUtilization(AntaTest):
``` ```
""" """
name = "VerifyCPUUtilization"
description = "Verifies whether the CPU utilization is below 75%."
categories: ClassVar[list[str]] = ["system"] categories: ClassVar[list[str]] = ["system"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show processes top once", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show processes top once", revision=1)]
@ -222,8 +213,6 @@ class VerifyMemoryUtilization(AntaTest):
``` ```
""" """
name = "VerifyMemoryUtilization"
description = "Verifies whether the memory utilization is below 75%."
categories: ClassVar[list[str]] = ["system"] categories: ClassVar[list[str]] = ["system"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)]
@ -254,8 +243,6 @@ class VerifyFileSystemUtilization(AntaTest):
``` ```
""" """
name = "VerifyFileSystemUtilization"
description = "Verifies that no partition is utilizing more than 75% of its disk space."
categories: ClassVar[list[str]] = ["system"] categories: ClassVar[list[str]] = ["system"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="bash timeout 10 df -h", ofmt="text")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="bash timeout 10 df -h", ofmt="text")]
@ -285,7 +272,6 @@ class VerifyNTP(AntaTest):
``` ```
""" """
name = "VerifyNTP"
description = "Verifies if NTP is synchronised." description = "Verifies if NTP is synchronised."
categories: ClassVar[list[str]] = ["system"] categories: ClassVar[list[str]] = ["system"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ntp status", ofmt="text")] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ntp status", ofmt="text")]
@ -299,3 +285,69 @@ class VerifyNTP(AntaTest):
else: else:
data = command_output.split("\n")[0] data = command_output.split("\n")[0]
self.result.is_failure(f"The device is not synchronized with the configured NTP server(s): '{data}'") self.result.is_failure(f"The device is not synchronized with the configured NTP server(s): '{data}'")
class VerifyNTPAssociations(AntaTest):
"""Verifies the Network Time Protocol (NTP) associations.
Expected Results
----------------
* Success: The test will pass if the Primary NTP server (marked as preferred) has the condition 'sys.peer' and
all other NTP servers have the condition 'candidate'.
* Failure: The test will fail if the Primary NTP server (marked as preferred) does not have the condition 'sys.peer' or
if any other NTP server does not have the condition 'candidate'.
Examples
--------
```yaml
anta.tests.system:
- VerifyNTPAssociations:
ntp_servers:
- server_address: 1.1.1.1
preferred: True
stratum: 1
- server_address: 2.2.2.2
stratum: 2
- server_address: 3.3.3.3
stratum: 2
```
"""
categories: ClassVar[list[str]] = ["system"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ntp associations")]
class Input(AntaTest.Input):
"""Input model for the VerifyNTPAssociations test."""
ntp_servers: list[NTPServer]
"""List of NTP servers."""
NTPServer: ClassVar[type[NTPServer]] = NTPServer
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyNTPAssociations."""
self.result.is_success()
if not (peers := get_value(self.instance_commands[0].json_output, "peers")):
self.result.is_failure("No NTP peers configured")
return
# Iterate over each NTP server.
for ntp_server in self.inputs.ntp_servers:
server_address = str(ntp_server.server_address)
# We check `peerIpAddr` in the peer details - covering IPv4Address input, or the peer key - covering Hostname input.
matching_peer = next((peer for peer, peer_details in peers.items() if (server_address in {peer_details["peerIpAddr"], peer})), None)
if not matching_peer:
self.result.is_failure(f"{ntp_server} - Not configured")
continue
# Collecting the expected/actual NTP peer details.
exp_condition = "sys.peer" if ntp_server.preferred else "candidate"
exp_stratum = ntp_server.stratum
act_condition = get_value(peers[matching_peer], "condition")
act_stratum = get_value(peers[matching_peer], "stratumLevel")
if act_condition != exp_condition or act_stratum != exp_stratum:
self.result.is_failure(f"{ntp_server} - Bad association - Condition: {act_condition}, Stratum: {act_stratum}")

View file

@ -38,7 +38,6 @@ class VerifyVlanInternalPolicy(AntaTest):
``` ```
""" """
name = "VerifyVlanInternalPolicy"
description = "Verifies the VLAN internal allocation policy and the range of VLANs." description = "Verifies the VLAN internal allocation policy and the range of VLANs."
categories: ClassVar[list[str]] = ["vlan"] categories: ClassVar[list[str]] = ["vlan"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vlan internal allocation policy", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vlan internal allocation policy", revision=1)]

View file

@ -23,8 +23,8 @@ if TYPE_CHECKING:
class VerifyVxlan1Interface(AntaTest): class VerifyVxlan1Interface(AntaTest):
"""Verifies if the Vxlan1 interface is configured and 'up/up'. """Verifies if the Vxlan1 interface is configured and 'up/up'.
Warning Warnings
------- --------
The name of this test has been updated from 'VerifyVxlan' for better representation. The name of this test has been updated from 'VerifyVxlan' for better representation.
Expected Results Expected Results
@ -41,7 +41,6 @@ class VerifyVxlan1Interface(AntaTest):
``` ```
""" """
name = "VerifyVxlan1Interface"
description = "Verifies the Vxlan1 interface status." description = "Verifies the Vxlan1 interface status."
categories: ClassVar[list[str]] = ["vxlan"] categories: ClassVar[list[str]] = ["vxlan"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces description", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces description", revision=1)]
@ -65,7 +64,7 @@ class VerifyVxlan1Interface(AntaTest):
class VerifyVxlanConfigSanity(AntaTest): class VerifyVxlanConfigSanity(AntaTest):
"""Verifies that no issues are detected with the VXLAN configuration. """Verifies there are no VXLAN config-sanity inconsistencies.
Expected Results Expected Results
---------------- ----------------
@ -81,8 +80,6 @@ class VerifyVxlanConfigSanity(AntaTest):
``` ```
""" """
name = "VerifyVxlanConfigSanity"
description = "Verifies there are no VXLAN config-sanity inconsistencies."
categories: ClassVar[list[str]] = ["vxlan"] categories: ClassVar[list[str]] = ["vxlan"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan config-sanity", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan config-sanity", revision=1)]
@ -124,8 +121,6 @@ class VerifyVxlanVniBinding(AntaTest):
``` ```
""" """
name = "VerifyVxlanVniBinding"
description = "Verifies the VNI-VLAN bindings of the Vxlan1 interface."
categories: ClassVar[list[str]] = ["vxlan"] categories: ClassVar[list[str]] = ["vxlan"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan vni", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan vni", revision=1)]
@ -187,8 +182,6 @@ class VerifyVxlanVtep(AntaTest):
``` ```
""" """
name = "VerifyVxlanVtep"
description = "Verifies the VTEP peers of the Vxlan1 interface"
categories: ClassVar[list[str]] = ["vxlan"] categories: ClassVar[list[str]] = ["vxlan"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan vtep", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan vtep", revision=1)]
@ -238,8 +231,6 @@ class VerifyVxlan1ConnSettings(AntaTest):
``` ```
""" """
name = "VerifyVxlan1ConnSettings"
description = "Verifies the interface vxlan1 source interface and UDP port."
categories: ClassVar[list[str]] = ["vxlan"] categories: ClassVar[list[str]] = ["vxlan"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces", revision=1)] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces", revision=1)]

View file

@ -8,10 +8,13 @@ from __future__ import annotations
import cProfile import cProfile
import os import os
import pstats import pstats
import re
from functools import wraps from functools import wraps
from time import perf_counter from time import perf_counter
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
from anta.constants import ACRONYM_CATEGORIES
from anta.custom_types import REGEXP_PATH_MARKERS
from anta.logger import format_td from anta.logger import format_td
if TYPE_CHECKING: if TYPE_CHECKING:
@ -32,14 +35,17 @@ def get_failed_logs(expected_output: dict[Any, Any], actual_output: dict[Any, An
Returns the failed log or an empty string if there is no difference between the expected and actual output. Returns the failed log or an empty string if there is no difference between the expected and actual output.
Args: Parameters
---- ----------
expected_output (dict): Expected output of a test. expected_output
actual_output (dict): Actual output of a test Expected output of a test.
actual_output
Actual output of a test
Returns Returns
------- -------
str: Failed log of a test. str
Failed log of a test.
""" """
failed_logs = [] failed_logs = []
@ -65,18 +71,20 @@ def custom_division(numerator: float, denominator: float) -> int | float:
Parameters Parameters
---------- ----------
numerator: The numerator. numerator
denominator: The denominator. The numerator.
denominator
The denominator.
Returns Returns
------- -------
Union[int, float]: The result of the division. Union[int, float]
The result of the division.
""" """
result = numerator / denominator result = numerator / denominator
return int(result) if result.is_integer() else result return int(result) if result.is_integer() else result
# pylint: disable=too-many-arguments
def get_dict_superset( def get_dict_superset(
list_of_dicts: list[dict[Any, Any]], list_of_dicts: list[dict[Any, Any]],
input_dict: dict[Any, Any], input_dict: dict[Any, Any],
@ -86,8 +94,7 @@ def get_dict_superset(
*, *,
required: bool = False, required: bool = False,
) -> Any: ) -> Any:
""" """Get the first dictionary from a list of dictionaries that is a superset of the input dict.
Get the first dictionary from a list of dictionaries that is a superset of the input dict.
Returns the supplied default value or None if there is no match and "required" is False. Returns the supplied default value or None if there is no match and "required" is False.
@ -136,7 +143,6 @@ def get_dict_superset(
return default return default
# pylint: disable=too-many-arguments
def get_value( def get_value(
dictionary: dict[Any, Any], dictionary: dict[Any, Any],
key: str, key: str,
@ -193,7 +199,6 @@ def get_value(
return value return value
# pylint: disable=too-many-arguments
def get_item( def get_item(
list_of_dicts: list[dict[Any, Any]], list_of_dicts: list[dict[Any, Any]],
key: Any, key: Any,
@ -302,13 +307,15 @@ def cprofile(sort_by: str = "cumtime") -> Callable[[F], F]:
profile is conditionally enabled based on the presence of ANTA_CPROFILE environment variable. profile is conditionally enabled based on the presence of ANTA_CPROFILE environment variable.
Expect to decorate an async function. Expect to decorate an async function.
Args: Parameters
---- ----------
sort_by (str): The criterion to sort the profiling results. Default is 'cumtime'. sort_by
The criterion to sort the profiling results. Default is 'cumtime'.
Returns Returns
------- -------
Callable: The decorated function with conditional profiling. Callable
The decorated function with conditional profiling.
""" """
def decorator(func: F) -> F: def decorator(func: F) -> F:
@ -318,13 +325,16 @@ def cprofile(sort_by: str = "cumtime") -> Callable[[F], F]:
If `ANTA_CPROFILE` is set, cProfile is enabled and dumps the stats to the file. If `ANTA_CPROFILE` is set, cProfile is enabled and dumps the stats to the file.
Args: Parameters
---- ----------
*args: Arbitrary positional arguments. *args
**kwargs: Arbitrary keyword arguments. Arbitrary positional arguments.
**kwargs
Arbitrary keyword arguments.
Returns Returns
------- -------
Any
The result of the function call. The result of the function call.
""" """
cprofile_file = os.environ.get("ANTA_CPROFILE") cprofile_file = os.environ.get("ANTA_CPROFILE")
@ -346,3 +356,62 @@ def cprofile(sort_by: str = "cumtime") -> Callable[[F], F]:
return cast(F, wrapper) return cast(F, wrapper)
return decorator return decorator
def safe_command(command: str) -> str:
"""Return a sanitized command.
Parameters
----------
command
The command to sanitize.
Returns
-------
str
The sanitized command.
"""
return re.sub(rf"{REGEXP_PATH_MARKERS}", "_", command)
def convert_categories(categories: list[str]) -> list[str]:
"""Convert categories for reports.
If the category is part of the defined acronym, transform it to upper case
otherwise capitalize the first letter.
Parameters
----------
categories
A list of categories
Returns
-------
list[str]
The list of converted categories
"""
if isinstance(categories, list):
return [" ".join(word.upper() if word.lower() in ACRONYM_CATEGORIES else word.title() for word in category.split()) for category in categories]
msg = f"Wrong input type '{type(categories)}' for convert_categories."
raise TypeError(msg)
def format_data(data: dict[str, bool]) -> str:
"""Format a data dictionary for logging purposes.
Parameters
----------
data
A dictionary containing the data to format.
Returns
-------
str
The formatted data.
Example
-------
>>> format_data({"advertised": True, "received": True, "enabled": True})
"Advertised: True, Received: True, Enabled: True"
"""
return ", ".join(f"{k.capitalize()}: {v}" for k, v in data.items())

View file

@ -9,4 +9,4 @@ from .config_session import SessionConfig
from .device import Device from .device import Device
from .errors import EapiCommandError from .errors import EapiCommandError
__all__ = ["Device", "SessionConfig", "EapiCommandError"] __all__ = ["Device", "EapiCommandError", "SessionConfig"]

View file

@ -34,15 +34,19 @@ __all__ = ["port_check_url"]
async def port_check_url(url: URL, timeout: int = 5) -> bool: async def port_check_url(url: URL, timeout: int = 5) -> bool:
""" """Open the port designated by the URL given the timeout in seconds.
Open the port designated by the URL given the timeout in seconds.
If the port is available then return True; False otherwise.
Parameters Parameters
---------- ----------
url: The URL that provides the target system url
timeout: Time to await for the port to open in seconds The URL that provides the target system.
timeout
Time to await for the port to open in seconds.
Returns
-------
bool
If the port is available then return True; False otherwise.
""" """
port = url.port or socket.getservbyname(url.scheme) port = url.port or socket.getservbyname(url.scheme)

View file

@ -29,8 +29,7 @@ __all__ = ["SessionConfig"]
class SessionConfig: class SessionConfig:
""" """Send configuration to a device using the EOS session mechanism.
Send configuration to a device using the EOS session mechanism.
This is the preferred way of managing configuration changes. This is the preferred way of managing configuration changes.
@ -44,16 +43,17 @@ class SessionConfig:
CLI_CFG_FACTORY_RESET = "rollback clean-config" CLI_CFG_FACTORY_RESET = "rollback clean-config"
def __init__(self, device: Device, name: str) -> None: def __init__(self, device: Device, name: str) -> None:
""" """Create a new instance of SessionConfig.
Create a new instance of SessionConfig.
The session config instance bound The session config instance bound
to the given device instance, and using the session `name`. to the given device instance, and using the session `name`.
Parameters Parameters
---------- ----------
device: The associated device instance device
name: The name of the config session The associated device instance.
name
The name of the config session.
""" """
self._device = device self._device = device
self._cli = device.cli self._cli = device.cli
@ -79,19 +79,22 @@ class SessionConfig:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
async def status_all(self) -> dict[str, Any]: async def status_all(self) -> dict[str, Any]:
""" """Get the status of all the session config on the device.
Get the status of all the session config on the device.
Run the following command on the device: Run the following command on the device:
# show configuration sessions detail # show configuration sessions detail
Returns Returns
------- -------
Dict object of native EOS eAPI response; see `status` method for dict[str, Any]
Dictionary of native EOS eAPI response; see `status` method for
details. details.
Examples Examples
-------- --------
Return example:
```
{ {
"maxSavedSessions": 1, "maxSavedSessions": 1,
"maxOpenSessions": 5, "maxOpenSessions": 5,
@ -111,12 +114,12 @@ class SessionConfig:
} }
} }
} }
```
""" """
return await self._cli("show configuration sessions detail") # type: ignore[return-value] # json outformat returns dict[str, Any] return await self._cli("show configuration sessions detail") # type: ignore[return-value] # json outformat returns dict[str, Any]
async def status(self) -> dict[str, Any] | None: async def status(self) -> dict[str, Any] | None:
""" """Get the status of a session config on the device.
Get the status of a session config on the device.
Run the following command on the device: Run the following command on the device:
# show configuration sessions detail # show configuration sessions detail
@ -126,13 +129,15 @@ class SessionConfig:
Returns Returns
------- -------
Dict instance of the session status. If the session does not exist, dict[str, Any] | None
Dictionary instance of the session status. If the session does not exist,
then this method will return None. then this method will return None.
The native eAPI results from JSON output, see example:
Examples Examples
-------- --------
The return is the native eAPI results from JSON output:
```
all results: all results:
{ {
"maxSavedSessions": 1, "maxSavedSessions": 1,
@ -153,34 +158,37 @@ class SessionConfig:
} }
} }
} }
```
if the session name was 'jeremy1', then this method would return If the session name was 'jeremy1', then this method would return:
```
{ {
"instances": {}, "instances": {},
"state": "pending", "state": "pending",
"commitUser": "", "commitUser": "",
"description": "" "description": ""
} }
```
""" """
res = await self.status_all() res = await self.status_all()
return res["sessions"].get(self.name) return res["sessions"].get(self.name)
async def push(self, content: list[str] | str, *, replace: bool = False) -> None: async def push(self, content: list[str] | str, *, replace: bool = False) -> None:
""" """Send the configuration content to the device.
Send the configuration content to the device.
If `replace` is true, then the command "rollback clean-config" is issued If `replace` is true, then the command "rollback clean-config" is issued
before sending the configuration content. before sending the configuration content.
Parameters Parameters
---------- ----------
content: content
The text configuration CLI commands, as a list of strings, that The text configuration CLI commands, as a list of strings, that
will be sent to the device. If the parameter is a string, and not will be sent to the device. If the parameter is a string, and not
a list, then split the string across linebreaks. In either case a list, then split the string across linebreaks. In either case
any empty lines will be discarded before they are send to the any empty lines will be discarded before they are send to the
device. device.
replace: replace
When True, the content will replace the existing configuration When True, the content will replace the existing configuration
on the device. on the device.
""" """
@ -205,13 +213,15 @@ class SessionConfig:
await self._cli(commands=commands) await self._cli(commands=commands)
async def commit(self, timer: str | None = None) -> None: async def commit(self, timer: str | None = None) -> None:
""" """Commit the session config.
Commit the session config.
Run the following command on the device: Run the following command on the device:
# configure session <name> # configure session <name>
# commit # commit
Parameters
----------
timer
If the timer is specified, format is "hh:mm:ss", then a commit timer is If the timer is specified, format is "hh:mm:ss", then a commit timer is
started. A second commit action must be made to confirm the config started. A second commit action must be made to confirm the config
session before the timer expires; otherwise the config-session is session before the timer expires; otherwise the config-session is
@ -225,8 +235,7 @@ class SessionConfig:
await self._cli(command) await self._cli(command)
async def abort(self) -> None: async def abort(self) -> None:
""" """Abort the configuration session.
Abort the configuration session.
Run the following command on the device: Run the following command on the device:
# configure session <name> abort # configure session <name> abort
@ -234,14 +243,14 @@ class SessionConfig:
await self._cli(f"{self._cli_config_session} abort") await self._cli(f"{self._cli_config_session} abort")
async def diff(self) -> str: async def diff(self) -> str:
""" """Return the "diff" of the session config relative to the running config.
Return the "diff" of the session config relative to the running config.
Run the following command on the device: Run the following command on the device:
# show session-config named <name> diffs # show session-config named <name> diffs
Returns Returns
------- -------
str
Return a string in diff-patch format. Return a string in diff-patch format.
References References
@ -251,24 +260,24 @@ class SessionConfig:
return await self._cli(f"show session-config named {self.name} diffs", ofmt="text") # type: ignore[return-value] # text outformat returns str return await self._cli(f"show session-config named {self.name} diffs", ofmt="text") # type: ignore[return-value] # text outformat returns str
async def load_file(self, filename: str, *, replace: bool = False) -> None: async def load_file(self, filename: str, *, replace: bool = False) -> None:
""" """Load the configuration from <filename> into the session configuration.
Load the configuration from <filename> into the session configuration.
If the replace parameter is True then the file contents will replace the existing session config (load-replace). If the replace parameter is True then the file contents will replace the existing session config (load-replace).
Parameters Parameters
---------- ----------
filename: filename
The name of the configuration file. The caller is required to The name of the configuration file. The caller is required to
specify the filesystem, for example, the specify the filesystem, for example, the
filename="flash:thisfile.cfg" filename="flash:thisfile.cfg".
replace: replace
When True, the contents of the file will completely replace the When True, the contents of the file will completely replace the
session config for a load-replace behavior. session config for a load-replace behavior.
Raises Raises
------ ------
RuntimeError
If there are any issues with loading the configuration file then a If there are any issues with loading the configuration file then a
RuntimeError is raised with the error messages content. RuntimeError is raised with the error messages content.
""" """
@ -278,7 +287,7 @@ class SessionConfig:
commands.append(f"copy {filename} session-config") commands.append(f"copy {filename} session-config")
res: list[dict[str, Any]] = await self._cli(commands=commands) # type: ignore[assignment] # JSON outformat of multiple commands returns list[dict[str, Any]] res: list[dict[str, Any]] = await self._cli(commands=commands) # type: ignore[assignment] # JSON outformat of multiple commands returns list[dict[str, Any]]
checks_re = re.compile(r"error|abort|invalid", flags=re.I) checks_re = re.compile(r"error|abort|invalid", flags=re.IGNORECASE)
messages = res[-1]["messages"] messages = res[-1]["messages"]
if any(map(checks_re.search, messages)): if any(map(checks_re.search, messages)):

View file

@ -43,8 +43,7 @@ __all__ = ["Device"]
class Device(httpx.AsyncClient): class Device(httpx.AsyncClient):
""" """Represent the async JSON-RPC client that communicates with an Arista EOS device.
Represent the async JSON-RPC client that communicates with an Arista EOS device.
This class inherits directly from the This class inherits directly from the
httpx.AsyncClient, so any initialization options can be passed directly. httpx.AsyncClient, so any initialization options can be passed directly.
@ -54,7 +53,7 @@ class Device(httpx.AsyncClient):
EAPI_OFMT_OPTIONS = ("json", "text") EAPI_OFMT_OPTIONS = ("json", "text")
EAPI_DEFAULT_OFMT = "json" EAPI_DEFAULT_OFMT = "json"
def __init__( # noqa: PLR0913 # pylint: disable=too-many-arguments def __init__(
self, self,
host: str | None = None, host: str | None = None,
username: str | None = None, username: str | None = None,
@ -63,21 +62,28 @@ class Device(httpx.AsyncClient):
port: str | int | None = None, port: str | int | None = None,
**kwargs: Any, # noqa: ANN401 **kwargs: Any, # noqa: ANN401
) -> None: ) -> None:
""" """Initialize the Device class.
Initialize the Device class.
As a subclass to httpx.AsyncClient, the caller can provide any of those initializers. As a subclass to httpx.AsyncClient, the caller can provide any of those initializers.
Specific parameters for Device class are all optional and described below. Specific parameters for Device class are all optional and described below.
Parameters Parameters
---------- ----------
host: The EOS target device, either hostname (DNS) or ipaddress. host
username: The login user-name; requires the password parameter. The EOS target device, either hostname (DNS) or ipaddress.
password: The login password; requires the username parameter. username
proto: The protocol, http or https, to communicate eAPI with the device. The login user-name; requires the password parameter.
port: If not provided, the proto value is used to look up the associated password
The login password; requires the username parameter.
proto
The protocol, http or https, to communicate eAPI with the device.
port
If not provided, the proto value is used to look up the associated
port (http=80, https=443). If provided, overrides the port used to port (http=80, https=443). If provided, overrides the port used to
communite with the device. communite with the device.
kwargs
Other named keyword arguments, some of them are being used in the function
cf Other Parameters section below, others are just passed as is to the httpx.AsyncClient.
Other Parameters Other Parameters
---------------- ----------------
@ -103,19 +109,19 @@ class Device(httpx.AsyncClient):
self.headers["Content-Type"] = "application/json-rpc" self.headers["Content-Type"] = "application/json-rpc"
async def check_connection(self) -> bool: async def check_connection(self) -> bool:
""" """Check the target device to ensure that the eAPI port is open and accepting connections.
Check the target device to ensure that the eAPI port is open and accepting connections.
It is recommended that a Caller checks the connection before involving cli commands, It is recommended that a Caller checks the connection before involving cli commands,
but this step is not required. but this step is not required.
Returns Returns
------- -------
bool
True when the device eAPI is accessible, False otherwise. True when the device eAPI is accessible, False otherwise.
""" """
return await port_check_url(self.base_url) return await port_check_url(self.base_url)
async def cli( # noqa: PLR0913 # pylint: disable=too-many-arguments async def cli(
self, self,
command: str | dict[str, Any] | None = None, command: str | dict[str, Any] | None = None,
commands: Sequence[str | dict[str, Any]] | None = None, commands: Sequence[str | dict[str, Any]] | None = None,
@ -127,23 +133,22 @@ class Device(httpx.AsyncClient):
expand_aliases: bool = False, expand_aliases: bool = False,
req_id: int | str | None = None, req_id: int | str | None = None,
) -> list[dict[str, Any] | str] | dict[str, Any] | str | None: ) -> list[dict[str, Any] | str] | dict[str, Any] | str | None:
""" """Execute one or more CLI commands.
Execute one or more CLI commands.
Parameters Parameters
---------- ----------
command: command
A single command to execute; results in a single output response A single command to execute; results in a single output response.
commands: commands
A list of commands to execute; results in a list of output responses A list of commands to execute; results in a list of output responses.
ofmt: ofmt
Either 'json' or 'text'; indicates the output format for the CLI commands. Either 'json' or 'text'; indicates the output format for the CLI commands.
version: version
By default the eAPI will use "version 1" for all API object models. By default the eAPI will use "version 1" for all API object models.
This driver will, by default, always set version to "latest" so This driver will, by default, always set version to "latest" so
that the behavior matches the CLI of the device. The caller can that the behavior matches the CLI of the device. The caller can
override the "latest" behavior by explicitly setting the version. override the "latest" behavior by explicitly setting the version.
suppress_error: suppress_error
When not False, then if the execution of the command would-have When not False, then if the execution of the command would-have
raised an EapiCommandError, rather than raising this exception this raised an EapiCommandError, rather than raising this exception this
routine will return the value None. routine will return the value None.
@ -152,13 +157,13 @@ class Device(httpx.AsyncClient):
EapiCommandError, now response would be set to None instead. EapiCommandError, now response would be set to None instead.
response = dev.cli(..., suppress_error=True) response = dev.cli(..., suppress_error=True)
auto_complete: auto_complete
Enabled/disables the command auto-compelete feature of the EAPI. Per the Enabled/disables the command auto-compelete feature of the EAPI. Per the
documentation: documentation:
Allows users to use shorthand commands in eAPI calls. With this Allows users to use shorthand commands in eAPI calls. With this
parameter included a user can send 'sh ver' via eAPI to get the parameter included a user can send 'sh ver' via eAPI to get the
output of 'show version'. output of 'show version'.
expand_aliases: expand_aliases
Enables/disables the command use of User defined alias. Per the Enables/disables the command use of User defined alias. Per the
documentation: documentation:
Allowed users to provide the expandAliases parameter to eAPI Allowed users to provide the expandAliases parameter to eAPI
@ -166,11 +171,12 @@ class Device(httpx.AsyncClient):
For example if an alias is configured as 'sv' for 'show version' For example if an alias is configured as 'sv' for 'show version'
then an API call with sv and the expandAliases parameter will then an API call with sv and the expandAliases parameter will
return the output of show version. return the output of show version.
req_id: req_id
A unique identifier that will be echoed back by the switch. May be a string or number. A unique identifier that will be echoed back by the switch. May be a string or number.
Returns Returns
------- -------
list[dict[str, Any] | str] | dict[str, Any] | str | None
One or List of output responses, per the description above. One or List of output responses, per the description above.
""" """
if not any((command, commands)): if not any((command, commands)):
@ -189,7 +195,7 @@ class Device(httpx.AsyncClient):
return None return None
raise raise
def _jsonrpc_command( # noqa: PLR0913 # pylint: disable=too-many-arguments def _jsonrpc_command(
self, self,
commands: Sequence[str | dict[str, Any]] | None = None, commands: Sequence[str | dict[str, Any]] | None = None,
ofmt: str | None = None, ofmt: str | None = None,
@ -199,7 +205,42 @@ class Device(httpx.AsyncClient):
expand_aliases: bool = False, expand_aliases: bool = False,
req_id: int | str | None = None, req_id: int | str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create the JSON-RPC command dictionary object.""" """Create the JSON-RPC command dictionary object.
Parameters
----------
commands
A list of commands to execute; results in a list of output responses.
ofmt
Either 'json' or 'text'; indicates the output format for the CLI commands.
version
By default the eAPI will use "version 1" for all API object models.
This driver will, by default, always set version to "latest" so
that the behavior matches the CLI of the device. The caller can
override the "latest" behavior by explicitly setting the version.
auto_complete
Enabled/disables the command auto-compelete feature of the EAPI. Per the
documentation:
Allows users to use shorthand commands in eAPI calls. With this
parameter included a user can send 'sh ver' via eAPI to get the
output of 'show version'.
expand_aliases
Enables/disables the command use of User defined alias. Per the
documentation:
Allowed users to provide the expandAliases parameter to eAPI
calls. This allows users to use aliased commands via the API.
For example if an alias is configured as 'sv' for 'show version'
then an API call with sv and the expandAliases parameter will
return the output of show version.
req_id
A unique identifier that will be echoed back by the switch. May be a string or number.
Returns
-------
dict[str, Any]:
dict containing the JSON payload to run the command.
"""
cmd: dict[str, Any] = { cmd: dict[str, Any] = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": "runCmds", "method": "runCmds",
@ -219,12 +260,11 @@ class Device(httpx.AsyncClient):
return cmd return cmd
async def jsonrpc_exec(self, jsonrpc: dict[str, Any]) -> list[dict[str, Any] | str]: async def jsonrpc_exec(self, jsonrpc: dict[str, Any]) -> list[dict[str, Any] | str]:
""" """Execute the JSON-RPC dictionary object.
Execute the JSON-RPC dictionary object.
Parameters Parameters
---------- ----------
jsonrpc: jsonrpc
The JSON-RPC as created by the `meth`:_jsonrpc_command(). The JSON-RPC as created by the `meth`:_jsonrpc_command().
Raises Raises
@ -234,6 +274,7 @@ class Device(httpx.AsyncClient):
Returns Returns
------- -------
list[dict[str, Any] | str]
The list of command results; either dict or text depending on the The list of command results; either dict or text depending on the
JSON-RPC format parameter. JSON-RPC format parameter.
""" """
@ -271,21 +312,27 @@ class Device(httpx.AsyncClient):
len_data = len(cmd_data) len_data = len(cmd_data)
err_at = len_data - 1 err_at = len_data - 1
err_msg = err_data["message"] err_msg = err_data["message"]
failed_cmd = commands[err_at]
raise EapiCommandError( raise EapiCommandError(
passed=[get_output(cmd_data[cmd_i]) for cmd_i, cmd in enumerate(commands[:err_at])], passed=[get_output(cmd_data[cmd_i]) for cmd_i, cmd in enumerate(commands[:err_at])],
failed=commands[err_at]["cmd"], failed=failed_cmd["cmd"] if isinstance(failed_cmd, dict) else failed_cmd,
errors=cmd_data[err_at]["errors"], errors=cmd_data[err_at]["errors"],
errmsg=err_msg, errmsg=err_msg,
not_exec=commands[err_at + 1 :], not_exec=commands[err_at + 1 :],
) )
def config_session(self, name: str) -> SessionConfig: def config_session(self, name: str) -> SessionConfig:
""" """Return a SessionConfig instance bound to this device with the given session name.
return a SessionConfig instance bound to this device with the given session name.
Parameters Parameters
---------- ----------
name: The config-session name name
The config-session name.
Returns
-------
SessionConfig
SessionConfig instance bound to this device with the given session name.
""" """
return SessionConfig(self, name) return SessionConfig(self, name)

View file

@ -12,8 +12,7 @@ import httpx
class EapiCommandError(RuntimeError): class EapiCommandError(RuntimeError):
""" """Exception class for EAPI command errors.
Exception class for EAPI command errors.
Attributes Attributes
---------- ----------
@ -24,7 +23,7 @@ class EapiCommandError(RuntimeError):
not_exec: a list of commands that were not executed not_exec: a list of commands that were not executed
""" """
def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]) -> None: # noqa: PLR0913 # pylint: disable=too-many-arguments def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]) -> None:
"""Initialize for the EapiCommandError exception.""" """Initialize for the EapiCommandError exception."""
self.failed = failed self.failed = failed
self.errmsg = errmsg self.errmsg = errmsg

32
debian/changelog vendored
View file

@ -1,3 +1,35 @@
anta (1.2.0-1) sid; urgency=medium
* Uploading to sid.
* Merging upstream version 1.2.0.
* Updating source url in copyright.
-- Daniel Baumann <daniel.baumann@progress-linux.org> Tue, 14 Jan 2025 11:18:31 +0100
anta (1.1.0-1) sid; urgency=medium
* Uploading to sid.
* Merging upstream version 1.1.0.
* Updating homepage field.
* Updating github urls to new project home.
-- Daniel Baumann <daniel.baumann@progress-linux.org> Tue, 15 Oct 2024 22:33:34 +0200
anta (1.0.0-1) sid; urgency=medium
* Uploading to sid.
* Merging upstream version 1.0.0.
-- Daniel Baumann <daniel.baumann@progress-linux.org> Mon, 17 Jun 2024 11:02:19 +0200
anta (0.15.0-1) sid; urgency=medium
* Uploading to sid.
* Merging upstream version 0.15.0.
* Removing manual depends to python3-aioeapi.
-- Daniel Baumann <daniel.baumann@progress-linux.org> Thu, 23 May 2024 07:06:57 +0200
anta (0.14.0-2) sid; urgency=medium anta (0.14.0-2) sid; urgency=medium
* Uploading to sid. * Uploading to sid.

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