Compare commits
10 commits
b10eb070e3
...
acc044718f
Author | SHA1 | Date | |
---|---|---|---|
acc044718f | |||
6277bef8ef | |||
afeccccd6a | |||
ae7b7df396 | |||
7e78d93566 | |||
6252b03e63 | |||
2044ea6182 | |||
50f8dbf7e8 | |||
3ccac88507 | |||
256a120fdd |
269 changed files with 18497 additions and 8003 deletions
10
.arista/secret_allowlist.yaml
Normal file
10
.arista/secret_allowlist.yaml
Normal 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
|
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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]"
|
||||||
|
|
2
.github/generate_release.py
vendored
2
.github/generate_release.py
vendored
|
@ -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
98
.github/markdownlint.yaml
vendored
Normal 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
0
.github/markdownlintignore
vendored
Normal file
2
.github/release.md
vendored
2
.github/release.md
vendored
|
@ -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
|
||||||
|
|
77
.github/workflows/code-testing.yml
vendored
77
.github/workflows/code-testing.yml
vendored
|
@ -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
22
.github/workflows/codspeed.yml
vendored
Normal 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
|
2
.github/workflows/on-demand.yml
vendored
2
.github/workflows/on-demand.yml
vendored
|
@ -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
|
||||||
|
|
4
.github/workflows/pr-triage.yml
vendored
4
.github/workflows/pr-triage.yml
vendored
|
@ -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:
|
||||||
|
|
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
|
@ -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
15
.github/workflows/secret-scanner.yml
vendored
Normal 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
44
.github/workflows/sonar.yml
vendored
Normal 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 }}
|
22
.gitignore
vendored
22
.gitignore
vendored
|
@ -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
|
||||||
|
|
||||||
|
@ -97,17 +98,4 @@ venv.bak/
|
||||||
/site
|
/site
|
||||||
|
|
||||||
# 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/
|
|
|
@ -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
20
.vscode/settings.json
vendored
|
@ -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"
|
||||||
|
],
|
||||||
|
|
||||||
}
|
}
|
|
@ -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" \
|
||||||
|
|
|
@ -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."
|
||||||
|
|
214
anta/catalog.py
214
anta/catalog.py
|
@ -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,14 +143,14 @@ 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:
|
||||||
```
|
```
|
||||||
<Python module>:
|
<Python module>:
|
||||||
- <AntaTest subclass>:
|
- <AntaTest subclass>:
|
||||||
<AntaTest.Input compliant dictionary>
|
<AntaTest.Input compliant dictionary>
|
||||||
```
|
```
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -147,16 +160,16 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
|
||||||
def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[ModuleType, list[Any]]:
|
def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[ModuleType, list[Any]]:
|
||||||
"""Allow the user to provide a data structure with nested Python modules.
|
"""Allow the user to provide a data structure with nested Python modules.
|
||||||
|
|
||||||
Example:
|
Example
|
||||||
-------
|
-------
|
||||||
```
|
```
|
||||||
anta.tests.routing:
|
anta.tests.routing:
|
||||||
generic:
|
generic:
|
||||||
- <AntaTestDefinition>
|
- <AntaTestDefinition>
|
||||||
bgp:
|
bgp:
|
||||||
- <AntaTestDefinition>
|
- <AntaTestDefinition>
|
||||||
```
|
```
|
||||||
`anta.tests.routing.generic` and `anta.tests.routing.bgp` are importable Python modules.
|
`anta.tests.routing.generic` and `anta.tests.routing.bgp` are importable Python modules.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
modules: dict[ModuleType, list[Any]] = {}
|
modules: dict[ModuleType, list[Any]] = {}
|
||||||
|
@ -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.
|
||||||
If False, returns tests that contain any of the specified tags (union).
|
strict
|
||||||
|
If True, returns only tests that contain all specified tags (intersection).
|
||||||
|
If False, returns tests that contain any of the specified tags (union).
|
||||||
|
|
||||||
Returns
|
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."
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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}",
|
||||||
|
|
|
@ -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,14 +70,16 @@ 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
|
||||||
|
|
||||||
"""
|
"""
|
||||||
template_params = dict(zip(params[::2], params[1::2]))
|
template_params = dict(zip(params[::2], params[1::2]))
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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."""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
create_inventory_from_cvp(cvp_inventory, output)
|
try:
|
||||||
|
create_inventory_from_cvp(cvp_inventory, output)
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
ctx.exit(ExitCode.USAGE_ERROR)
|
||||||
|
|
||||||
|
|
||||||
@click.command
|
@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)
|
||||||
|
|
|
@ -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)
|
||||||
with output.open(mode="w", encoding="UTF-8") as out_fd:
|
try:
|
||||||
out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: i.model_dump(exclude_unset=True)}))
|
with output.open(mode="w", encoding="UTF-8") as out_fd:
|
||||||
logger.info("ANTA inventory file has been created: '%s'", output)
|
out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: yaml.safe_load(i.yaml())}))
|
||||||
|
logger.info("ANTA inventory file has been created: '%s'", output)
|
||||||
|
except OSError as exc:
|
||||||
|
msg = f"Could not write inventory to path '{output}'."
|
||||||
|
raise OSError(msg) from exc
|
||||||
|
|
||||||
|
|
||||||
def create_inventory_from_cvp(inv: list[dict[str, Any]], output: Path) -> None:
|
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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
console.print()
|
|
||||||
console.print(Panel("JSON results", style="cyan"))
|
if output is None:
|
||||||
rich.print_json(results.json)
|
console.print()
|
||||||
if output is not None:
|
console.print(Panel("JSON results", style="cyan"))
|
||||||
with output.open(mode="w", encoding="utf-8") as fout:
|
rich.print_json(results.json)
|
||||||
fout.write(results.json)
|
else:
|
||||||
|
try:
|
||||||
|
with output.open(mode="w", encoding="utf-8") as file:
|
||||||
|
file.write(results.json)
|
||||||
|
console.print(f"JSON results saved to {output} ✅", style="cyan")
|
||||||
|
except OSError:
|
||||||
|
console.print(f"Failed to save JSON results to {output} ❌", style="cyan")
|
||||||
|
ctx.exit(ExitCode.USAGE_ERROR)
|
||||||
|
|
||||||
|
|
||||||
def print_text(ctx: click.Context) -> None:
|
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]
|
||||||
|
|
|
@ -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
28
anta/constants.py
Normal 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."""
|
|
@ -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`."""
|
||||||
|
|
||||||
|
@ -66,9 +68,9 @@ def interface_case_sensitivity(v: str) -> str:
|
||||||
|
|
||||||
Examples
|
Examples
|
||||||
--------
|
--------
|
||||||
- ethernet -> Ethernet
|
- ethernet -> Ethernet
|
||||||
- vlan -> Vlan
|
- vlan -> Vlan
|
||||||
- loopback -> Loopback
|
- loopback -> Loopback
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if isinstance(v, str) and v != "" and not v[0].isupper():
|
if isinstance(v, str) and v != "" and not v[0].isupper():
|
||||||
|
@ -81,10 +83,10 @@ def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str:
|
||||||
|
|
||||||
Examples
|
Examples
|
||||||
--------
|
--------
|
||||||
- IPv4 Unicast
|
- IPv4 Unicast
|
||||||
- L2vpnEVPN
|
- L2vpnEVPN
|
||||||
- ipv4 MPLS Labels
|
- ipv4 MPLS Labels
|
||||||
- ipv4Mplsvpn
|
- ipv4Mplsvpn
|
||||||
|
|
||||||
"""
|
"""
|
||||||
patterns = {
|
patterns = {
|
||||||
|
@ -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",
|
||||||
|
]
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
227
anta/device.py
227
anta/device.py
|
@ -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(
|
||||||
|
|
4
anta/input_models/__init__.py
Normal file
4
anta/input_models/__init__.py
Normal 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
36
anta/input_models/avt.py
Normal 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
37
anta/input_models/bfd.py
Normal 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}"
|
83
anta/input_models/connectivity.py
Normal file
83
anta/input_models/connectivity.py
Normal 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
19
anta/input_models/cvx.py
Normal 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"
|
48
anta/input_models/interfaces.py
Normal file
48
anta/input_models/interfaces.py
Normal 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
|
4
anta/input_models/routing/__init__.py
Normal file
4
anta/input_models/routing/__init__.py
Normal 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."""
|
209
anta/input_models/routing/bgp.py
Normal file
209
anta/input_models/routing/bgp.py
Normal 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}"
|
28
anta/input_models/routing/generic.py
Normal file
28
anta/input_models/routing/generic.py
Normal 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}"
|
61
anta/input_models/security.py
Normal file
61
anta/input_models/security.py
Normal 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)
|
31
anta/input_models/services.py
Normal file
31
anta/input_models/services.py
Normal 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
35
anta/input_models/stun.py
Normal 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}"
|
31
anta/input_models/system.py
Normal file
31
anta/input_models/system.py
Normal 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})"
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
242
anta/models.py
242
anta/models.py
|
@ -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,20 +100,22 @@ 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.
|
||||||
|
|
||||||
Raises
|
Raises
|
||||||
------
|
------
|
||||||
AntaTemplateRenderError
|
AntaTemplateRenderError
|
||||||
If a parameter is missing to render the AntaTemplate instance.
|
If a parameter is missing to render the AntaTemplate instance.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
command = self.template.format(**params)
|
command = self.template.format(**params)
|
||||||
|
@ -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.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -214,9 +230,9 @@ class AntaCommand(BaseModel):
|
||||||
|
|
||||||
Raises
|
Raises
|
||||||
------
|
------
|
||||||
RuntimeError
|
RuntimeError
|
||||||
If the command has not been collected and has not returned an error.
|
If the command has not been collected and has not returned an error.
|
||||||
AntaDevice.collect() must be called before this property.
|
AntaDevice.collect() must be called before this property.
|
||||||
"""
|
"""
|
||||||
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()."
|
||||||
|
@ -225,18 +241,37 @@ 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
|
||||||
------
|
------
|
||||||
RuntimeError
|
RuntimeError
|
||||||
If the command has not been collected and has not returned an error.
|
If the command has not been collected and has not returned an error.
|
||||||
AntaDevice.collect() must be called before this property.
|
AntaDevice.collect() must be called before this property.
|
||||||
|
"""
|
||||||
|
if not self.collected and not self.error:
|
||||||
|
msg = f"Command '{self.command}' has not been collected and has not returned an error. Call AntaDevice.collect()."
|
||||||
|
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
return all("not supported on this hardware platform" not in e for e in self.errors)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def returned_known_eos_error(self) -> bool:
|
||||||
|
"""Return True if the command returned a known_eos_error on the device, False otherwise.
|
||||||
|
|
||||||
|
RuntimeError
|
||||||
|
If the command has not been collected and has not returned an error.
|
||||||
|
AntaDevice.collect() must be called before this property.
|
||||||
"""
|
"""
|
||||||
if not self.collected and not self.error:
|
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 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,12 +434,15 @@ 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
|
||||||
This list must have the same length and order than the `instance_commands` instance attribute.
|
Dictionary of attributes used to instantiate the AntaTest.Input instance.
|
||||||
|
eos_data
|
||||||
|
Populate outputs of the test commands instead of collecting from devices.
|
||||||
|
This list must have the same length and order than the `instance_commands` instance attribute.
|
||||||
"""
|
"""
|
||||||
self.logger: logging.Logger = logging.getLogger(f"{self.module}.{self.__class__.__name__}")
|
self.logger: logging.Logger = logging.getLogger(f"{self.module}.{self.__class__.__name__}")
|
||||||
self.device: AntaDevice = device
|
self.device: AntaDevice = device
|
||||||
|
@ -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.
|
||||||
This list must have the same length and order than the `instance_commands` instance attribute.
|
eos_data
|
||||||
kwargs: Any keyword argument to pass to the test.
|
Populate outputs of the test commands instead of collecting from devices.
|
||||||
|
This list must have the same length and order than the `instance_commands` instance attribute.
|
||||||
|
kwargs
|
||||||
|
Any keyword argument to pass to the test.
|
||||||
|
|
||||||
Returns
|
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
0
anta/py.typed
Normal 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
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
121
anta/reporter/csv_reporter.py
Normal file
121
anta/reporter/csv_reporter.py
Normal 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
|
298
anta/reporter/md_reporter.py
Normal file
298
anta/reporter/md_reporter.py
Normal 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)
|
|
@ -6,69 +6,82 @@
|
||||||
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.
|
||||||
|
|
||||||
Examples
|
Examples
|
||||||
--------
|
--------
|
||||||
Create Inventory:
|
Create Inventory:
|
||||||
|
|
||||||
inventory_anta = AntaInventory.parse(
|
inventory_anta = AntaInventory.parse(
|
||||||
filename='examples/inventory.yml',
|
filename='examples/inventory.yml',
|
||||||
username='ansible',
|
username='ansible',
|
||||||
password='ansible',
|
password='ansible',
|
||||||
|
)
|
||||||
|
|
||||||
|
Create Result Manager:
|
||||||
|
|
||||||
|
manager = ResultManager()
|
||||||
|
|
||||||
|
Run tests for all connected devices:
|
||||||
|
|
||||||
|
for device in inventory_anta.get_inventory().devices:
|
||||||
|
manager.add(
|
||||||
|
VerifyNTP(device=device).test()
|
||||||
|
)
|
||||||
|
manager.add(
|
||||||
|
VerifyEOSVersion(device=device).test(version='4.28.3M')
|
||||||
)
|
)
|
||||||
|
|
||||||
Create Result Manager:
|
Print result in native format:
|
||||||
|
|
||||||
manager = ResultManager()
|
manager.results
|
||||||
|
[
|
||||||
Run tests for all connected devices:
|
TestResult(
|
||||||
|
name="pf1",
|
||||||
for device in inventory_anta.get_inventory().devices:
|
test="VerifyZeroTouch",
|
||||||
manager.add(
|
categories=["configuration"],
|
||||||
VerifyNTP(device=device).test()
|
description="Verifies ZeroTouch is disabled",
|
||||||
)
|
result="success",
|
||||||
manager.add(
|
messages=[],
|
||||||
VerifyEOSVersion(device=device).test(version='4.28.3M')
|
custom_field=None,
|
||||||
)
|
),
|
||||||
|
TestResult(
|
||||||
Print result in native format:
|
name="pf1",
|
||||||
|
test='VerifyNTP',
|
||||||
manager.results
|
categories=["software"],
|
||||||
[
|
categories=['system'],
|
||||||
TestResult(
|
description='Verifies if NTP is synchronised.',
|
||||||
name="pf1",
|
result='failure',
|
||||||
test="VerifyZeroTouch",
|
messages=["The device is not synchronized with the configured NTP server(s): 'NTP is disabled.'"],
|
||||||
categories=["configuration"],
|
custom_field=None,
|
||||||
description="Verifies ZeroTouch is disabled",
|
),
|
||||||
result="success",
|
]
|
||||||
messages=[],
|
|
||||||
custom_field=None,
|
|
||||||
),
|
|
||||||
TestResult(
|
|
||||||
name="pf1",
|
|
||||||
test='VerifyNTP',
|
|
||||||
categories=["software"],
|
|
||||||
categories=['system'],
|
|
||||||
description='Verifies if NTP is synchronised.',
|
|
||||||
result='failure',
|
|
||||||
messages=["The device is not synchronized with the configured NTP server(s): 'NTP is disabled.'"],
|
|
||||||
custom_field=None,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_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}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
198
anta/runner.py
198
anta/runner.py
|
@ -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,42 +26,47 @@ 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
|
||||||
|
|
||||||
|
if os.name == "posix":
|
||||||
|
import resource
|
||||||
|
|
||||||
|
DEFAULT_NOFILE = 16384
|
||||||
|
|
||||||
|
def adjust_rlimit_nofile() -> tuple[int, int]:
|
||||||
|
"""Adjust the maximum number of open file descriptors for the ANTA process.
|
||||||
|
|
||||||
|
The limit is set to the lower of the current hard limit and the value of the ANTA_NOFILE environment variable.
|
||||||
|
|
||||||
|
If the `ANTA_NOFILE` environment variable is not set or is invalid, `DEFAULT_NOFILE` is used.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tuple[int, int]
|
||||||
|
The new soft and hard limits for open file descriptors.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
nofile = int(os.environ.get("ANTA_NOFILE", DEFAULT_NOFILE))
|
||||||
|
except ValueError as exception:
|
||||||
|
logger.warning("The ANTA_NOFILE environment variable value is invalid: %s\nDefault to %s.", exc_to_str(exception), DEFAULT_NOFILE)
|
||||||
|
nofile = DEFAULT_NOFILE
|
||||||
|
|
||||||
|
limits = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||||
|
logger.debug("Initial limit numbers for open file descriptors for the current ANTA process: Soft Limit: %s | Hard Limit: %s", limits[0], limits[1])
|
||||||
|
nofile = min(limits[1], nofile)
|
||||||
|
logger.debug("Setting soft limit for open file descriptors for the current ANTA process to %s", nofile)
|
||||||
|
resource.setrlimit(resource.RLIMIT_NOFILE, (nofile, limits[1]))
|
||||||
|
return resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_NOFILE = 16384
|
|
||||||
|
|
||||||
|
|
||||||
def adjust_rlimit_nofile() -> tuple[int, int]:
|
|
||||||
"""Adjust the maximum number of open file descriptors for the ANTA process.
|
|
||||||
|
|
||||||
The limit is set to the lower of the current hard limit and the value of the ANTA_NOFILE environment variable.
|
|
||||||
|
|
||||||
If the `ANTA_NOFILE` environment variable is not set or is invalid, `DEFAULT_NOFILE` is used.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
tuple[int, int]: The new soft and hard limits for open file descriptors.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
nofile = int(os.environ.get("ANTA_NOFILE", DEFAULT_NOFILE))
|
|
||||||
except ValueError as exception:
|
|
||||||
logger.warning("The ANTA_NOFILE environment variable value is invalid: %s\nDefault to %s.", exc_to_str(exception), DEFAULT_NOFILE)
|
|
||||||
nofile = DEFAULT_NOFILE
|
|
||||||
|
|
||||||
limits = resource.getrlimit(resource.RLIMIT_NOFILE)
|
|
||||||
logger.debug("Initial limit numbers for open file descriptors for the current ANTA process: Soft Limit: %s | Hard Limit: %s", limits[0], limits[1])
|
|
||||||
nofile = nofile if limits[1] > nofile else limits[1]
|
|
||||||
logger.debug("Setting soft limit for open file descriptors for the current ANTA process to %s", nofile)
|
|
||||||
resource.setrlimit(resource.RLIMIT_NOFILE, (nofile, limits[1]))
|
|
||||||
return resource.getrlimit(resource.RLIMIT_NOFILE)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
||||||
|
@ -173,19 +167,17 @@ class VerifyAuthenMethods(AntaTest):
|
||||||
```yaml
|
```yaml
|
||||||
anta.tests.aaa:
|
anta.tests.aaa:
|
||||||
- VerifyAuthenMethods:
|
- VerifyAuthenMethods:
|
||||||
methods:
|
methods:
|
||||||
- local
|
- local
|
||||||
- none
|
- none
|
||||||
- logging
|
- logging
|
||||||
types:
|
types:
|
||||||
- login
|
- login
|
||||||
- enable
|
- enable
|
||||||
- dot1x
|
- dot1x
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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)]
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
valid = get_value(path_data, "flags.valid")
|
path_type_found = all([dest == str(avt_path.destination), nexthop == str(avt_path.next_hop), path_type == avt_path.path_type])
|
||||||
active = get_value(path_data, "flags.active")
|
if path_type_found:
|
||||||
|
path_found = True
|
||||||
|
# Check the path status and type against the expected values
|
||||||
|
valid = get_value(path_data, "flags.valid")
|
||||||
|
active = get_value(path_data, "flags.active")
|
||||||
|
if not all([valid, active]):
|
||||||
|
self.result.is_failure(f"{avt_path} - Incorrect path {path} - Valid: {valid}, Active: {active}")
|
||||||
|
|
||||||
# Check the path status and type against the expected values
|
# If no matching path found, mark the test as failed
|
||||||
if not all([valid, active]):
|
if not path_found:
|
||||||
failure_reasons = []
|
if avt_path.path_type and not path_type_found:
|
||||||
if not get_value(path_data, "flags.active"):
|
self.result.is_failure(f"{avt_path} Path Type: {avt_path.path_type} - Path not found")
|
||||||
failure_reasons.append("inactive")
|
else:
|
||||||
if not get_value(path_data, "flags.valid"):
|
self.result.is_failure(f"{avt_path} - Path not found")
|
||||||
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 not nexthop_path_found or not path_type_found:
|
|
||||||
self.result.is_failure(
|
|
||||||
f"No '{input_path_type}' path found with next-hop address '{next_hop}' for AVT peer '{peer}' under topology '{avt_name}' in VRF '{vrf}'."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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")]
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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")]
|
||||||
|
|
||||||
|
@ -98,13 +94,12 @@ class VerifyRunningConfigLines(AntaTest):
|
||||||
```yaml
|
```yaml
|
||||||
anta.tests.configuration:
|
anta.tests.configuration:
|
||||||
- VerifyRunningConfigLines:
|
- VerifyRunningConfigLines:
|
||||||
regex_patterns:
|
regex_patterns:
|
||||||
- "^enable password.*$"
|
- "^enable password.*$"
|
||||||
- "bla bla"
|
- "bla bla"
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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")]
|
||||||
|
|
|
@ -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 = []
|
self.result.is_success()
|
||||||
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]:
|
for command, host in zip(self.instance_commands, self.inputs.hosts):
|
||||||
failures.append((str(src), str(dst)))
|
if f"{host.repeat} received" not in command.json_output["messages"][0]:
|
||||||
|
self.result.is_failure(f"{host} - Unreachable")
|
||||||
if not failures:
|
|
||||||
self.result.is_success()
|
|
||||||
else:
|
|
||||||
self.result.is_failure(f"Connectivity test failed for the following source-destination pairs: {failures}")
|
|
||||||
|
|
||||||
|
|
||||||
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(
|
if not match_found:
|
||||||
[
|
failure_msg = [f"{info['systemName']}/{info['neighborInterfaceInfo']['interfaceId_v2']}" for info in lldp_neighbor_info]
|
||||||
f"{neighbor[0]}_{neighbor[1]}"
|
self.result.is_failure(f"{neighbor} - Wrong LLDP neighbors: {', '.join(failure_msg)}")
|
||||||
for neighbor in [(info["systemName"], info["neighborInterfaceInfo"]["interfaceId_v2"]) for info in lldp_neighbor_info]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
failures.setdefault("Wrong LLDP neighbor(s) on port(s)", []).append(f"{neighbor.port}\n {neighbors}")
|
|
||||||
|
|
||||||
if not failures:
|
|
||||||
self.result.is_success()
|
|
||||||
else:
|
|
||||||
failure_messages = []
|
|
||||||
for failure_type, ports in failures.items():
|
|
||||||
ports_str = "\n ".join(ports)
|
|
||||||
failure_messages.append(f"{failure_type}:\n {ports_str}")
|
|
||||||
self.result.is_failure("\n".join(failure_messages))
|
|
||||||
|
|
283
anta/tests/cvx.py
Normal file
283
anta/tests/cvx.py
Normal 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}")
|
|
@ -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
192
anta/tests/flow_tracking.py
Normal 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")
|
|
@ -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)]
|
||||||
|
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
|
@ -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)}")
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
|
@ -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")]
|
||||||
|
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
|
@ -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
|
@ -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."""
|
||||||
return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes]
|
if template == VerifyRoutingTableEntry.commands[0] and self.inputs.collect == "one":
|
||||||
|
return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes]
|
||||||
|
|
||||||
|
if template == VerifyRoutingTableEntry.commands[1] and self.inputs.collect == "all":
|
||||||
|
return [template.render(vrf=self.inputs.vrf)]
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@cache
|
||||||
|
def ip_interface_ip(route: str) -> IPv4Address:
|
||||||
|
"""Return the IP address of the provided ip route with mask."""
|
||||||
|
return IPv4Interface(route).ip
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@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}")
|
||||||
|
|
|
@ -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,14 +229,15 @@ 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}: "
|
||||||
f"expected Level {interface.level}: count {interface.count}, "
|
f"expected Level {interface.level}: count {interface.count}, "
|
||||||
f"got Level {eos_data[0]['level']}: count {eos_data[0]['count']}"
|
f"got Level {eos_data[0]['level']}: count {eos_data[0]['count']}"
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
|
@ -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
|
||||||
|
@ -490,15 +483,13 @@ class VerifyBannerLogin(AntaTest):
|
||||||
```yaml
|
```yaml
|
||||||
anta.tests.security:
|
anta.tests.security:
|
||||||
- VerifyBannerLogin:
|
- VerifyBannerLogin:
|
||||||
login_banner: |
|
login_banner: |
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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)]
|
||||||
|
|
||||||
|
@ -534,15 +525,13 @@ class VerifyBannerMotd(AntaTest):
|
||||||
```yaml
|
```yaml
|
||||||
anta.tests.security:
|
anta.tests.security:
|
||||||
- VerifyBannerMotd:
|
- VerifyBannerMotd:
|
||||||
motd_banner: |
|
motd_banner: |
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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()
|
||||||
|
|
|
@ -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")]
|
||||||
|
|
|
@ -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}")
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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}")
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
||||||
|
|
111
anta/tools.py
111
anta/tools.py
|
@ -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())
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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,44 +79,47 @@ 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,
|
|
||||||
"maxOpenSessions": 5,
|
```
|
||||||
"sessions": {
|
{
|
||||||
"jeremy1": {
|
"maxSavedSessions": 1,
|
||||||
"instances": {},
|
"maxOpenSessions": 5,
|
||||||
"state": "pending",
|
"sessions": {
|
||||||
"commitUser": "",
|
"jeremy1": {
|
||||||
"description": ""
|
"instances": {},
|
||||||
},
|
"state": "pending",
|
||||||
"ansible_167510439362": {
|
"commitUser": "",
|
||||||
"instances": {},
|
"description": ""
|
||||||
"state": "completed",
|
},
|
||||||
"commitUser": "joe.bob",
|
"ansible_167510439362": {
|
||||||
"description": "",
|
"instances": {},
|
||||||
"completedTime": 1675104396.4500246
|
"state": "completed",
|
||||||
}
|
"commitUser": "joe.bob",
|
||||||
}
|
"description": "",
|
||||||
|
"completedTime": 1675104396.4500246
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
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,63 +129,68 @@ 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
|
||||||
--------
|
--------
|
||||||
all results:
|
The return is the native eAPI results from JSON output:
|
||||||
{
|
|
||||||
"maxSavedSessions": 1,
|
```
|
||||||
"maxOpenSessions": 5,
|
all results:
|
||||||
"sessions": {
|
{
|
||||||
"jeremy1": {
|
"maxSavedSessions": 1,
|
||||||
"instances": {},
|
"maxOpenSessions": 5,
|
||||||
"state": "pending",
|
"sessions": {
|
||||||
"commitUser": "",
|
"jeremy1": {
|
||||||
"description": ""
|
"instances": {},
|
||||||
},
|
"state": "pending",
|
||||||
"ansible_167510439362": {
|
"commitUser": "",
|
||||||
"instances": {},
|
"description": ""
|
||||||
"state": "completed",
|
},
|
||||||
"commitUser": "joe.bob",
|
"ansible_167510439362": {
|
||||||
"description": "",
|
"instances": {},
|
||||||
"completedTime": 1675104396.4500246
|
"state": "completed",
|
||||||
}
|
"commitUser": "joe.bob",
|
||||||
|
"description": "",
|
||||||
|
"completedTime": 1675104396.4500246
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
if the session name was 'jeremy1', then this method would return
|
If the session name was 'jeremy1', then this method would return:
|
||||||
{
|
|
||||||
"instances": {},
|
```
|
||||||
"state": "pending",
|
{
|
||||||
"commitUser": "",
|
"instances": {},
|
||||||
"description": ""
|
"state": "pending",
|
||||||
}
|
"commitUser": "",
|
||||||
|
"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.
|
||||||
"""
|
"""
|
||||||
# if given s string, we need to break it up into individual command
|
# if given s string, we need to break it up into individual command
|
||||||
# lines.
|
# lines.
|
||||||
|
@ -205,17 +213,19 @@ 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
|
||||||
|
|
||||||
If the timer is specified, format is "hh:mm:ss", then a commit timer is
|
Parameters
|
||||||
started. A second commit action must be made to confirm the config
|
----------
|
||||||
session before the timer expires; otherwise the config-session is
|
timer
|
||||||
automatically aborted.
|
If the timer is specified, format is "hh:mm:ss", then a commit timer is
|
||||||
|
started. A second commit action must be made to confirm the config
|
||||||
|
session before the timer expires; otherwise the config-session is
|
||||||
|
automatically aborted.
|
||||||
"""
|
"""
|
||||||
command = f"{self._cli_config_session} commit"
|
command = f"{self._cli_config_session} commit"
|
||||||
|
|
||||||
|
@ -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)):
|
||||||
|
|
|
@ -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,31 +62,38 @@ 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
|
||||||
----------------
|
----------------
|
||||||
base_url: str
|
base_url : str
|
||||||
If provided, the complete URL to the device eAPI endpoint.
|
If provided, the complete URL to the device eAPI endpoint.
|
||||||
|
|
||||||
auth:
|
auth :
|
||||||
If provided, used as the httpx authorization initializer value. If
|
If provided, used as the httpx authorization initializer value. If
|
||||||
not provided, then username+password is assumed by the Caller and
|
not provided, then username+password is assumed by the Caller and
|
||||||
used to create a BasicAuth instance.
|
used to create a BasicAuth instance.
|
||||||
"""
|
"""
|
||||||
self.port = port or getservbyname(proto)
|
self.port = port or getservbyname(proto)
|
||||||
self.host = host
|
self.host = host
|
||||||
|
@ -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,21 +260,21 @@ 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
|
||||||
------
|
------
|
||||||
EapiCommandError
|
EapiCommandError
|
||||||
In the event that a command resulted in an error response.
|
In the event that a command resulted in an error response.
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
@ -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
32
debian/changelog
vendored
|
@ -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
Loading…
Add table
Reference in a new issue