Compare commits
5 commits
ecf5ca3300
...
6fd6eb426a
Author | SHA1 | Date | |
---|---|---|---|
6fd6eb426a | |||
77504588ab | |||
f13b7abbd8 | |||
a1777afd4b | |||
6721599912 |
340 changed files with 107108 additions and 11772 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
|
|
@ -15,15 +15,23 @@
|
|||
"vscode": {
|
||||
"settings": {},
|
||||
"extensions": [
|
||||
"ms-python.black-formatter",
|
||||
"ms-python.isort",
|
||||
"formulahendry.github-actions",
|
||||
"matangover.mypy",
|
||||
"ms-python.mypy-type-checker",
|
||||
"ms-python.pylint",
|
||||
"LittleFoxTeam.vscode-python-test-adapter",
|
||||
"njqdev.vscode-python-typehint",
|
||||
"hbenl.vscode-test-explorer"
|
||||
"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"
|
||||
pip install -e .
|
||||
|
||||
echo "Installing ANTA CLI package from git"
|
||||
pip install -e ".[cli]"
|
||||
|
||||
echo "Installing development tools"
|
||||
pip install -e ".[dev]"
|
||||
|
|
24
.github/generate_release.py
vendored
24
.github/generate_release.py
vendored
|
@ -1,6 +1,5 @@
|
|||
#!/usr/bin/env python
|
||||
"""
|
||||
generate_release.py
|
||||
"""generate_release.py.
|
||||
|
||||
This script is used to generate the release.yml file as per
|
||||
https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes
|
||||
|
@ -20,21 +19,18 @@ CATEGORIES = {
|
|||
"fix": "Bug Fixes",
|
||||
"cut": "Cut",
|
||||
"doc": "Documentation",
|
||||
# "CI": "CI",
|
||||
"bump": "Bump",
|
||||
# "test": "Test",
|
||||
"revert": "Revert",
|
||||
"refactor": "Refactoring",
|
||||
}
|
||||
|
||||
|
||||
class SafeDumper(yaml.SafeDumper):
|
||||
"""
|
||||
Make yamllint happy
|
||||
https://github.com/yaml/pyyaml/issues/234#issuecomment-765894586
|
||||
"""Make yamllint happy
|
||||
https://github.com/yaml/pyyaml/issues/234#issuecomment-765894586.
|
||||
"""
|
||||
|
||||
# pylint: disable=R0901,W0613,W1113
|
||||
# pylint: disable=R0901
|
||||
|
||||
def increase_indent(self, flow=False, *args, **kwargs):
|
||||
return super().increase_indent(flow=flow, indentless=False)
|
||||
|
@ -60,7 +56,7 @@ if __name__ == "__main__":
|
|||
{
|
||||
"title": "Breaking Changes",
|
||||
"labels": breaking_labels,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Add new features
|
||||
|
@ -71,7 +67,7 @@ if __name__ == "__main__":
|
|||
{
|
||||
"title": "New features and enhancements",
|
||||
"labels": feat_labels,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Add fixes
|
||||
|
@ -82,7 +78,7 @@ if __name__ == "__main__":
|
|||
{
|
||||
"title": "Fixed issues",
|
||||
"labels": fixes_labels,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Add Documentation
|
||||
|
@ -93,7 +89,7 @@ if __name__ == "__main__":
|
|||
{
|
||||
"title": "Documentation",
|
||||
"labels": doc_labels,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Add the catch all
|
||||
|
@ -101,7 +97,7 @@ if __name__ == "__main__":
|
|||
{
|
||||
"title": "Other Changes",
|
||||
"labels": ["*"],
|
||||
}
|
||||
},
|
||||
)
|
||||
with open(r"release.yml", "w", encoding="utf-8") as release_file:
|
||||
yaml.dump(
|
||||
|
@ -109,7 +105,7 @@ if __name__ == "__main__":
|
|||
"changelog": {
|
||||
"exclude": {"labels": exclude_list},
|
||||
"categories": categories_list,
|
||||
}
|
||||
},
|
||||
},
|
||||
release_file,
|
||||
Dumper=SafeDumper,
|
||||
|
|
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
|
||||
gh pr create --title 'bump: ANTA vx.x.x'
|
||||
```
|
||||
9. Merge PR after review and wait for [workflow](https://github.com/arista-netdevops-community/anta/actions/workflows/release.yml) to be executed.
|
||||
9. Merge PR after review and wait for [workflow](https://github.com/aristanetworks/anta/actions/workflows/release.yml) to be executed.
|
||||
|
||||
```bash
|
||||
gh pr merge --squash
|
||||
|
|
83
.github/workflows/code-testing.yml
vendored
83
.github/workflows/code-testing.yml
vendored
|
@ -23,6 +23,8 @@ jobs:
|
|||
- 'anta/**'
|
||||
- 'tests/*'
|
||||
- 'tests/**'
|
||||
# detect dependency changes
|
||||
- 'pyproject.toml'
|
||||
core:
|
||||
- 'anta/*'
|
||||
- 'anta/reporter/*'
|
||||
|
@ -44,7 +46,7 @@ jobs:
|
|||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
needs: file-changes
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
@ -57,32 +59,21 @@ jobs:
|
|||
pip install .
|
||||
- name: install dev requirements
|
||||
run: pip install .[dev]
|
||||
missing-documentation:
|
||||
name: "Warning documentation is missing"
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [file-changes]
|
||||
if: needs.file-changes.outputs.cli == 'true' && needs.file-changes.outputs.docs == 'false'
|
||||
steps:
|
||||
- name: Documentation is missing
|
||||
uses: GrantBirki/comment@v2.0.9
|
||||
with:
|
||||
body: |
|
||||
Please consider that documentation is missing under `docs/` folder.
|
||||
You should update documentation to reflect your change, or maybe not :)
|
||||
lint-yaml:
|
||||
name: Run linting for yaml files
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [file-changes, check-requirements]
|
||||
if: needs.file-changes.outputs.code == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: yaml-lint
|
||||
uses: ibiqlik/action-yamllint@v3
|
||||
with:
|
||||
config_file: .yamllint.yml
|
||||
file_or_dir: .
|
||||
# @gmuloc: commenting this out for now
|
||||
#missing-documentation:
|
||||
# name: "Warning documentation is missing"
|
||||
# runs-on: ubuntu-20.04
|
||||
# needs: [file-changes]
|
||||
# if: needs.file-changes.outputs.cli == 'true' && needs.file-changes.outputs.docs == 'false'
|
||||
# steps:
|
||||
# - name: Documentation is missing
|
||||
# uses: GrantBirki/comment@v2.0.10
|
||||
# with:
|
||||
# body: |
|
||||
# Please consider that documentation is missing under `docs/` folder.
|
||||
# You should update documentation to reflect your change, or maybe not :)
|
||||
lint-python:
|
||||
name: Run isort, black, flake8 and pylint
|
||||
name: Check the code style
|
||||
runs-on: ubuntu-20.04
|
||||
needs: file-changes
|
||||
if: needs.file-changes.outputs.code == 'true'
|
||||
|
@ -97,7 +88,7 @@ jobs:
|
|||
- name: "Run tox linting environment"
|
||||
run: tox -e lint
|
||||
type-python:
|
||||
name: Run mypy
|
||||
name: Check typing
|
||||
runs-on: ubuntu-20.04
|
||||
needs: file-changes
|
||||
if: needs.file-changes.outputs.code == 'true'
|
||||
|
@ -117,7 +108,7 @@ jobs:
|
|||
needs: [lint-python, type-python]
|
||||
strategy:
|
||||
matrix:
|
||||
python: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
python: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Python
|
||||
|
@ -128,10 +119,27 @@ jobs:
|
|||
run: pip install tox tox-gh-actions
|
||||
- name: "Run pytest via tox for ${{ matrix.python }}"
|
||||
run: tox
|
||||
test-python-windows:
|
||||
name: Pytest on 3.12 for windows
|
||||
runs-on: windows-2022
|
||||
needs: [lint-python, type-python]
|
||||
env:
|
||||
# Required to prevent asyncssh to fail.
|
||||
USERNAME: WindowsUser
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
- name: Install dependencies
|
||||
run: pip install tox tox-gh-actions
|
||||
- name: Run pytest via tox for 3.12 on Windows
|
||||
run: tox
|
||||
test-documentation:
|
||||
name: Build offline documentation for testing
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [lint-python, type-python, test-python]
|
||||
needs: [test-python]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Python
|
||||
|
@ -142,3 +150,20 @@ jobs:
|
|||
run: pip install .[doc]
|
||||
- name: "Build mkdocs documentation offline"
|
||||
run: mkdocs build
|
||||
benchmarks:
|
||||
name: Benchmark ANTA for Python 3.12
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-python]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install dependencies
|
||||
run: pip install .[dev]
|
||||
- name: Run benchmarks
|
||||
uses: CodSpeedHQ/action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODSPEED_TOKEN }}
|
||||
run: pytest --codspeed --no-cov --log-cli-level INFO tests/benchmark
|
||||
|
|
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/main-doc.yml
vendored
2
.github/workflows/main-doc.yml
vendored
|
@ -7,9 +7,9 @@ on:
|
|||
- main
|
||||
paths:
|
||||
# Run only if any of the following paths are changed when pushing to main
|
||||
# May need to update this
|
||||
- "docs/**"
|
||||
- "mkdocs.yml"
|
||||
- "anta/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
|
2
.github/workflows/on-demand.yml
vendored
2
.github/workflows/on-demand.yml
vendored
|
@ -39,7 +39,7 @@ jobs:
|
|||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
|
|
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
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: toshimaru/auto-author-assign@v2.1.0
|
||||
- uses: toshimaru/auto-author-assign@v2.1.1
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
|
@ -22,7 +22,7 @@ jobs:
|
|||
steps:
|
||||
# Please look up the latest version from
|
||||
# https://github.com/amannn/action-semantic-pull-request/releases
|
||||
- uses: amannn/action-semantic-pull-request@v5.4.0
|
||||
- uses: amannn/action-semantic-pull-request@v5.5.3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
|
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
|
@ -7,8 +7,13 @@ on:
|
|||
|
||||
jobs:
|
||||
pypi:
|
||||
name: Publish version to Pypi servers
|
||||
name: Publish Python 🐍 distribution 📦 to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: production
|
||||
url: https://pypi.org/p/anta
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
@ -19,11 +24,8 @@ jobs:
|
|||
- name: Build package
|
||||
run: |
|
||||
python -m build
|
||||
- name: Publish package to Pypi
|
||||
- name: Publish distribution 📦 to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
|
||||
release-coverage:
|
||||
name: Updated ANTA release coverage badge
|
||||
|
@ -100,7 +102,7 @@ jobs:
|
|||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
|
|
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 }}
|
23
.gitignore
vendored
23
.gitignore
vendored
|
@ -1,8 +1,10 @@
|
|||
__pycache__
|
||||
*.pyc
|
||||
.pages
|
||||
.coverage
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
.cache
|
||||
build
|
||||
dist
|
||||
*.egg-info
|
||||
|
@ -30,7 +32,6 @@ share/python-wheels/
|
|||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
.flake8
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
|
@ -47,14 +48,13 @@ htmlcov/
|
|||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
coverage_html_report
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
report.html
|
||||
|
||||
|
@ -98,17 +98,4 @@ venv.bak/
|
|||
/site
|
||||
|
||||
# VScode settings
|
||||
.vscode
|
||||
test.env
|
||||
tech-support/
|
||||
tech-support/*
|
||||
2*
|
||||
|
||||
**/report.html
|
||||
.*report.html
|
||||
|
||||
# direnv file
|
||||
.envrc
|
||||
|
||||
clab-atd-anta/*
|
||||
clab-atd-anta/
|
||||
.vscode
|
|
@ -1,19 +1,24 @@
|
|||
---
|
||||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
files: ^(anta|docs|scripts|tests)/
|
||||
ci:
|
||||
autoupdate_commit_msg: "ci: pre-commit autoupdate"
|
||||
|
||||
files: ^(anta|docs|scripts|tests|asynceapi)/
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: docs/.*.svg
|
||||
- id: end-of-file-fixer
|
||||
- id: check-added-large-files
|
||||
exclude: tests/data/.*$
|
||||
- id: check-merge-conflict
|
||||
|
||||
- repo: https://github.com/Lucas-C/pre-commit-hooks
|
||||
rev: v1.5.4
|
||||
rev: v1.5.5
|
||||
hooks:
|
||||
- name: Check and insert license on Python files
|
||||
id: insert-license
|
||||
|
@ -30,7 +35,7 @@ repos:
|
|||
- name: Check and insert license on Markdown files
|
||||
id: insert-license
|
||||
files: .*\.md$
|
||||
# exclude:
|
||||
exclude: ^tests/data/.*\.md$
|
||||
args:
|
||||
- --license-filepath
|
||||
- .github/license-short.txt
|
||||
|
@ -40,66 +45,82 @@ repos:
|
|||
- --comment-style
|
||||
- '<!--| ~| -->'
|
||||
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.13.2
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.4
|
||||
hooks:
|
||||
- id: isort
|
||||
name: Check for changes when running isort on all python files
|
||||
- id: ruff
|
||||
name: Run Ruff linter
|
||||
args: [ --fix ]
|
||||
- id: ruff-format
|
||||
name: Run Ruff formatter
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 24.1.1
|
||||
hooks:
|
||||
- id: black
|
||||
name: Check for changes when running Black on all python files
|
||||
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 7.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
name: Check for PEP8 error on Python files
|
||||
args:
|
||||
- --config=/dev/null
|
||||
- --max-line-length=165
|
||||
|
||||
- repo: local # as per https://pylint.pycqa.org/en/latest/user_guide/installation/pre-commit-integration.html
|
||||
- repo: https://github.com/pycqa/pylint
|
||||
rev: "v3.3.2"
|
||||
hooks:
|
||||
- id: pylint
|
||||
entry: pylint
|
||||
language: python
|
||||
name: Check for Linting error on Python files
|
||||
name: Check code style with pylint
|
||||
description: This hook runs pylint.
|
||||
types: [python]
|
||||
args:
|
||||
- -rn # Only display messages
|
||||
- -sn # Don't display the score
|
||||
- --rcfile=pylintrc # Link to config file
|
||||
- -rn # Only display messages
|
||||
- -sn # Don't display the score
|
||||
- --rcfile=pyproject.toml # Link to config file
|
||||
additional_dependencies:
|
||||
- anta[cli]
|
||||
- types-PyYAML
|
||||
- types-requests
|
||||
- types-pyOpenSSL
|
||||
- pylint_pydantic
|
||||
- pytest
|
||||
- pytest-codspeed
|
||||
- respx
|
||||
|
||||
# Prepare to turn on ruff
|
||||
# - repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# # Ruff version.
|
||||
# rev: v0.0.280
|
||||
# hooks:
|
||||
# - id: ruff
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.3.0
|
||||
hooks:
|
||||
- id: codespell
|
||||
name: Checks for common misspellings in text files.
|
||||
entry: codespell
|
||||
language: python
|
||||
types: [text]
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.7.1
|
||||
rev: v1.14.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
name: Check typing with mypy
|
||||
args:
|
||||
- --config-file=pyproject.toml
|
||||
additional_dependencies:
|
||||
- "aio-eapi==0.3.0"
|
||||
- "click==8.1.3"
|
||||
- "click-help-colors==0.9.1"
|
||||
- "cvprac~=1.3"
|
||||
- "netaddr==0.8.0"
|
||||
- "pydantic~=2.0"
|
||||
- "PyYAML==6.0"
|
||||
- "requests>=2.27"
|
||||
- "rich~=13.4"
|
||||
- "asyncssh==2.13.1"
|
||||
- "Jinja2==3.1.2"
|
||||
- anta[cli]
|
||||
- types-PyYAML
|
||||
- types-paramiko
|
||||
- types-requests
|
||||
- types-pyOpenSSL
|
||||
- pytest
|
||||
files: ^(anta|tests)/
|
||||
|
||||
- repo: https://github.com/igorshubovych/markdownlint-cli
|
||||
rev: v0.43.0
|
||||
hooks:
|
||||
- id: markdownlint
|
||||
name: Check Markdown files style.
|
||||
args:
|
||||
- --config=.github/markdownlint.yaml
|
||||
- --ignore-path=.github/markdownlintignore
|
||||
- --fix
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: examples-test
|
||||
name: Generate examples/tests.yaml
|
||||
entry: >-
|
||||
sh -c "docs/scripts/generate_examples_tests.py"
|
||||
language: python
|
||||
types: [python]
|
||||
files: anta/
|
||||
verbose: true
|
||||
pass_filenames: false
|
||||
additional_dependencies:
|
||||
- anta[cli]
|
||||
# TODO: next can go once we have it added to anta properly
|
||||
- numpydoc
|
||||
|
|
34
.vscode/settings.json
vendored
34
.vscode/settings.json
vendored
|
@ -1,30 +1,14 @@
|
|||
{
|
||||
"black-formatter.importStrategy": "fromEnvironment",
|
||||
"pylint.importStrategy": "fromEnvironment",
|
||||
"pylint.args": [
|
||||
"--rcfile=pylintrc"
|
||||
],
|
||||
"flake8.importStrategy": "fromEnvironment",
|
||||
"flake8.args": [
|
||||
"--config=/dev/null",
|
||||
"--max-line-length=165"
|
||||
],
|
||||
"mypy-type-checker.importStrategy": "fromEnvironment",
|
||||
"mypy-type-checker.args": [
|
||||
"--config-file=pyproject.toml"
|
||||
],
|
||||
"pylint.severity": {
|
||||
"refactor": "Warning"
|
||||
},
|
||||
"pylint.args": [
|
||||
"--load-plugins pylint_pydantic",
|
||||
"--rcfile=pylintrc"
|
||||
],
|
||||
"ruff.enable": true,
|
||||
"ruff.configuration": "pyproject.toml",
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.testing.pytestArgs": [
|
||||
"tests"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"isort.importStrategy": "fromEnvironment",
|
||||
"isort.check": true,
|
||||
"githubIssues.issueBranchTitle": "issues/${issueNumber}-${issueTitle}",
|
||||
"pylint.importStrategy": "fromEnvironment",
|
||||
"pylint.args": [
|
||||
"--rcfile=pyproject.toml"
|
||||
],
|
||||
|
||||
}
|
34
Dockerfile
34
Dockerfile
|
@ -3,23 +3,30 @@ ARG IMG_OPTION=alpine
|
|||
|
||||
### BUILDER
|
||||
|
||||
FROM python:${PYTHON_VER}-${IMG_OPTION} as BUILDER
|
||||
FROM python:${PYTHON_VER}-${IMG_OPTION} AS BUILDER
|
||||
|
||||
RUN pip install --upgrade pip
|
||||
|
||||
WORKDIR /local
|
||||
COPY . /local
|
||||
|
||||
ENV PYTHONPATH=/local
|
||||
ENV PATH=$PATH:/root/.local/bin
|
||||
RUN python -m venv /opt/venv
|
||||
|
||||
RUN pip --no-cache-dir install --user .
|
||||
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
RUN apk add --no-cache build-base # Add build-base package
|
||||
RUN pip --no-cache-dir install "." &&\
|
||||
pip --no-cache-dir install ".[cli]"
|
||||
|
||||
# ----------------------------------- #
|
||||
|
||||
### BASE
|
||||
|
||||
FROM python:${PYTHON_VER}-${IMG_OPTION} as BASE
|
||||
FROM python:${PYTHON_VER}-${IMG_OPTION} AS BASE
|
||||
|
||||
# Add a system user
|
||||
RUN adduser --system anta
|
||||
|
||||
# Opencontainer labels
|
||||
# Labels version and revision will be updating
|
||||
|
@ -30,17 +37,22 @@ FROM python:${PYTHON_VER}-${IMG_OPTION} as BASE
|
|||
LABEL "org.opencontainers.image.title"="anta" \
|
||||
"org.opencontainers.artifact.description"="network-test-automation in a Python package and Python scripts to test Arista devices." \
|
||||
"org.opencontainers.image.description"="network-test-automation in a Python package and Python scripts to test Arista devices." \
|
||||
"org.opencontainers.image.source"="https://github.com/arista-netdevops-community/anta" \
|
||||
"org.opencontainers.image.source"="https://github.com/aristanetworks/anta" \
|
||||
"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.vendor"="The anta contributors." \
|
||||
"org.opencontainers.image.vendor"="Arista Networks" \
|
||||
"org.opencontainers.image.authors"="Khelil Sator, Angélique Phillipps, Colin MacGiollaEáin, Matthieu Tache, Onur Gashi, Paul Lavelle, Guillaume Mulocher, Thomas Grimonet" \
|
||||
"org.opencontainers.image.base.name"="python" \
|
||||
"org.opencontainers.image.revision"="dev" \
|
||||
"org.opencontainers.image.version"="dev"
|
||||
|
||||
COPY --from=BUILDER /root/.local/ /root/.local
|
||||
ENV PATH=$PATH:/root/.local/bin
|
||||
# Copy artifacts from builder
|
||||
COPY --from=BUILDER /opt/venv /opt/venv
|
||||
|
||||
ENTRYPOINT [ "/root/.local/bin/anta" ]
|
||||
# Define PATH and default user
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
USER anta
|
||||
|
||||
ENTRYPOINT [ "/opt/venv/bin/anta" ]
|
||||
|
|
28
NOTICE
Normal file
28
NOTICE
Normal file
|
@ -0,0 +1,28 @@
|
|||
ANTA Project
|
||||
|
||||
Copyright 2024 Arista Networks
|
||||
|
||||
This product includes software developed at Arista Networks.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
This product includes software developed by contributors from the
|
||||
following projects, which are also licensed under the Apache License, Version 2.0:
|
||||
|
||||
1. aio-eapi
|
||||
- Copyright 2024 Jeremy Schulman
|
||||
- URL: https://github.com/jeremyschulman/aio-eapi
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
|
@ -2,6 +2,7 @@
|
|||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""Arista Network Test Automation (ANTA) Framework."""
|
||||
|
||||
import importlib.metadata
|
||||
import os
|
||||
|
||||
|
@ -16,10 +17,13 @@ __credits__ = [
|
|||
"Guillaume Mulocher",
|
||||
"Thomas Grimonet",
|
||||
]
|
||||
__copyright__ = "Copyright 2022, Arista EMEA AS"
|
||||
__copyright__ = "Copyright 2022-2024, Arista Networks, Inc."
|
||||
|
||||
# Global ANTA debug mode environment variable
|
||||
__DEBUG__ = bool(os.environ.get("ANTA_DEBUG", "").lower() == "true")
|
||||
# ANTA Debug Mode environment variable
|
||||
__DEBUG__ = os.environ.get("ANTA_DEBUG", "").lower() == "true"
|
||||
if __DEBUG__:
|
||||
# enable asyncio DEBUG mode when __DEBUG__ is enabled
|
||||
os.environ["PYTHONASYNCIODEBUG"] = "1"
|
||||
|
||||
|
||||
# Source: https://rich.readthedocs.io/en/stable/appendix/colors.html
|
||||
|
@ -44,4 +48,4 @@ RICH_COLOR_THEME = {
|
|||
"unset": RICH_COLOR_PALETTE.UNSET,
|
||||
}
|
||||
|
||||
GITHUB_SUGGESTION = "Please reach out to the maintainer team or open an issue on Github: https://github.com/arista-netdevops-community/anta."
|
||||
GITHUB_SUGGESTION = "Please reach out to the maintainer team or open an issue on Github: https://github.com/aristanetworks/anta."
|
||||
|
|
108
anta/aioeapi.py
108
anta/aioeapi.py
|
@ -1,108 +0,0 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""Patch for aioeapi waiting for https://github.com/jeremyschulman/aio-eapi/pull/13"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, AnyStr
|
||||
|
||||
import aioeapi
|
||||
|
||||
Device = aioeapi.Device
|
||||
|
||||
|
||||
class EapiCommandError(RuntimeError):
|
||||
"""
|
||||
Exception class for EAPI command errors
|
||||
|
||||
Attributes
|
||||
----------
|
||||
failed: str - the failed command
|
||||
errmsg: str - a description of the failure reason
|
||||
errors: list[str] - the command failure details
|
||||
passed: list[dict] - a list of command results of the commands that passed
|
||||
not_exec: list[str] - a list of commands that were not executed
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]):
|
||||
"""Initializer for the EapiCommandError exception"""
|
||||
self.failed = failed
|
||||
self.errmsg = errmsg
|
||||
self.errors = errors
|
||||
self.passed = passed
|
||||
self.not_exec = not_exec
|
||||
super().__init__()
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""returns the error message associated with the exception"""
|
||||
return self.errmsg
|
||||
|
||||
|
||||
aioeapi.EapiCommandError = EapiCommandError
|
||||
|
||||
|
||||
async def jsonrpc_exec(self, jsonrpc: dict) -> list[dict | AnyStr]: # type: ignore
|
||||
"""
|
||||
Execute the JSON-RPC dictionary object.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
jsonrpc: dict
|
||||
The JSON-RPC as created by the `meth`:jsonrpc_command().
|
||||
|
||||
Raises
|
||||
------
|
||||
EapiCommandError
|
||||
In the event that a command resulted in an error response.
|
||||
|
||||
Returns
|
||||
-------
|
||||
The list of command results; either dict or text depending on the
|
||||
JSON-RPC format pameter.
|
||||
"""
|
||||
res = await self.post("/command-api", json=jsonrpc)
|
||||
res.raise_for_status()
|
||||
body = res.json()
|
||||
|
||||
commands = jsonrpc["params"]["cmds"]
|
||||
ofmt = jsonrpc["params"]["format"]
|
||||
|
||||
get_output = (lambda _r: _r["output"]) if ofmt == "text" else (lambda _r: _r)
|
||||
|
||||
# if there are no errors then return the list of command results.
|
||||
if (err_data := body.get("error")) is None:
|
||||
return [get_output(cmd_res) for cmd_res in body["result"]]
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# if we are here, then there were some command errors. Raise a
|
||||
# EapiCommandError exception with args (commands that failed, passed,
|
||||
# not-executed).
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
# -------------------------- eAPI specification ----------------------
|
||||
# On an error, no result object is present, only an error object, which
|
||||
# is guaranteed to have the following attributes: code, messages, and
|
||||
# data. Similar to the result object in the successful response, the
|
||||
# data object is a list of objects corresponding to the results of all
|
||||
# commands up to, and including, the failed command. If there was a an
|
||||
# error before any commands were executed (e.g. bad credentials), data
|
||||
# will be empty. The last object in the data array will always
|
||||
# correspond to the failed command. The command failure details are
|
||||
# always stored in the errors array.
|
||||
|
||||
cmd_data = err_data["data"]
|
||||
len_data = len(cmd_data)
|
||||
err_at = len_data - 1
|
||||
err_msg = err_data["message"]
|
||||
|
||||
raise EapiCommandError(
|
||||
passed=[get_output(cmd_data[cmd_i]) for cmd_i, cmd in enumerate(commands[:err_at])],
|
||||
failed=commands[err_at]["cmd"],
|
||||
errors=cmd_data[err_at]["errors"],
|
||||
errmsg=err_msg,
|
||||
not_exec=commands[err_at + 1 :], # noqa: E203
|
||||
)
|
||||
|
||||
|
||||
aioeapi.Device.jsonrpc_exec = jsonrpc_exec
|
505
anta/catalog.py
505
anta/catalog.py
|
@ -1,51 +1,84 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Catalog related functions
|
||||
"""
|
||||
"""Catalog related functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from inspect import isclass
|
||||
from itertools import chain
|
||||
from json import load as json_load
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type, Union
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
|
||||
from warnings import warn
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_serializer, model_validator
|
||||
from pydantic.types import ImportString
|
||||
from yaml import YAMLError, safe_load
|
||||
from pydantic_core import PydanticCustomError
|
||||
from yaml import YAMLError, safe_dump, safe_load
|
||||
|
||||
from anta.logger import anta_log_exception
|
||||
from anta.models import AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sys
|
||||
from types import ModuleType
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self
|
||||
else:
|
||||
from typing_extensions import Self
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# { <module_name> : [ { <test_class_name>: <input_as_dict_or_None> }, ... ] }
|
||||
RawCatalogInput = Dict[str, List[Dict[str, Optional[Dict[str, Any]]]]]
|
||||
RawCatalogInput = dict[str, list[dict[str, Optional[dict[str, Any]]]]]
|
||||
|
||||
# [ ( <AntaTest class>, <input_as AntaTest.Input or dict or None > ), ... ]
|
||||
ListAntaTestTuples = List[Tuple[Type[AntaTest], Optional[Union[AntaTest.Input, Dict[str, Any]]]]]
|
||||
ListAntaTestTuples = list[tuple[type[AntaTest], Optional[Union[AntaTest.Input, dict[str, Any]]]]]
|
||||
|
||||
|
||||
class AntaTestDefinition(BaseModel):
|
||||
"""
|
||||
Define a test with its associated inputs.
|
||||
"""Define a test with its associated inputs.
|
||||
|
||||
test: An AntaTest concrete subclass
|
||||
inputs: The associated AntaTest.Input subclass instance
|
||||
Attributes
|
||||
----------
|
||||
test
|
||||
An AntaTest concrete subclass.
|
||||
inputs
|
||||
The associated AntaTest.Input subclass instance.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
test: Type[AntaTest]
|
||||
test: type[AntaTest]
|
||||
inputs: AntaTest.Input
|
||||
|
||||
def __init__(self, **data: Any) -> None:
|
||||
@model_serializer()
|
||||
def serialize_model(self) -> dict[str, AntaTest.Input]:
|
||||
"""Serialize the AntaTestDefinition model.
|
||||
|
||||
The dictionary representing the model will be look like:
|
||||
```
|
||||
<AntaTest subclass name>:
|
||||
<AntaTest.Input compliant dictionary>
|
||||
```
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
A dictionary representing the model.
|
||||
"""
|
||||
Inject test in the context to allow to instantiate Input in the BeforeValidator
|
||||
https://docs.pydantic.dev/2.0/usage/validators/#using-validation-context-with-basemodel-initialization
|
||||
return {self.test.__name__: self.inputs}
|
||||
|
||||
def __init__(self, **data: type[AntaTest] | AntaTest.Input | dict[str, Any] | None) -> None:
|
||||
"""Inject test in the context to allow to instantiate Input in the BeforeValidator.
|
||||
|
||||
https://docs.pydantic.dev/2.0/usage/validators/#using-validation-context-with-basemodel-initialization.
|
||||
"""
|
||||
self.__pydantic_validator__.validate_python(
|
||||
data,
|
||||
|
@ -56,133 +89,202 @@ class AntaTestDefinition(BaseModel):
|
|||
|
||||
@field_validator("inputs", mode="before")
|
||||
@classmethod
|
||||
def instantiate_inputs(cls, data: AntaTest.Input | dict[str, Any] | None, info: ValidationInfo) -> AntaTest.Input:
|
||||
"""
|
||||
def instantiate_inputs(
|
||||
cls: type[AntaTestDefinition],
|
||||
data: AntaTest.Input | dict[str, Any] | None,
|
||||
info: ValidationInfo,
|
||||
) -> AntaTest.Input:
|
||||
"""Ensure the test inputs can be instantiated and thus are valid.
|
||||
|
||||
If the test has no inputs, allow the user to omit providing the `inputs` field.
|
||||
If the test has inputs, allow the user to provide a valid dictionary of the input fields.
|
||||
This model validator will instantiate an Input class from the `test` class field.
|
||||
"""
|
||||
if info.context is None:
|
||||
raise ValueError("Could not validate inputs as no test class could be identified")
|
||||
msg = "Could not validate inputs as no test class could be identified"
|
||||
raise ValueError(msg)
|
||||
# Pydantic guarantees at this stage that test_class is a subclass of AntaTest because of the ordering
|
||||
# of fields in the class definition - so no need to check for this
|
||||
test_class = info.context["test"]
|
||||
if not (isclass(test_class) and issubclass(test_class, AntaTest)):
|
||||
raise ValueError(f"Could not validate inputs as no test class {test_class} is not a subclass of AntaTest")
|
||||
msg = f"Could not validate inputs as no test class {test_class} is not a subclass of AntaTest"
|
||||
raise ValueError(msg)
|
||||
|
||||
if data is None:
|
||||
return test_class.Input()
|
||||
if isinstance(data, AntaTest.Input):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
return test_class.Input(**data)
|
||||
raise ValueError(f"Coud not instantiate inputs as type {type(data).__name__} is not valid")
|
||||
try:
|
||||
if data is None:
|
||||
return test_class.Input()
|
||||
if isinstance(data, dict):
|
||||
return test_class.Input(**data)
|
||||
except ValidationError as e:
|
||||
inputs_msg = str(e).replace("\n", "\n\t")
|
||||
err_type = "wrong_test_inputs"
|
||||
raise PydanticCustomError(
|
||||
err_type,
|
||||
f"{test_class.name} test inputs are not valid: {inputs_msg}\n",
|
||||
{"errors": e.errors()},
|
||||
) from e
|
||||
msg = f"Could not instantiate inputs as type {type(data).__name__} is not valid"
|
||||
raise ValueError(msg)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_inputs(self) -> "AntaTestDefinition":
|
||||
"""
|
||||
def check_inputs(self) -> Self:
|
||||
"""Check the `inputs` field typing.
|
||||
|
||||
The `inputs` class attribute needs to be an instance of the AntaTest.Input subclass defined in the class `test`.
|
||||
"""
|
||||
if not isinstance(self.inputs, self.test.Input):
|
||||
raise ValueError(f"Test input has type {self.inputs.__class__.__qualname__} but expected type {self.test.Input.__qualname__}")
|
||||
msg = f"Test input has type {self.inputs.__class__.__qualname__} but expected type {self.test.Input.__qualname__}"
|
||||
raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError
|
||||
return self
|
||||
|
||||
|
||||
class AntaCatalogFile(RootModel[Dict[ImportString[Any], List[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
This model represents an ANTA Test Catalog File.
|
||||
class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods
|
||||
"""Represents an ANTA Test Catalog File.
|
||||
|
||||
Example
|
||||
-------
|
||||
A valid test catalog file must have the following structure:
|
||||
```
|
||||
<Python module>:
|
||||
- <AntaTest subclass>:
|
||||
<AntaTest.Input compliant dictionary>
|
||||
```
|
||||
|
||||
A valid test catalog file must have the following structure:
|
||||
<Python module>:
|
||||
- <AntaTest subclass>:
|
||||
<AntaTest.Input compliant dictionary>
|
||||
"""
|
||||
|
||||
root: Dict[ImportString[Any], List[AntaTestDefinition]]
|
||||
root: dict[ImportString[Any], list[AntaTestDefinition]]
|
||||
|
||||
@staticmethod
|
||||
def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[ModuleType, list[Any]]:
|
||||
"""Allow the user to provide a data structure with nested Python modules.
|
||||
|
||||
Example
|
||||
-------
|
||||
```
|
||||
anta.tests.routing:
|
||||
generic:
|
||||
- <AntaTestDefinition>
|
||||
bgp:
|
||||
- <AntaTestDefinition>
|
||||
```
|
||||
`anta.tests.routing.generic` and `anta.tests.routing.bgp` are importable Python modules.
|
||||
|
||||
"""
|
||||
modules: dict[ModuleType, list[Any]] = {}
|
||||
for module_name, tests in data.items():
|
||||
if package and not module_name.startswith("."):
|
||||
# PLW2901 - we redefine the loop variable on purpose here.
|
||||
module_name = f".{module_name}" # noqa: PLW2901
|
||||
try:
|
||||
module: ModuleType = importlib.import_module(name=module_name, package=package)
|
||||
except Exception as e:
|
||||
# A test module is potentially user-defined code.
|
||||
# We need to catch everything if we want to have meaningful logs
|
||||
module_str = f"{module_name[1:] if module_name.startswith('.') else module_name}{f' from package {package}' if package else ''}"
|
||||
message = f"Module named {module_str} cannot be imported. Verify that the module exists and there is no Python syntax issues."
|
||||
anta_log_exception(e, message, logger)
|
||||
raise ValueError(message) from e
|
||||
if isinstance(tests, dict):
|
||||
# This is an inner Python module
|
||||
modules.update(AntaCatalogFile.flatten_modules(data=tests, package=module.__name__))
|
||||
elif isinstance(tests, list):
|
||||
# This is a list of AntaTestDefinition
|
||||
modules[module] = tests
|
||||
else:
|
||||
msg = f"Syntax error when parsing: {tests}\nIt must be a list of ANTA tests. Check the test catalog."
|
||||
raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError
|
||||
return modules
|
||||
|
||||
# ANN401 - Any ok for this validator as we are validating the received data
|
||||
# and cannot know in advance what it is.
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def check_tests(cls, data: Any) -> Any:
|
||||
"""
|
||||
Allow the user to provide a Python data structure that only has string values.
|
||||
def check_tests(cls: type[AntaCatalogFile], data: Any) -> Any: # noqa: ANN401
|
||||
"""Allow the user to provide a Python data structure that only has string values.
|
||||
|
||||
This validator will try to flatten and import Python modules, check if the tests classes
|
||||
are actually defined in their respective Python module and instantiate Input instances
|
||||
with provided value to validate test inputs.
|
||||
"""
|
||||
|
||||
def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[ModuleType, list[Any]]:
|
||||
"""
|
||||
Allow the user to provide a data structure with nested Python modules.
|
||||
|
||||
Example:
|
||||
```
|
||||
anta.tests.routing:
|
||||
generic:
|
||||
- <AntaTestDefinition>
|
||||
bgp:
|
||||
- <AntaTestDefinition>
|
||||
```
|
||||
`anta.tests.routing.generic` and `anta.tests.routing.bgp` are importable Python modules.
|
||||
"""
|
||||
modules: dict[ModuleType, list[Any]] = {}
|
||||
for module_name, tests in data.items():
|
||||
if package and not module_name.startswith("."):
|
||||
module_name = f".{module_name}"
|
||||
try:
|
||||
module: ModuleType = importlib.import_module(name=module_name, package=package)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
# A test module is potentially user-defined code.
|
||||
# We need to catch everything if we want to have meaningful logs
|
||||
module_str = f"{module_name[1:] if module_name.startswith('.') else module_name}{f' from package {package}' if package else ''}"
|
||||
message = f"Module named {module_str} cannot be imported. Verify that the module exists and there is no Python syntax issues."
|
||||
anta_log_exception(e, message, logger)
|
||||
raise ValueError(message) from e
|
||||
if isinstance(tests, dict):
|
||||
# This is an inner Python module
|
||||
modules.update(flatten_modules(data=tests, package=module.__name__))
|
||||
else:
|
||||
if not isinstance(tests, list):
|
||||
raise ValueError(f"Syntax error when parsing: {tests}\nIt must be a list of ANTA tests. Check the test catalog.")
|
||||
# This is a list of AntaTestDefinition
|
||||
modules[module] = tests
|
||||
return modules
|
||||
|
||||
if isinstance(data, dict):
|
||||
typed_data: dict[ModuleType, list[Any]] = flatten_modules(data)
|
||||
if not data:
|
||||
return data
|
||||
typed_data: dict[ModuleType, list[Any]] = AntaCatalogFile.flatten_modules(data)
|
||||
for module, tests in typed_data.items():
|
||||
test_definitions: list[AntaTestDefinition] = []
|
||||
for test_definition in tests:
|
||||
if isinstance(test_definition, AntaTestDefinition):
|
||||
test_definitions.append(test_definition)
|
||||
continue
|
||||
if not isinstance(test_definition, dict):
|
||||
raise ValueError(f"Syntax error when parsing: {test_definition}\nIt must be a dictionary. Check the test catalog.")
|
||||
msg = f"Syntax error when parsing: {test_definition}\nIt must be a dictionary. Check the test catalog."
|
||||
raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError
|
||||
if len(test_definition) != 1:
|
||||
raise ValueError(
|
||||
f"Syntax error when parsing: {test_definition}\nIt must be a dictionary with a single entry. Check the indentation in the test catalog."
|
||||
msg = (
|
||||
f"Syntax error when parsing: {test_definition}\n"
|
||||
"It must be a dictionary with a single entry. Check the indentation in the test catalog."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
for test_name, test_inputs in test_definition.copy().items():
|
||||
test: type[AntaTest] | None = getattr(module, test_name, None)
|
||||
if test is None:
|
||||
raise ValueError(
|
||||
f"{test_name} is not defined in Python module {module.__name__}{f' (from {module.__file__})' if module.__file__ is not None else ''}"
|
||||
msg = (
|
||||
f"{test_name} is not defined in Python module {module.__name__}"
|
||||
f"{f' (from {module.__file__})' if module.__file__ is not None else ''}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
test_definitions.append(AntaTestDefinition(test=test, inputs=test_inputs))
|
||||
typed_data[module] = test_definitions
|
||||
return typed_data
|
||||
return typed_data
|
||||
return data
|
||||
|
||||
def yaml(self) -> str:
|
||||
"""Return a YAML representation string of this model.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The YAML representation string of this model.
|
||||
"""
|
||||
# TODO: Pydantic and YAML serialization/deserialization is not supported natively.
|
||||
# This could be improved.
|
||||
# https://github.com/pydantic/pydantic/issues/1043
|
||||
# Explore if this worth using this: https://github.com/NowanIlfideme/pydantic-yaml
|
||||
return safe_dump(safe_load(self.model_dump_json(serialize_as_any=True, exclude_unset=True)), indent=2, width=math.inf)
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Return a JSON representation string of this model.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The JSON representation string of this model.
|
||||
"""
|
||||
return self.model_dump_json(serialize_as_any=True, exclude_unset=True, indent=2)
|
||||
|
||||
|
||||
class AntaCatalog:
|
||||
"""
|
||||
Class representing an ANTA Catalog.
|
||||
"""Class representing an ANTA Catalog.
|
||||
|
||||
It can be instantiated using its contructor or one of the static methods: `parse()`, `from_list()` or `from_dict()`
|
||||
It can be instantiated using its constructor or one of the static methods: `parse()`, `from_list()` or `from_dict()`
|
||||
"""
|
||||
|
||||
def __init__(self, tests: list[AntaTestDefinition] | None = None, filename: str | Path | None = None) -> None:
|
||||
"""
|
||||
Constructor of AntaCatalog.
|
||||
def __init__(
|
||||
self,
|
||||
tests: list[AntaTestDefinition] | None = None,
|
||||
filename: str | Path | None = None,
|
||||
) -> None:
|
||||
"""Instantiate an AntaCatalog instance.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
tests
|
||||
A list of AntaTestDefinition instances.
|
||||
filename
|
||||
The path from which the catalog is loaded.
|
||||
|
||||
Args:
|
||||
tests: A list of AntaTestDefinition instances.
|
||||
filename: The path from which the catalog is loaded.
|
||||
"""
|
||||
self._tests: list[AntaTestDefinition] = []
|
||||
if tests is not None:
|
||||
|
@ -194,37 +296,61 @@ class AntaCatalog:
|
|||
else:
|
||||
self._filename = Path(filename)
|
||||
|
||||
self.indexes_built: bool
|
||||
self.tag_to_tests: defaultdict[str | None, set[AntaTestDefinition]]
|
||||
self._init_indexes()
|
||||
|
||||
def _init_indexes(self) -> None:
|
||||
"""Init indexes related variables."""
|
||||
self.tag_to_tests = defaultdict(set)
|
||||
self.indexes_built = False
|
||||
|
||||
@property
|
||||
def filename(self) -> Path | None:
|
||||
"""Path of the file used to create this AntaCatalog instance"""
|
||||
"""Path of the file used to create this AntaCatalog instance."""
|
||||
return self._filename
|
||||
|
||||
@property
|
||||
def tests(self) -> list[AntaTestDefinition]:
|
||||
"""List of AntaTestDefinition in this catalog"""
|
||||
"""List of AntaTestDefinition in this catalog."""
|
||||
return self._tests
|
||||
|
||||
@tests.setter
|
||||
def tests(self, value: list[AntaTestDefinition]) -> None:
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("The catalog must contain a list of tests")
|
||||
msg = "The catalog must contain a list of tests"
|
||||
raise TypeError(msg)
|
||||
for t in value:
|
||||
if not isinstance(t, AntaTestDefinition):
|
||||
raise ValueError("A test in the catalog must be an AntaTestDefinition instance")
|
||||
msg = "A test in the catalog must be an AntaTestDefinition instance"
|
||||
raise TypeError(msg)
|
||||
self._tests = value
|
||||
|
||||
@staticmethod
|
||||
def parse(filename: str | Path) -> AntaCatalog:
|
||||
"""
|
||||
Create an AntaCatalog instance from a test catalog file.
|
||||
def parse(filename: str | Path, file_format: Literal["yaml", "json"] = "yaml") -> AntaCatalog:
|
||||
"""Create an AntaCatalog instance from a test catalog file.
|
||||
|
||||
Args:
|
||||
filename: Path to test catalog YAML file
|
||||
Parameters
|
||||
----------
|
||||
filename
|
||||
Path to test catalog YAML or JSON file.
|
||||
file_format
|
||||
Format of the file, either 'yaml' or 'json'.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AntaCatalog
|
||||
An AntaCatalog populated with the file content.
|
||||
"""
|
||||
if file_format not in ["yaml", "json"]:
|
||||
message = f"'{file_format}' is not a valid format for an AntaCatalog file. Only 'yaml' and 'json' are supported."
|
||||
raise ValueError(message)
|
||||
|
||||
try:
|
||||
with open(file=filename, mode="r", encoding="UTF-8") as file:
|
||||
data = safe_load(file)
|
||||
except (TypeError, YAMLError, OSError) as e:
|
||||
file: Path = filename if isinstance(filename, Path) else Path(filename)
|
||||
with file.open(encoding="UTF-8") as f:
|
||||
data = safe_load(f) if file_format == "yaml" else json_load(f)
|
||||
except (TypeError, YAMLError, OSError, ValueError) as e:
|
||||
message = f"Unable to parse ANTA Test Catalog file '{filename}'"
|
||||
anta_log_exception(e, message, logger)
|
||||
raise
|
||||
|
@ -233,15 +359,23 @@ class AntaCatalog:
|
|||
|
||||
@staticmethod
|
||||
def from_dict(data: RawCatalogInput, filename: str | Path | None = None) -> AntaCatalog:
|
||||
"""
|
||||
Create an AntaCatalog instance from a dictionary data structure.
|
||||
"""Create an AntaCatalog instance from a dictionary data structure.
|
||||
|
||||
See RawCatalogInput type alias for details.
|
||||
It is the data structure returned by `yaml.load()` function of a valid
|
||||
YAML Test Catalog file.
|
||||
|
||||
Args:
|
||||
data: Python dictionary used to instantiate the AntaCatalog instance
|
||||
filename: value to be set as AntaCatalog instance attribute
|
||||
Parameters
|
||||
----------
|
||||
data
|
||||
Python dictionary used to instantiate the AntaCatalog instance.
|
||||
filename
|
||||
value to be set as AntaCatalog instance attribute
|
||||
|
||||
Returns
|
||||
-------
|
||||
AntaCatalog
|
||||
An AntaCatalog populated with the 'data' dictionary content.
|
||||
"""
|
||||
tests: list[AntaTestDefinition] = []
|
||||
if data is None:
|
||||
|
@ -249,12 +383,17 @@ class AntaCatalog:
|
|||
return AntaCatalog(filename=filename)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Wrong input type for catalog data{f' (from {filename})' if filename is not None else ''}, must be a dict, got {type(data).__name__}")
|
||||
msg = f"Wrong input type for catalog data{f' (from {filename})' if filename is not None else ''}, must be a dict, got {type(data).__name__}"
|
||||
raise TypeError(msg)
|
||||
|
||||
try:
|
||||
catalog_data = AntaCatalogFile(**data) # type: ignore[arg-type]
|
||||
catalog_data = AntaCatalogFile(data) # type: ignore[arg-type]
|
||||
except ValidationError as e:
|
||||
anta_log_exception(e, f"Test catalog is invalid!{f' (from {filename})' if filename is not None else ''}", logger)
|
||||
anta_log_exception(
|
||||
e,
|
||||
f"Test catalog is invalid!{f' (from {filename})' if filename is not None else ''}",
|
||||
logger,
|
||||
)
|
||||
raise
|
||||
for t in catalog_data.root.values():
|
||||
tests.extend(t)
|
||||
|
@ -262,12 +401,19 @@ class AntaCatalog:
|
|||
|
||||
@staticmethod
|
||||
def from_list(data: ListAntaTestTuples) -> AntaCatalog:
|
||||
"""
|
||||
Create an AntaCatalog instance from a list data structure.
|
||||
"""Create an AntaCatalog instance from a list data structure.
|
||||
|
||||
See ListAntaTestTuples type alias for details.
|
||||
|
||||
Args:
|
||||
data: Python list used to instantiate the AntaCatalog instance
|
||||
Parameters
|
||||
----------
|
||||
data
|
||||
Python list used to instantiate the AntaCatalog instance.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AntaCatalog
|
||||
An AntaCatalog populated with the 'data' list content.
|
||||
"""
|
||||
tests: list[AntaTestDefinition] = []
|
||||
try:
|
||||
|
@ -277,15 +423,120 @@ class AntaCatalog:
|
|||
raise
|
||||
return AntaCatalog(tests)
|
||||
|
||||
def get_tests_by_tags(self, tags: list[str], strict: bool = False) -> list[AntaTestDefinition]:
|
||||
@classmethod
|
||||
def merge_catalogs(cls, catalogs: list[AntaCatalog]) -> AntaCatalog:
|
||||
"""Merge multiple AntaCatalog instances.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
catalogs
|
||||
A list of AntaCatalog instances to merge.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AntaCatalog
|
||||
A new AntaCatalog instance containing the tests of all the input catalogs.
|
||||
"""
|
||||
Return all the tests that have matching tags in their input filters.
|
||||
If strict=True, returns only tests that match all the tags provided as input.
|
||||
If strict=False, return all the tests that match at least one tag provided as input.
|
||||
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.
|
||||
"""
|
||||
result: list[AntaTestDefinition] = []
|
||||
# TODO: Use a decorator to deprecate this method instead. See https://github.com/aristanetworks/anta/issues/754
|
||||
warn(
|
||||
message="AntaCatalog.merge() is deprecated and will be removed in ANTA v2.0. Use AntaCatalog.merge_catalogs() instead.",
|
||||
category=DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self.merge_catalogs([self, catalog])
|
||||
|
||||
def dump(self) -> AntaCatalogFile:
|
||||
"""Return an AntaCatalogFile instance from this AntaCatalog instance.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AntaCatalogFile
|
||||
An AntaCatalogFile instance containing tests of this AntaCatalog instance.
|
||||
"""
|
||||
root: dict[ImportString[Any], list[AntaTestDefinition]] = {}
|
||||
for test in self.tests:
|
||||
if test.inputs.filters and (f := test.inputs.filters.tags):
|
||||
if (strict and all(t in tags for t in f)) or (not strict and any(t in tags for t in f)):
|
||||
result.append(test)
|
||||
return result
|
||||
# Cannot use AntaTest.module property as the class is not instantiated
|
||||
root.setdefault(test.test.__module__, []).append(test)
|
||||
return AntaCatalogFile(root=root)
|
||||
|
||||
def build_indexes(self, filtered_tests: set[str] | None = None) -> None:
|
||||
"""Indexes tests by their tags for quick access during filtering operations.
|
||||
|
||||
If a `filtered_tests` set is provided, only the tests in this set will be indexed.
|
||||
|
||||
This method populates the tag_to_tests attribute, which is a dictionary mapping tags to sets of tests.
|
||||
|
||||
Once the indexes are built, the `indexes_built` attribute is set to True.
|
||||
"""
|
||||
for test in self.tests:
|
||||
# Skip tests that are not in the specified filtered_tests set
|
||||
if filtered_tests and test.test.name not in filtered_tests:
|
||||
continue
|
||||
|
||||
# Indexing by tag
|
||||
if test.inputs.filters and (test_tags := test.inputs.filters.tags):
|
||||
for tag in test_tags:
|
||||
self.tag_to_tests[tag].add(test)
|
||||
else:
|
||||
self.tag_to_tests[None].add(test)
|
||||
|
||||
self.indexes_built = True
|
||||
|
||||
def clear_indexes(self) -> None:
|
||||
"""Clear this AntaCatalog instance indexes."""
|
||||
self._init_indexes()
|
||||
|
||||
def get_tests_by_tags(self, tags: set[str], *, strict: bool = False) -> set[AntaTestDefinition]:
|
||||
"""Return all tests that match a given set of tags, according to the specified strictness.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
tags
|
||||
The tags to filter tests by. If empty, return all tests without tags.
|
||||
strict
|
||||
If True, returns only tests that contain all specified tags (intersection).
|
||||
If False, returns tests that contain any of the specified tags (union).
|
||||
|
||||
Returns
|
||||
-------
|
||||
set[AntaTestDefinition]
|
||||
A set of tests that match the given tags.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the indexes have not been built prior to method call.
|
||||
"""
|
||||
if not self.indexes_built:
|
||||
msg = "Indexes have not been built yet. Call build_indexes() first."
|
||||
raise ValueError(msg)
|
||||
if not tags:
|
||||
return self.tag_to_tests[None]
|
||||
|
||||
filtered_sets = [self.tag_to_tests[tag] for tag in tags if tag in self.tag_to_tests]
|
||||
if not filtered_sets:
|
||||
return set()
|
||||
|
||||
if strict:
|
||||
return set.intersection(*filtered_sets)
|
||||
return set.union(*filtered_sets)
|
||||
|
|
|
@ -1,72 +1,41 @@
|
|||
#!/usr/bin/env python
|
||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
ANTA CLI
|
||||
"""
|
||||
"""ANTA CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
import sys
|
||||
from typing import Callable
|
||||
|
||||
import click
|
||||
from anta import __DEBUG__
|
||||
|
||||
from anta import GITHUB_SUGGESTION, __version__
|
||||
from anta.cli.check import check as check_command
|
||||
from anta.cli.debug import debug as debug_command
|
||||
from anta.cli.exec import exec as exec_command
|
||||
from anta.cli.get import get as get_command
|
||||
from anta.cli.nrfu import nrfu as nrfu_command
|
||||
from anta.cli.utils import AliasedGroup, ExitCode
|
||||
from anta.logger import Log, LogLevel, anta_log_exception, setup_logging
|
||||
# Note: need to separate this file from _main to be able to fail on the import.
|
||||
try:
|
||||
from ._main import anta, cli
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
except ImportError as exc:
|
||||
|
||||
def build_cli(exception: Exception) -> Callable[[], None]:
|
||||
"""Build CLI function using the caught exception."""
|
||||
|
||||
@click.group(cls=AliasedGroup)
|
||||
@click.pass_context
|
||||
@click.version_option(__version__)
|
||||
@click.option(
|
||||
"--log-file",
|
||||
help="Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout.",
|
||||
show_envvar=True,
|
||||
type=click.Path(file_okay=True, dir_okay=False, writable=True, path_type=pathlib.Path),
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
"-l",
|
||||
help="ANTA logging level",
|
||||
default=logging.getLevelName(logging.INFO),
|
||||
show_envvar=True,
|
||||
show_default=True,
|
||||
type=click.Choice(
|
||||
[Log.CRITICAL, Log.ERROR, Log.WARNING, Log.INFO, Log.DEBUG],
|
||||
case_sensitive=False,
|
||||
),
|
||||
)
|
||||
def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> None:
|
||||
"""Arista Network Test Automation (ANTA) CLI"""
|
||||
ctx.ensure_object(dict)
|
||||
setup_logging(log_level, log_file)
|
||||
def wrap() -> None:
|
||||
"""Error message if any CLI dependency is missing."""
|
||||
print(
|
||||
"The ANTA command line client could not run because the required "
|
||||
"dependencies were not installed.\nMake sure you've installed "
|
||||
"everything with: pip install 'anta[cli]'"
|
||||
)
|
||||
if __DEBUG__:
|
||||
print(f"The caught exception was: {exception}")
|
||||
|
||||
sys.exit(1)
|
||||
|
||||
anta.add_command(nrfu_command)
|
||||
anta.add_command(check_command)
|
||||
anta.add_command(exec_command)
|
||||
anta.add_command(get_command)
|
||||
anta.add_command(debug_command)
|
||||
return wrap
|
||||
|
||||
cli = build_cli(exc)
|
||||
|
||||
def cli() -> None:
|
||||
"""Entrypoint for pyproject.toml"""
|
||||
try:
|
||||
anta(obj={}, auto_envvar_prefix="ANTA")
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
anta_log_exception(e, f"Uncaught Exception when running ANTA CLI\n{GITHUB_SUGGESTION}", logger)
|
||||
sys.exit(ExitCode.INTERNAL_ERROR)
|
||||
|
||||
__all__ = ["anta", "cli"]
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
|
|
71
anta/cli/_main.py
Normal file
71
anta/cli/_main.py
Normal file
|
@ -0,0 +1,71 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""ANTA CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from anta import GITHUB_SUGGESTION, __version__
|
||||
from anta.cli.check import check as check_command
|
||||
from anta.cli.debug import debug as debug_command
|
||||
from anta.cli.exec import _exec as exec_command
|
||||
from anta.cli.get import get as get_command
|
||||
from anta.cli.nrfu import nrfu as nrfu_command
|
||||
from anta.cli.utils import AliasedGroup, ExitCode
|
||||
from anta.logger import Log, LogLevel, anta_log_exception, setup_logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@click.group(cls=AliasedGroup)
|
||||
@click.pass_context
|
||||
@click.help_option(allow_from_autoenv=False)
|
||||
@click.version_option(__version__, allow_from_autoenv=False)
|
||||
@click.option(
|
||||
"--log-file",
|
||||
help="Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout.",
|
||||
show_envvar=True,
|
||||
type=click.Path(file_okay=True, dir_okay=False, writable=True, path_type=pathlib.Path),
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
"-l",
|
||||
help="ANTA logging level",
|
||||
default=logging.getLevelName(logging.INFO),
|
||||
show_envvar=True,
|
||||
show_default=True,
|
||||
type=click.Choice(
|
||||
[Log.CRITICAL, Log.ERROR, Log.WARNING, Log.INFO, Log.DEBUG],
|
||||
case_sensitive=False,
|
||||
),
|
||||
)
|
||||
def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> None:
|
||||
"""Arista Network Test Automation (ANTA) CLI."""
|
||||
ctx.ensure_object(dict)
|
||||
setup_logging(log_level, log_file)
|
||||
|
||||
|
||||
anta.add_command(nrfu_command)
|
||||
anta.add_command(check_command)
|
||||
anta.add_command(exec_command)
|
||||
anta.add_command(get_command)
|
||||
anta.add_command(debug_command)
|
||||
|
||||
|
||||
def cli() -> None:
|
||||
"""Entrypoint for pyproject.toml."""
|
||||
try:
|
||||
anta(obj={}, auto_envvar_prefix="ANTA")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
anta_log_exception(
|
||||
exc,
|
||||
f"Uncaught Exception when running ANTA CLI\n{GITHUB_SUGGESTION}",
|
||||
logger,
|
||||
)
|
||||
sys.exit(ExitCode.INTERNAL_ERROR)
|
|
@ -1,9 +1,8 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Click commands to validate configuration files
|
||||
"""
|
||||
"""Click commands to validate configuration files."""
|
||||
|
||||
import click
|
||||
|
||||
from anta.cli.check import commands
|
||||
|
@ -11,7 +10,7 @@ from anta.cli.check import commands
|
|||
|
||||
@click.group
|
||||
def check() -> None:
|
||||
"""Commands to validate configuration files"""
|
||||
"""Commands to validate configuration files."""
|
||||
|
||||
|
||||
check.add_command(commands.catalog)
|
||||
|
|
|
@ -2,28 +2,28 @@
|
|||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
# pylint: disable = redefined-outer-name
|
||||
"""
|
||||
Click commands to validate configuration files
|
||||
"""
|
||||
"""Click commands to validate configuration files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
from rich.pretty import pretty_repr
|
||||
|
||||
from anta.catalog import AntaCatalog
|
||||
from anta.cli.console import console
|
||||
from anta.cli.utils import catalog_options
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.catalog import AntaCatalog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@click.command
|
||||
@catalog_options
|
||||
def catalog(catalog: AntaCatalog) -> None:
|
||||
"""
|
||||
Check that the catalog is valid
|
||||
"""
|
||||
"""Check that the catalog is valid."""
|
||||
console.print(f"[bold][green]Catalog is valid: {catalog.filename}")
|
||||
console.print(pretty_repr(catalog.tests))
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
ANTA Top-level Console
|
||||
https://rich.readthedocs.io/en/stable/console.html#console-api
|
||||
"""ANTA Top-level Console.
|
||||
|
||||
https://rich.readthedocs.io/en/stable/console.html#console-api.
|
||||
"""
|
||||
|
||||
from rich.console import Console
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Click commands to execute EOS commands on remote devices
|
||||
"""
|
||||
"""Click commands to execute EOS commands on remote devices."""
|
||||
|
||||
import click
|
||||
|
||||
from anta.cli.debug import commands
|
||||
|
@ -11,7 +10,7 @@ from anta.cli.debug import commands
|
|||
|
||||
@click.group
|
||||
def debug() -> None:
|
||||
"""Commands to execute EOS commands on remote devices"""
|
||||
"""Commands to execute EOS commands on remote devices."""
|
||||
|
||||
|
||||
debug.add_command(commands.run_cmd)
|
||||
|
|
|
@ -2,23 +2,24 @@
|
|||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
# pylint: disable = redefined-outer-name
|
||||
"""
|
||||
Click commands to execute EOS commands on remote devices
|
||||
"""
|
||||
"""Click commands to execute EOS commands on remote devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Literal
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import click
|
||||
|
||||
from anta.cli.console import console
|
||||
from anta.cli.debug.utils import debug_options
|
||||
from anta.cli.utils import ExitCode
|
||||
from anta.device import AntaDevice
|
||||
from anta.models import AntaCommand, AntaTemplate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.device import AntaDevice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -26,8 +27,15 @@ logger = logging.getLogger(__name__)
|
|||
@debug_options
|
||||
@click.pass_context
|
||||
@click.option("--command", "-c", type=str, required=True, help="Command to run")
|
||||
def run_cmd(ctx: click.Context, device: AntaDevice, command: str, ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int) -> None:
|
||||
"""Run arbitrary command to an ANTA device"""
|
||||
def run_cmd(
|
||||
ctx: click.Context,
|
||||
device: AntaDevice,
|
||||
command: str,
|
||||
ofmt: Literal["json", "text"],
|
||||
version: Literal["1", "latest"],
|
||||
revision: int,
|
||||
) -> None:
|
||||
"""Run arbitrary command to an ANTA device."""
|
||||
console.print(f"Run command [green]{command}[/green] on [red]{device.name}[/red]")
|
||||
# I do not assume the following line, but click make me do it
|
||||
v: Literal[1, "latest"] = version if version == "latest" else 1
|
||||
|
@ -45,18 +53,34 @@ def run_cmd(ctx: click.Context, device: AntaDevice, command: str, ofmt: Literal[
|
|||
@click.command
|
||||
@debug_options
|
||||
@click.pass_context
|
||||
@click.option("--template", "-t", type=str, required=True, help="Command template to run. E.g. 'show vlan {vlan_id}'")
|
||||
@click.option(
|
||||
"--template",
|
||||
"-t",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Command template to run. E.g. 'show vlan {vlan_id}'",
|
||||
)
|
||||
@click.argument("params", required=True, nargs=-1)
|
||||
def run_template(
|
||||
ctx: click.Context, device: AntaDevice, template: str, params: list[str], ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int
|
||||
ctx: click.Context,
|
||||
device: AntaDevice,
|
||||
template: str,
|
||||
params: list[str],
|
||||
ofmt: Literal["json", "text"],
|
||||
version: Literal["1", "latest"],
|
||||
revision: int,
|
||||
) -> None:
|
||||
# pylint: disable=too-many-arguments
|
||||
# Using \b for click
|
||||
# ruff: noqa: D301
|
||||
"""Run arbitrary templated command to an ANTA device.
|
||||
|
||||
Takes a list of arguments (keys followed by a value) to build a dictionary used as template parameters.
|
||||
Example:
|
||||
|
||||
anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1
|
||||
\b
|
||||
Example
|
||||
-------
|
||||
anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1
|
||||
|
||||
"""
|
||||
template_params = dict(zip(params[::2], params[1::2]))
|
||||
|
||||
|
@ -64,7 +88,7 @@ def run_template(
|
|||
# I do not assume the following line, but click make me do it
|
||||
v: Literal[1, "latest"] = version if version == "latest" else 1
|
||||
t = AntaTemplate(template=template, ofmt=ofmt, version=v, revision=revision)
|
||||
c = t.render(**template_params) # type: ignore
|
||||
c = t.render(**template_params)
|
||||
asyncio.run(device.collect(c))
|
||||
if not c.collected:
|
||||
console.print(f"[bold red] Command '{c.command}' failed to execute!")
|
||||
|
|
|
@ -1,40 +1,56 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Utils functions to use with anta.cli.debug module.
|
||||
"""
|
||||
"""Utils functions to use with anta.cli.debug module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
import click
|
||||
|
||||
from anta.cli.utils import ExitCode, inventory_options
|
||||
from anta.inventory import AntaInventory
|
||||
from anta.cli.utils import ExitCode, core_options
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.inventory import AntaInventory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def debug_options(f: Any) -> Any:
|
||||
"""Click common options required to execute a command on a specific device"""
|
||||
def debug_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Click common options required to execute a command on a specific device."""
|
||||
|
||||
@inventory_options
|
||||
@click.option("--ofmt", type=click.Choice(["json", "text"]), default="json", help="EOS eAPI format to use. can be text or json")
|
||||
@click.option("--version", "-v", type=click.Choice(["1", "latest"]), default="latest", help="EOS eAPI version")
|
||||
@core_options
|
||||
@click.option(
|
||||
"--ofmt",
|
||||
type=click.Choice(["json", "text"]),
|
||||
default="json",
|
||||
help="EOS eAPI format to use. can be text or json",
|
||||
)
|
||||
@click.option(
|
||||
"--version",
|
||||
"-v",
|
||||
type=click.Choice(["1", "latest"]),
|
||||
default="latest",
|
||||
help="EOS eAPI version",
|
||||
)
|
||||
@click.option("--revision", "-r", type=int, help="eAPI command revision", required=False)
|
||||
@click.option("--device", "-d", type=str, required=True, help="Device from inventory to use")
|
||||
@click.pass_context
|
||||
@functools.wraps(f)
|
||||
def wrapper(ctx: click.Context, *args: tuple[Any], inventory: AntaInventory, tags: list[str] | None, device: str, **kwargs: dict[str, Any]) -> Any:
|
||||
# pylint: disable=unused-argument
|
||||
try:
|
||||
d = inventory[device]
|
||||
except KeyError as e:
|
||||
message = f"Device {device} does not exist in Inventory"
|
||||
logger.error(e, message)
|
||||
def wrapper(
|
||||
ctx: click.Context,
|
||||
*args: tuple[Any],
|
||||
inventory: AntaInventory,
|
||||
device: str,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
# TODO: @gmuloc - tags come from context https://github.com/aristanetworks/anta/issues/584
|
||||
# ruff: noqa: ARG001
|
||||
if (d := inventory.get(device)) is None:
|
||||
logger.error("Device '%s' does not exist in Inventory", device)
|
||||
ctx.exit(ExitCode.USAGE_ERROR)
|
||||
return f(*args, device=d, **kwargs)
|
||||
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Click commands to execute various scripts on EOS devices
|
||||
"""
|
||||
"""Click commands to execute various scripts on EOS devices."""
|
||||
|
||||
import click
|
||||
|
||||
from anta.cli.exec import commands
|
||||
|
||||
|
||||
@click.group
|
||||
def exec() -> None: # pylint: disable=redefined-builtin
|
||||
"""Commands to execute various scripts on EOS devices"""
|
||||
@click.group("exec")
|
||||
def _exec() -> None:
|
||||
"""Commands to execute various scripts on EOS devices."""
|
||||
|
||||
|
||||
exec.add_command(commands.clear_counters)
|
||||
exec.add_command(commands.snapshot)
|
||||
exec.add_command(commands.collect_tech_support)
|
||||
_exec.add_command(commands.clear_counters)
|
||||
_exec.add_command(commands.snapshot)
|
||||
_exec.add_command(commands.collect_tech_support)
|
||||
|
|
|
@ -1,32 +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.
|
||||
"""
|
||||
Click commands to execute various scripts on EOS devices
|
||||
"""
|
||||
"""Click commands to execute various scripts on EOS devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
from yaml import safe_load
|
||||
|
||||
from anta.cli.exec.utils import clear_counters_utils, collect_commands, collect_scheduled_show_tech
|
||||
from anta.cli.console import console
|
||||
from anta.cli.exec import utils
|
||||
from anta.cli.utils import inventory_options
|
||||
from anta.inventory import AntaInventory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.inventory import AntaInventory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@click.command
|
||||
@inventory_options
|
||||
def clear_counters(inventory: AntaInventory, tags: list[str] | None) -> None:
|
||||
"""Clear counter statistics on EOS devices"""
|
||||
asyncio.run(clear_counters_utils(inventory, tags=tags))
|
||||
def clear_counters(inventory: AntaInventory, tags: set[str] | None) -> None:
|
||||
"""Clear counter statistics on EOS devices."""
|
||||
asyncio.run(utils.clear_counters(inventory, tags=tags))
|
||||
|
||||
|
||||
@click.command()
|
||||
|
@ -45,34 +48,57 @@ def clear_counters(inventory: AntaInventory, tags: list[str] | None) -> None:
|
|||
show_envvar=True,
|
||||
type=click.Path(file_okay=False, dir_okay=True, exists=False, writable=True, path_type=Path),
|
||||
help="Directory to save commands output.",
|
||||
default=f"anta_snapshot_{datetime.now().strftime('%Y-%m-%d_%H_%M_%S')}",
|
||||
default=f"anta_snapshot_{datetime.now(tz=timezone.utc).astimezone().strftime('%Y-%m-%d_%H_%M_%S')}",
|
||||
show_default=True,
|
||||
)
|
||||
def snapshot(inventory: AntaInventory, tags: list[str] | None, commands_list: Path, output: Path) -> None:
|
||||
"""Collect commands output from devices in inventory"""
|
||||
print(f"Collecting data for {commands_list}")
|
||||
print(f"Output directory is {output}")
|
||||
def snapshot(inventory: AntaInventory, tags: set[str] | None, commands_list: Path, output: Path) -> None:
|
||||
"""Collect commands output from devices in inventory."""
|
||||
console.print(f"Collecting data for {commands_list}")
|
||||
console.print(f"Output directory is {output}")
|
||||
try:
|
||||
with open(commands_list, "r", encoding="UTF-8") as file:
|
||||
with commands_list.open(encoding="UTF-8") as file:
|
||||
file_content = file.read()
|
||||
eos_commands = safe_load(file_content)
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Error reading {commands_list}")
|
||||
logger.error("Error reading %s", commands_list)
|
||||
sys.exit(1)
|
||||
asyncio.run(collect_commands(inventory, eos_commands, output, tags=tags))
|
||||
asyncio.run(utils.collect_commands(inventory, eos_commands, output, tags=tags))
|
||||
|
||||
|
||||
@click.command()
|
||||
@inventory_options
|
||||
@click.option("--output", "-o", default="./tech-support", show_default=True, help="Path for test catalog", type=click.Path(path_type=Path), required=False)
|
||||
@click.option("--latest", help="Number of scheduled show-tech to retrieve", type=int, required=False)
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
default="./tech-support",
|
||||
show_default=True,
|
||||
help="Path for test catalog",
|
||||
type=click.Path(path_type=Path),
|
||||
required=False,
|
||||
)
|
||||
@click.option(
|
||||
"--latest",
|
||||
help="Number of scheduled show-tech to retrieve",
|
||||
type=int,
|
||||
required=False,
|
||||
)
|
||||
@click.option(
|
||||
"--configure",
|
||||
help="Ensure devices have 'aaa authorization exec default local' configured (required for SCP on EOS). THIS WILL CHANGE THE CONFIGURATION OF YOUR NETWORK.",
|
||||
help=(
|
||||
"[DEPRECATED] Ensure devices have 'aaa authorization exec default local' configured (required for SCP on EOS). "
|
||||
"THIS WILL CHANGE THE CONFIGURATION OF YOUR NETWORK."
|
||||
),
|
||||
default=False,
|
||||
is_flag=True,
|
||||
show_default=True,
|
||||
)
|
||||
def collect_tech_support(inventory: AntaInventory, tags: list[str] | None, output: Path, latest: int | None, configure: bool) -> None:
|
||||
"""Collect scheduled tech-support from EOS devices"""
|
||||
asyncio.run(collect_scheduled_show_tech(inventory, output, configure, tags=tags, latest=latest))
|
||||
def collect_tech_support(
|
||||
inventory: AntaInventory,
|
||||
tags: set[str] | None,
|
||||
output: Path,
|
||||
latest: int | None,
|
||||
*,
|
||||
configure: bool,
|
||||
) -> None:
|
||||
"""Collect scheduled tech-support from EOS devices."""
|
||||
asyncio.run(utils.collect_show_tech(inventory, output, configure=configure, tags=tags, latest=latest))
|
||||
|
|
|
@ -2,35 +2,35 @@
|
|||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
|
||||
"""
|
||||
Exec CLI helpers
|
||||
"""
|
||||
"""Exec CLI helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from aioeapi import EapiCommandError
|
||||
from click.exceptions import UsageError
|
||||
from httpx import ConnectError, HTTPError
|
||||
|
||||
from anta.device import AntaDevice, AsyncEOSDevice
|
||||
from anta.inventory import AntaInventory
|
||||
from anta.models import AntaCommand
|
||||
from anta.tools import safe_command
|
||||
from asynceapi import EapiCommandError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.inventory import AntaInventory
|
||||
|
||||
EOS_SCHEDULED_TECH_SUPPORT = "/mnt/flash/schedule/tech-support"
|
||||
INVALID_CHAR = "`~!@#$/"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def clear_counters_utils(anta_inventory: AntaInventory, tags: list[str] | None = None) -> None:
|
||||
"""
|
||||
Clear counters
|
||||
"""
|
||||
async def clear_counters(anta_inventory: AntaInventory, tags: set[str] | None = None) -> None:
|
||||
"""Clear counters."""
|
||||
|
||||
async def clear(dev: AntaDevice) -> None:
|
||||
commands = [AntaCommand(command="clear counters")]
|
||||
|
@ -39,48 +39,51 @@ async def clear_counters_utils(anta_inventory: AntaInventory, tags: list[str] |
|
|||
await dev.collect_commands(commands=commands)
|
||||
for command in commands:
|
||||
if not command.collected:
|
||||
logger.error(f"Could not clear counters on device {dev.name}: {command.errors}")
|
||||
logger.info(f"Cleared counters on {dev.name} ({dev.hw_model})")
|
||||
logger.error("Could not clear counters on device %s: %s", dev.name, command.errors)
|
||||
logger.info("Cleared counters on %s (%s)", dev.name, dev.hw_model)
|
||||
|
||||
logger.info("Connecting to devices...")
|
||||
await anta_inventory.connect_inventory()
|
||||
devices = anta_inventory.get_inventory(established_only=True, tags=tags).values()
|
||||
devices = anta_inventory.get_inventory(established_only=True, tags=tags).devices
|
||||
logger.info("Clearing counters on remote devices...")
|
||||
await asyncio.gather(*(clear(device) for device in devices))
|
||||
|
||||
|
||||
async def collect_commands(
|
||||
inv: AntaInventory,
|
||||
commands: dict[str, str],
|
||||
commands: dict[str, list[str]],
|
||||
root_dir: Path,
|
||||
tags: list[str] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Collect EOS commands
|
||||
"""
|
||||
"""Collect EOS commands."""
|
||||
|
||||
async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "text"]) -> None:
|
||||
outdir = Path() / root_dir / dev.name / outformat
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
safe_command = re.sub(r"(/|\|$)", "_", command)
|
||||
c = AntaCommand(command=command, ofmt=outformat)
|
||||
await dev.collect(c)
|
||||
if not c.collected:
|
||||
logger.error(f"Could not collect commands on device {dev.name}: {c.errors}")
|
||||
logger.error("Could not collect commands on device %s: %s", dev.name, c.errors)
|
||||
return
|
||||
if c.ofmt == "json":
|
||||
outfile = outdir / f"{safe_command}.json"
|
||||
outfile = outdir / f"{safe_command(command)}.json"
|
||||
content = json.dumps(c.json_output, indent=2)
|
||||
elif c.ofmt == "text":
|
||||
outfile = outdir / f"{safe_command}.log"
|
||||
outfile = outdir / f"{safe_command(command)}.log"
|
||||
content = c.text_output
|
||||
else:
|
||||
logger.error("Command outformat is not in ['json', 'text'] for command '%s'", command)
|
||||
return
|
||||
with outfile.open(mode="w", encoding="UTF-8") as f:
|
||||
f.write(content)
|
||||
logger.info(f"Collected command '{command}' from device {dev.name} ({dev.hw_model})")
|
||||
logger.info("Collected command '%s' from device %s (%s)", command, dev.name, dev.hw_model)
|
||||
|
||||
logger.info("Connecting to devices...")
|
||||
await inv.connect_inventory()
|
||||
devices = inv.get_inventory(established_only=True, tags=tags).values()
|
||||
devices = inv.get_inventory(established_only=True, tags=tags).devices
|
||||
if not devices:
|
||||
logger.info("No online device found. Exiting")
|
||||
return
|
||||
logger.info("Collecting commands from remote devices")
|
||||
coros = []
|
||||
if "json_format" in commands:
|
||||
|
@ -90,18 +93,14 @@ async def collect_commands(
|
|||
res = await asyncio.gather(*coros, return_exceptions=True)
|
||||
for r in res:
|
||||
if isinstance(r, Exception):
|
||||
logger.error(f"Error when collecting commands: {str(r)}")
|
||||
logger.error("Error when collecting commands: %s", str(r))
|
||||
|
||||
|
||||
async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, configure: bool, tags: list[str] | None = None, latest: int | None = None) -> None:
|
||||
"""
|
||||
Collect scheduled show-tech on devices
|
||||
"""
|
||||
async def collect_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bool, tags: set[str] | None = None, latest: int | None = None) -> None:
|
||||
"""Collect scheduled show-tech on devices."""
|
||||
|
||||
async def collect(device: AntaDevice) -> None:
|
||||
"""
|
||||
Collect all the tech-support files stored on Arista switches flash and copy them locally
|
||||
"""
|
||||
"""Collect all the tech-support files stored on Arista switches flash and copy them locally."""
|
||||
try:
|
||||
# Get the tech-support filename to retrieve
|
||||
cmd = f"bash timeout 10 ls -1t {EOS_SCHEDULED_TECH_SUPPORT}"
|
||||
|
@ -109,12 +108,12 @@ async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, config
|
|||
cmd += f" | head -{latest}"
|
||||
command = AntaCommand(command=cmd, ofmt="text")
|
||||
await device.collect(command=command)
|
||||
if command.collected and command.text_output:
|
||||
filenames = list(map(lambda f: Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}"), command.text_output.splitlines()))
|
||||
else:
|
||||
logger.error(f"Unable to get tech-support filenames on {device.name}: verify that {EOS_SCHEDULED_TECH_SUPPORT} is not empty")
|
||||
if not (command.collected and command.text_output):
|
||||
logger.error("Unable to get tech-support filenames on %s: verify that %s is not empty", device.name, EOS_SCHEDULED_TECH_SUPPORT)
|
||||
return
|
||||
|
||||
filenames = [Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}") for f in command.text_output.splitlines()]
|
||||
|
||||
# Create directories
|
||||
outdir = Path() / root_dir / f"{device.name.lower()}"
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
|
@ -124,38 +123,49 @@ async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, config
|
|||
await device.collect(command=command)
|
||||
|
||||
if command.collected and not command.text_output:
|
||||
logger.debug(f"'aaa authorization exec default local' is not configured on device {device.name}")
|
||||
if configure:
|
||||
# Otherwise mypy complains about enable
|
||||
assert isinstance(device, AsyncEOSDevice)
|
||||
# TODO - @mtache - add `config` field to `AntaCommand` object to handle this use case.
|
||||
commands = []
|
||||
if device.enable and device._enable_password is not None: # pylint: disable=protected-access
|
||||
commands.append({"cmd": "enable", "input": device._enable_password}) # pylint: disable=protected-access
|
||||
elif device.enable:
|
||||
commands.append({"cmd": "enable"})
|
||||
commands.extend(
|
||||
[
|
||||
{"cmd": "configure terminal"},
|
||||
{"cmd": "aaa authorization exec default local"},
|
||||
]
|
||||
)
|
||||
logger.warning(f"Configuring 'aaa authorization exec default local' on device {device.name}")
|
||||
command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text")
|
||||
await device._session.cli(commands=commands) # pylint: disable=protected-access
|
||||
logger.info(f"Configured 'aaa authorization exec default local' on device {device.name}")
|
||||
else:
|
||||
logger.error(f"Unable to collect tech-support on {device.name}: configuration 'aaa authorization exec default local' is not present")
|
||||
logger.debug("'aaa authorization exec default local' is not configured on device %s", device.name)
|
||||
if not configure:
|
||||
logger.error("Unable to collect tech-support on %s: configuration 'aaa authorization exec default local' is not present", device.name)
|
||||
return
|
||||
logger.debug(f"'aaa authorization exec default local' is already configured on device {device.name}")
|
||||
|
||||
# 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 = []
|
||||
# TODO: @mtache - add `config` field to `AntaCommand` object to handle this use case.
|
||||
# Otherwise mypy complains about enable as it is only implemented for AsyncEOSDevice
|
||||
# TODO: Should enable be also included in AntaDevice?
|
||||
if not isinstance(device, AsyncEOSDevice):
|
||||
msg = "anta exec collect-tech-support is only supported with AsyncEOSDevice for now."
|
||||
raise UsageError(msg)
|
||||
if device.enable and device._enable_password is not None:
|
||||
commands.append({"cmd": "enable", "input": device._enable_password})
|
||||
elif device.enable:
|
||||
commands.append({"cmd": "enable"})
|
||||
commands.extend(
|
||||
[
|
||||
{"cmd": "configure terminal"},
|
||||
{"cmd": "aaa authorization exec default local"},
|
||||
],
|
||||
)
|
||||
logger.warning("Configuring 'aaa authorization exec default local' on device %s", device.name)
|
||||
command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text")
|
||||
await device._session.cli(commands=commands)
|
||||
logger.info("Configured 'aaa authorization exec default local' on device %s", device.name)
|
||||
|
||||
logger.debug("'aaa authorization exec default local' is already configured on device %s", device.name)
|
||||
|
||||
await device.copy(sources=filenames, destination=outdir, direction="from")
|
||||
logger.info(f"Collected {len(filenames)} scheduled tech-support from {device.name}")
|
||||
logger.info("Collected %s scheduled tech-support from %s", len(filenames), device.name)
|
||||
|
||||
except (EapiCommandError, HTTPError, ConnectError) as e:
|
||||
logger.error(f"Unable to collect tech-support on {device.name}: {str(e)}")
|
||||
logger.error("Unable to collect tech-support on %s: %s", device.name, str(e))
|
||||
|
||||
logger.info("Connecting to devices...")
|
||||
await inv.connect_inventory()
|
||||
devices = inv.get_inventory(established_only=True, tags=tags).values()
|
||||
devices = inv.get_inventory(established_only=True, tags=tags).devices
|
||||
await asyncio.gather(*(collect(device) for device in devices))
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Click commands to get information from or generate inventories
|
||||
"""
|
||||
"""Click commands to get information from or generate inventories."""
|
||||
|
||||
import click
|
||||
|
||||
from anta.cli.get import commands
|
||||
|
@ -11,10 +10,11 @@ from anta.cli.get import commands
|
|||
|
||||
@click.group
|
||||
def get() -> None:
|
||||
"""Commands to get information from or generate inventories"""
|
||||
"""Commands to get information from or generate inventories."""
|
||||
|
||||
|
||||
get.add_command(commands.from_cvp)
|
||||
get.add_command(commands.from_ansible)
|
||||
get.add_command(commands.inventory)
|
||||
get.add_command(commands.tags)
|
||||
get.add_command(commands.tests)
|
||||
|
|
|
@ -2,17 +2,18 @@
|
|||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
# pylint: disable = redefined-outer-name
|
||||
"""
|
||||
Click commands to get information from or generate inventories
|
||||
"""
|
||||
"""Click commands to get information from or generate inventories."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import click
|
||||
import requests
|
||||
from cvprac.cvp_client import CvpClient
|
||||
from cvprac.cvp_client_errors import CvpApiError
|
||||
from rich.pretty import pretty_repr
|
||||
|
@ -20,9 +21,11 @@ from rich.pretty import pretty_repr
|
|||
from anta.cli.console import console
|
||||
from anta.cli.get.utils import inventory_output_options
|
||||
from anta.cli.utils import ExitCode, inventory_options
|
||||
from anta.inventory import AntaInventory
|
||||
|
||||
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:
|
||||
from anta.inventory import AntaInventory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -34,33 +37,49 @@ logger = logging.getLogger(__name__)
|
|||
@click.option("--username", "-u", help="CloudVision username", type=str, required=True)
|
||||
@click.option("--password", "-p", help="CloudVision password", type=str, required=True)
|
||||
@click.option("--container", "-c", help="CloudVision container where devices are configured", type=str)
|
||||
def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str | None) -> None:
|
||||
"""
|
||||
Build ANTA inventory from Cloudvision
|
||||
@click.option(
|
||||
"--ignore-cert",
|
||||
help="Ignore verifying the SSL certificate when connecting to CloudVision",
|
||||
show_envvar=True,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
)
|
||||
def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str | None, *, ignore_cert: bool) -> None:
|
||||
"""Build ANTA inventory from CloudVision.
|
||||
|
||||
TODO - handle get_inventory and get_devices_in_container failure
|
||||
NOTE: Only username/password authentication is supported for on-premises CloudVision instances.
|
||||
Token authentication for both on-premises and CloudVision as a Service (CVaaS) is not supported.
|
||||
"""
|
||||
logger.info(f"Getting authentication token for user '{username}' from CloudVision instance '{host}'")
|
||||
token = get_cv_token(cvp_ip=host, cvp_username=username, cvp_password=password)
|
||||
# TODO: - Handle get_cv_token, get_inventory and get_devices_in_container failures.
|
||||
logger.info("Getting authentication token for user '%s' from CloudVision instance '%s'", username, host)
|
||||
try:
|
||||
token = get_cv_token(cvp_ip=host, cvp_username=username, cvp_password=password, verify_cert=not ignore_cert)
|
||||
except requests.exceptions.SSLError as error:
|
||||
logger.error("Authentication to CloudVison failed: %s.", error)
|
||||
ctx.exit(ExitCode.USAGE_ERROR)
|
||||
|
||||
clnt = CvpClient()
|
||||
try:
|
||||
clnt.connect(nodes=[host], username="", password="", api_token=token)
|
||||
except CvpApiError as error:
|
||||
logger.error(f"Error connecting to CloudVision: {error}")
|
||||
logger.error("Error connecting to CloudVision: %s", error)
|
||||
ctx.exit(ExitCode.USAGE_ERROR)
|
||||
logger.info(f"Connected to CloudVision instance '{host}'")
|
||||
logger.info("Connected to CloudVision instance '%s'", host)
|
||||
|
||||
cvp_inventory = None
|
||||
if container is None:
|
||||
# Get a list of all devices
|
||||
logger.info(f"Getting full inventory from CloudVision instance '{host}'")
|
||||
logger.info("Getting full inventory from CloudVision instance '%s'", host)
|
||||
cvp_inventory = clnt.api.get_inventory()
|
||||
else:
|
||||
# Get devices under a container
|
||||
logger.info(f"Getting inventory for container {container} from CloudVision instance '{host}'")
|
||||
logger.info("Getting inventory for container %s from CloudVision instance '%s'", container, host)
|
||||
cvp_inventory = clnt.api.get_devices_in_container(container)
|
||||
create_inventory_from_cvp(cvp_inventory, output)
|
||||
try:
|
||||
create_inventory_from_cvp(cvp_inventory, output)
|
||||
except OSError as e:
|
||||
logger.error(str(e))
|
||||
ctx.exit(ExitCode.USAGE_ERROR)
|
||||
|
||||
|
||||
@click.command
|
||||
|
@ -74,15 +93,19 @@ def from_cvp(ctx: click.Context, output: Path, host: str, username: str, passwor
|
|||
required=True,
|
||||
)
|
||||
def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_inventory: Path) -> None:
|
||||
"""Build ANTA inventory from an ansible inventory YAML file"""
|
||||
logger.info(f"Building inventory from ansible file '{ansible_inventory}'")
|
||||
"""Build ANTA inventory from an ansible inventory YAML file.
|
||||
|
||||
NOTE: This command does not support inline vaulted variables. Make sure to comment them out.
|
||||
|
||||
"""
|
||||
logger.info("Building inventory from ansible file '%s'", ansible_inventory)
|
||||
try:
|
||||
create_inventory_from_ansible(
|
||||
inventory=ansible_inventory,
|
||||
output=output,
|
||||
ansible_group=ansible_group,
|
||||
)
|
||||
except ValueError as e:
|
||||
except (ValueError, OSError) as e:
|
||||
logger.error(str(e))
|
||||
ctx.exit(ExitCode.USAGE_ERROR)
|
||||
|
||||
|
@ -90,10 +113,11 @@ def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_i
|
|||
@click.command
|
||||
@inventory_options
|
||||
@click.option("--connected/--not-connected", help="Display inventory after connection has been created", default=False, required=False)
|
||||
def inventory(inventory: AntaInventory, tags: list[str] | None, connected: bool) -> None:
|
||||
def inventory(inventory: AntaInventory, tags: set[str] | None, *, connected: bool) -> None:
|
||||
"""Show inventory loaded in ANTA."""
|
||||
|
||||
logger.debug(f"Requesting devices for tags: {tags}")
|
||||
# TODO: @gmuloc - tags come from context - we cannot have everything..
|
||||
# ruff: noqa: ARG001
|
||||
logger.debug("Requesting devices for tags: %s", tags)
|
||||
console.print("Current inventory content is:", style="white on blue")
|
||||
|
||||
if connected:
|
||||
|
@ -105,11 +129,32 @@ def inventory(inventory: AntaInventory, tags: list[str] | None, connected: bool)
|
|||
|
||||
@click.command
|
||||
@inventory_options
|
||||
def tags(inventory: AntaInventory, tags: list[str] | None) -> None: # pylint: disable=unused-argument
|
||||
def tags(inventory: AntaInventory, **kwargs: Any) -> None:
|
||||
"""Get list of configured tags in user inventory."""
|
||||
tags_found = []
|
||||
tags: set[str] = set()
|
||||
for device in inventory.values():
|
||||
tags_found += device.tags
|
||||
tags_found = sorted(set(tags_found))
|
||||
tags.update(device.tags)
|
||||
console.print("Tags found:")
|
||||
console.print_json(json.dumps(tags_found, 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)
|
||||
|
|
|
@ -1,34 +1,41 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Utils functions to use with anta.cli.get.commands module.
|
||||
"""
|
||||
"""Utils functions to use with anta.cli.get.commands module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import pkgutil
|
||||
import re
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from sys import stdin
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
import click
|
||||
import requests
|
||||
import urllib3
|
||||
import yaml
|
||||
|
||||
from anta.cli.console import console
|
||||
from anta.cli.utils import ExitCode
|
||||
from anta.inventory import AntaInventory
|
||||
from anta.inventory.models import AntaInventoryHost, AntaInventoryInput
|
||||
from anta.models import AntaTest
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def inventory_output_options(f: Any) -> Any:
|
||||
"""Click common options required when an inventory is being generated"""
|
||||
def inventory_output_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Click common options required when an inventory is being generated."""
|
||||
|
||||
@click.option(
|
||||
"--output",
|
||||
|
@ -50,7 +57,13 @@ def inventory_output_options(f: Any) -> Any:
|
|||
)
|
||||
@click.pass_context
|
||||
@functools.wraps(f)
|
||||
def wrapper(ctx: click.Context, *args: tuple[Any], output: Path, overwrite: bool, **kwargs: dict[str, Any]) -> Any:
|
||||
def wrapper(
|
||||
ctx: click.Context,
|
||||
*args: tuple[Any],
|
||||
output: Path,
|
||||
overwrite: bool,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> Any:
|
||||
# Boolean to check if the file is empty
|
||||
output_is_not_empty = output.exists() and output.stat().st_size != 0
|
||||
# Check overwrite when file is not empty
|
||||
|
@ -58,7 +71,10 @@ def inventory_output_options(f: Any) -> Any:
|
|||
is_tty = stdin.isatty()
|
||||
if is_tty:
|
||||
# File has content and it is in an interactive TTY --> Prompt user
|
||||
click.confirm(f"Your destination file '{output}' is not empty, continue?", abort=True)
|
||||
click.confirm(
|
||||
f"Your destination file '{output}' is not empty, continue?",
|
||||
abort=True,
|
||||
)
|
||||
else:
|
||||
# File has content and it is not interactive TTY nor overwrite set to True --> execution stop
|
||||
logger.critical("Conversion aborted since destination file is not empty (not running in interactive TTY)")
|
||||
|
@ -69,85 +85,292 @@ def inventory_output_options(f: Any) -> Any:
|
|||
return wrapper
|
||||
|
||||
|
||||
def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str) -> str:
|
||||
"""Generate AUTH token from CVP using password"""
|
||||
# TODO, need to handle requests eror
|
||||
def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str, *, verify_cert: bool) -> str:
|
||||
"""Generate the authentication token from CloudVision using username and password.
|
||||
|
||||
TODO: need to handle requests error
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cvp_ip
|
||||
IP address of CloudVision.
|
||||
cvp_username
|
||||
Username to connect to CloudVision.
|
||||
cvp_password
|
||||
Password to connect to CloudVision.
|
||||
verify_cert
|
||||
Enable or disable certificate verification when connecting to CloudVision.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The token to use in further API calls to CloudVision.
|
||||
|
||||
Raises
|
||||
------
|
||||
requests.ssl.SSLError
|
||||
If the certificate verification fails.
|
||||
|
||||
"""
|
||||
# use CVP REST API to generate a token
|
||||
URL = f"https://{cvp_ip}/cvpservice/login/authenticate.do"
|
||||
url = f"https://{cvp_ip}/cvpservice/login/authenticate.do"
|
||||
payload = json.dumps({"userId": cvp_username, "password": cvp_password})
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
|
||||
response = requests.request("POST", URL, headers=headers, data=payload, verify=False, timeout=10)
|
||||
response = requests.request("POST", url, headers=headers, data=payload, verify=verify_cert, timeout=10)
|
||||
return response.json()["sessionId"]
|
||||
|
||||
|
||||
def write_inventory_to_file(hosts: list[AntaInventoryHost], output: Path) -> None:
|
||||
"""Write a file inventory from pydantic models"""
|
||||
"""Write a file inventory from pydantic models.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
hosts:
|
||||
the list of AntaInventoryHost to write to an inventory file
|
||||
output:
|
||||
the Path where the inventory should be written.
|
||||
|
||||
Raises
|
||||
------
|
||||
OSError
|
||||
When anything goes wrong while writing the file.
|
||||
"""
|
||||
i = AntaInventoryInput(hosts=hosts)
|
||||
with open(output, "w", encoding="UTF-8") as out_fd:
|
||||
out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: i.model_dump(exclude_unset=True)}))
|
||||
logger.info(f"ANTA inventory file has been created: '{output}'")
|
||||
try:
|
||||
with output.open(mode="w", encoding="UTF-8") as out_fd:
|
||||
out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: yaml.safe_load(i.yaml())}))
|
||||
logger.info("ANTA inventory file has been created: '%s'", output)
|
||||
except OSError as exc:
|
||||
msg = f"Could not write inventory to path '{output}'."
|
||||
raise OSError(msg) from exc
|
||||
|
||||
|
||||
def create_inventory_from_cvp(inv: list[dict[str, Any]], output: Path) -> None:
|
||||
"""
|
||||
Create an inventory file from Arista CloudVision inventory
|
||||
"""
|
||||
logger.debug(f"Received {len(inv)} device(s) from CloudVision")
|
||||
"""Create an inventory file from Arista CloudVision inventory."""
|
||||
logger.debug("Received %s device(s) from CloudVision", len(inv))
|
||||
hosts = []
|
||||
for dev in inv:
|
||||
logger.info(f" * adding entry for {dev['hostname']}")
|
||||
hosts.append(AntaInventoryHost(name=dev["hostname"], host=dev["ipAddress"], tags=[dev["containerName"].lower()]))
|
||||
logger.info(" * adding entry for %s", dev["hostname"])
|
||||
hosts.append(
|
||||
AntaInventoryHost(
|
||||
name=dev["hostname"],
|
||||
host=dev["ipAddress"],
|
||||
tags={dev["containerName"].lower()},
|
||||
)
|
||||
)
|
||||
write_inventory_to_file(hosts, output)
|
||||
|
||||
|
||||
def find_ansible_group(data: dict[str, Any], group: str) -> dict[str, Any] | None:
|
||||
"""Retrieve Ansible group from an input data dict."""
|
||||
for k, v in data.items():
|
||||
if isinstance(v, dict):
|
||||
if k == group and ("children" in v or "hosts" in v):
|
||||
return v
|
||||
d = find_ansible_group(v, group)
|
||||
if d is not None:
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def deep_yaml_parsing(data: dict[str, Any], hosts: list[AntaInventoryHost] | None = None) -> list[AntaInventoryHost]:
|
||||
"""Deep parsing of YAML file to extract hosts and associated IPs."""
|
||||
if hosts is None:
|
||||
hosts = []
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict) and "ansible_host" in value:
|
||||
logger.info(" * adding entry for %s", key)
|
||||
hosts.append(AntaInventoryHost(name=key, host=value["ansible_host"]))
|
||||
elif isinstance(value, dict):
|
||||
deep_yaml_parsing(value, hosts)
|
||||
else:
|
||||
return hosts
|
||||
return hosts
|
||||
|
||||
|
||||
def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group: str = "all") -> None:
|
||||
"""Create an ANTA inventory from an Ansible inventory YAML file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
inventory
|
||||
Ansible Inventory file to read.
|
||||
output
|
||||
ANTA inventory file to generate.
|
||||
ansible_group
|
||||
Ansible group from where to extract data.
|
||||
|
||||
"""
|
||||
Create an ANTA inventory from an Ansible inventory YAML file
|
||||
|
||||
Args:
|
||||
inventory: Ansible Inventory file to read
|
||||
output: ANTA inventory file to generate.
|
||||
ansible_group: Ansible group from where to extract data.
|
||||
"""
|
||||
|
||||
def find_ansible_group(data: dict[str, Any], group: str) -> dict[str, Any] | None:
|
||||
for k, v in data.items():
|
||||
if isinstance(v, dict):
|
||||
if k == group and ("children" in v.keys() or "hosts" in v.keys()):
|
||||
return v
|
||||
d = find_ansible_group(v, group)
|
||||
if d is not None:
|
||||
return d
|
||||
return None
|
||||
|
||||
def deep_yaml_parsing(data: dict[str, Any], hosts: list[AntaInventoryHost] | None = None) -> list[AntaInventoryHost]:
|
||||
"""Deep parsing of YAML file to extract hosts and associated IPs"""
|
||||
if hosts is None:
|
||||
hosts = []
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict) and "ansible_host" in value.keys():
|
||||
logger.info(f" * adding entry for {key}")
|
||||
hosts.append(AntaInventoryHost(name=key, host=value["ansible_host"]))
|
||||
elif isinstance(value, dict):
|
||||
deep_yaml_parsing(value, hosts)
|
||||
else:
|
||||
return hosts
|
||||
return hosts
|
||||
|
||||
try:
|
||||
with open(inventory, encoding="utf-8") as inv:
|
||||
with inventory.open(encoding="utf-8") as inv:
|
||||
ansible_inventory = yaml.safe_load(inv)
|
||||
except yaml.constructor.ConstructorError as exc:
|
||||
if exc.problem and "!vault" in exc.problem:
|
||||
logger.error(
|
||||
"`anta get from-ansible` does not support inline vaulted variables, comment them out to generate your inventory. "
|
||||
"If the vaulted variable is necessary to build the inventory (e.g. `ansible_host`), it needs to be unvaulted for "
|
||||
"`from-ansible` command to work."
|
||||
)
|
||||
msg = f"Could not parse {inventory}."
|
||||
raise ValueError(msg) from exc
|
||||
except OSError as exc:
|
||||
raise ValueError(f"Could not parse {inventory}.") from exc
|
||||
msg = f"Could not parse {inventory}."
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
if not ansible_inventory:
|
||||
raise ValueError(f"Ansible inventory {inventory} is empty")
|
||||
msg = f"Ansible inventory {inventory} is empty"
|
||||
raise ValueError(msg)
|
||||
|
||||
ansible_inventory = find_ansible_group(ansible_inventory, ansible_group)
|
||||
|
||||
if ansible_inventory is None:
|
||||
raise ValueError(f"Group {ansible_group} not found in Ansible inventory")
|
||||
msg = f"Group {ansible_group} not found in Ansible inventory"
|
||||
raise ValueError(msg)
|
||||
ansible_hosts = deep_yaml_parsing(ansible_inventory)
|
||||
write_inventory_to_file(ansible_hosts, output)
|
||||
|
||||
|
||||
def explore_package(module_name: str, test_name: str | None = None, *, short: bool = False, count: bool = False) -> 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
|
||||
|
|
|
@ -1,40 +1,38 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Click commands that run ANTA tests using anta.runner
|
||||
"""
|
||||
"""Click commands that run ANTA tests using anta.runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from anta.catalog import AntaCatalog
|
||||
from anta.cli.nrfu import commands
|
||||
from anta.cli.utils import AliasedGroup, catalog_options, inventory_options
|
||||
from anta.inventory import AntaInventory
|
||||
from anta.models import AntaTest
|
||||
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:
|
||||
from anta.catalog import AntaCatalog
|
||||
from anta.inventory import AntaInventory
|
||||
|
||||
|
||||
class IgnoreRequiredWithHelp(AliasedGroup):
|
||||
"""
|
||||
"""Custom Click Group.
|
||||
|
||||
https://stackoverflow.com/questions/55818737/python-click-application-required-parameters-have-precedence-over-sub-command-he
|
||||
|
||||
Solution to allow help without required options on subcommand
|
||||
This is not planned to be fixed in click as per: https://github.com/pallets/click/issues/295#issuecomment-708129734
|
||||
This is not planned to be fixed in click as per: https://github.com/pallets/click/issues/295#issuecomment-708129734.
|
||||
"""
|
||||
|
||||
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
||||
"""
|
||||
Ignore MissingParameter exception when parsing arguments if `--help`
|
||||
is present for a subcommand
|
||||
"""
|
||||
"""Ignore MissingParameter exception when parsing arguments if `--help` is present for a subcommand."""
|
||||
# Adding a flag for potential callbacks
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["args"] = args
|
||||
if "--help" in args:
|
||||
ctx.obj["_anta_help"] = True
|
||||
|
||||
|
@ -51,31 +49,101 @@ class IgnoreRequiredWithHelp(AliasedGroup):
|
|||
return super().parse_args(ctx, args)
|
||||
|
||||
|
||||
HIDE_STATUS: list[str] = list(AntaTestStatus)
|
||||
HIDE_STATUS.remove("unset")
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True, cls=IgnoreRequiredWithHelp)
|
||||
@click.pass_context
|
||||
@inventory_options
|
||||
@catalog_options
|
||||
@click.option("--ignore-status", help="Always exit with success", show_envvar=True, is_flag=True, default=False)
|
||||
@click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False)
|
||||
def nrfu(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, catalog: AntaCatalog, ignore_status: bool, ignore_error: bool) -> None:
|
||||
"""Run ANTA tests on devices"""
|
||||
@click.option(
|
||||
"--device",
|
||||
"-d",
|
||||
help="Run tests on a specific device. Can be provided multiple times.",
|
||||
type=str,
|
||||
multiple=True,
|
||||
required=False,
|
||||
)
|
||||
@click.option(
|
||||
"--test",
|
||||
"-t",
|
||||
help="Run a specific test. Can be provided multiple times.",
|
||||
type=str,
|
||||
multiple=True,
|
||||
required=False,
|
||||
)
|
||||
@click.option(
|
||||
"--ignore-status",
|
||||
help="Exit code will always be 0.",
|
||||
show_envvar=True,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
)
|
||||
@click.option(
|
||||
"--ignore-error",
|
||||
help="Exit code will be 0 if all tests succeeded or 1 if any test failed.",
|
||||
show_envvar=True,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
)
|
||||
@click.option(
|
||||
"--hide",
|
||||
default=None,
|
||||
type=click.Choice(HIDE_STATUS, case_sensitive=False),
|
||||
multiple=True,
|
||||
help="Hide results by type: success / failure / error / skipped'.",
|
||||
required=False,
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
help="Run anta nrfu command but stop before starting to execute the tests. Considers all devices as connected.",
|
||||
type=str,
|
||||
show_envvar=True,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
)
|
||||
def nrfu(
|
||||
ctx: click.Context,
|
||||
inventory: AntaInventory,
|
||||
tags: set[str] | None,
|
||||
catalog: AntaCatalog,
|
||||
device: tuple[str],
|
||||
test: tuple[str],
|
||||
hide: tuple[str],
|
||||
*,
|
||||
ignore_status: bool,
|
||||
ignore_error: bool,
|
||||
dry_run: bool,
|
||||
catalog_format: str = "yaml",
|
||||
) -> None:
|
||||
"""Run ANTA tests on selected inventory devices."""
|
||||
# If help is invoke somewhere, skip the command
|
||||
if ctx.obj.get("_anta_help"):
|
||||
return
|
||||
|
||||
# We use ctx.obj to pass stuff to the next Click functions
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["result_manager"] = ResultManager()
|
||||
ctx.obj["ignore_status"] = ignore_status
|
||||
ctx.obj["ignore_error"] = ignore_error
|
||||
print_settings(inventory, catalog)
|
||||
with anta_progress_bar() as AntaTest.progress:
|
||||
asyncio.run(main(ctx.obj["result_manager"], inventory, catalog, tags=tags))
|
||||
ctx.obj["hide"] = set(hide) if hide else None
|
||||
ctx.obj["catalog"] = catalog
|
||||
ctx.obj["catalog_format"] = catalog_format
|
||||
ctx.obj["inventory"] = inventory
|
||||
ctx.obj["tags"] = tags
|
||||
ctx.obj["device"] = device
|
||||
ctx.obj["test"] = test
|
||||
ctx.obj["dry_run"] = dry_run
|
||||
|
||||
# Invoke `anta nrfu table` if no command is passed
|
||||
if ctx.invoked_subcommand is None:
|
||||
if not ctx.invoked_subcommand:
|
||||
ctx.invoke(commands.table)
|
||||
|
||||
|
||||
nrfu.add_command(commands.table)
|
||||
nrfu.add_command(commands.csv)
|
||||
nrfu.add_command(commands.json)
|
||||
nrfu.add_command(commands.text)
|
||||
nrfu.add_command(commands.tpl_report)
|
||||
nrfu.add_command(commands.md_report)
|
||||
|
|
|
@ -1,33 +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.
|
||||
"""
|
||||
Click commands that render ANTA tests results
|
||||
"""
|
||||
"""Click commands that render ANTA tests results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
from typing import Literal
|
||||
|
||||
import click
|
||||
|
||||
from anta.cli.utils import exit_with_code
|
||||
|
||||
from .utils import print_jinja, print_json, print_table, print_text
|
||||
from .utils import print_jinja, print_json, print_table, print_text, run_tests, save_markdown_report, save_to_csv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.pass_context
|
||||
@click.option("--device", "-d", help="Show a summary for this device", type=str, required=False)
|
||||
@click.option("--test", "-t", help="Show a summary for this test", type=str, required=False)
|
||||
@click.option(
|
||||
"--group-by", default=None, type=click.Choice(["device", "test"], case_sensitive=False), help="Group result by test or host. default none", required=False
|
||||
"--group-by",
|
||||
default=None,
|
||||
type=click.Choice(["device", "test"], case_sensitive=False),
|
||||
help="Group result by test or device.",
|
||||
required=False,
|
||||
)
|
||||
def table(ctx: click.Context, device: str | None, test: str | None, group_by: str) -> None:
|
||||
"""ANTA command to check network states with table result"""
|
||||
print_table(results=ctx.obj["result_manager"], device=device, group_by=group_by, test=test)
|
||||
def table(ctx: click.Context, group_by: Literal["device", "test"] | None) -> None:
|
||||
"""ANTA command to check network state with table results."""
|
||||
run_tests(ctx)
|
||||
print_table(ctx, group_by=group_by)
|
||||
exit_with_code(ctx)
|
||||
|
||||
|
||||
|
@ -39,21 +42,43 @@ def table(ctx: click.Context, device: str | None, test: str | None, group_by: st
|
|||
type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path),
|
||||
show_envvar=True,
|
||||
required=False,
|
||||
help="Path to save report as a file",
|
||||
help="Path to save report as a JSON file",
|
||||
)
|
||||
def json(ctx: click.Context, output: pathlib.Path | None) -> None:
|
||||
"""ANTA command to check network state with JSON result"""
|
||||
print_json(results=ctx.obj["result_manager"], output=output)
|
||||
"""ANTA command to check network state with JSON results."""
|
||||
run_tests(ctx)
|
||||
print_json(ctx, output=output)
|
||||
exit_with_code(ctx)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.pass_context
|
||||
@click.option("--search", "-s", help="Regular expression to search in both name and test", type=str, required=False)
|
||||
@click.option("--skip-error", help="Hide tests in errors due to connectivity issue", default=False, is_flag=True, show_default=True, required=False)
|
||||
def text(ctx: click.Context, search: str | None, skip_error: bool) -> None:
|
||||
"""ANTA command to check network states with text result"""
|
||||
print_text(results=ctx.obj["result_manager"], search=search, skip_error=skip_error)
|
||||
def text(ctx: click.Context) -> None:
|
||||
"""ANTA command to check network state with text results."""
|
||||
run_tests(ctx)
|
||||
print_text(ctx)
|
||||
exit_with_code(ctx)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.pass_context
|
||||
@click.option(
|
||||
"--csv-output",
|
||||
type=click.Path(
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
exists=False,
|
||||
writable=True,
|
||||
path_type=pathlib.Path,
|
||||
),
|
||||
show_envvar=True,
|
||||
required=False,
|
||||
help="Path to save report as a CSV file",
|
||||
)
|
||||
def csv(ctx: click.Context, csv_output: pathlib.Path) -> None:
|
||||
"""ANTA command to check network states with CSV result."""
|
||||
run_tests(ctx)
|
||||
save_to_csv(ctx, csv_file=csv_output)
|
||||
exit_with_code(ctx)
|
||||
|
||||
|
||||
|
@ -76,6 +101,23 @@ def text(ctx: click.Context, search: str | None, skip_error: bool) -> None:
|
|||
help="Path to save report as a file",
|
||||
)
|
||||
def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path | None) -> None:
|
||||
"""ANTA command to check network state with templated report"""
|
||||
"""ANTA command to check network state with templated report."""
|
||||
run_tests(ctx)
|
||||
print_jinja(results=ctx.obj["result_manager"], template=template, output=output)
|
||||
exit_with_code(ctx)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.pass_context
|
||||
@click.option(
|
||||
"--md-output",
|
||||
type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path),
|
||||
show_envvar=True,
|
||||
required=True,
|
||||
help="Path to save the report as a Markdown file",
|
||||
)
|
||||
def md_report(ctx: click.Context, md_output: pathlib.Path) -> None:
|
||||
"""ANTA command to check network state with Markdown report."""
|
||||
run_tests(ctx)
|
||||
save_markdown_report(ctx, md_output=md_output)
|
||||
exit_with_code(ctx)
|
||||
|
|
|
@ -1,101 +1,172 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Utils functions to use with anta.cli.nrfu.commands module.
|
||||
"""
|
||||
"""Utils functions to use with anta.cli.nrfu.commands module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import pathlib
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import rich
|
||||
from rich.panel import Panel
|
||||
from rich.pretty import pprint
|
||||
from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn
|
||||
|
||||
from anta.catalog import AntaCatalog
|
||||
from anta.cli.console import console
|
||||
from anta.inventory import AntaInventory
|
||||
from anta.cli.utils import ExitCode
|
||||
from anta.models import AntaTest
|
||||
from anta.reporter import ReportJinja, ReportTable
|
||||
from anta.result_manager import ResultManager
|
||||
from anta.reporter.csv_reporter import ReportCsv
|
||||
from anta.reporter.md_reporter import MDReportGenerator
|
||||
from anta.runner import main
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pathlib
|
||||
|
||||
import click
|
||||
|
||||
from anta.catalog import AntaCatalog
|
||||
from anta.inventory import AntaInventory
|
||||
from anta.result_manager import ResultManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_tests(ctx: click.Context) -> None:
|
||||
"""Run the tests."""
|
||||
# Digging up the parameters from the parent context
|
||||
if ctx.parent is None:
|
||||
ctx.exit()
|
||||
nrfu_ctx_params = ctx.parent.params
|
||||
tags = nrfu_ctx_params["tags"]
|
||||
device = nrfu_ctx_params["device"] or None
|
||||
test = nrfu_ctx_params["test"] or None
|
||||
dry_run = nrfu_ctx_params["dry_run"]
|
||||
|
||||
catalog = ctx.obj["catalog"]
|
||||
inventory = ctx.obj["inventory"]
|
||||
|
||||
print_settings(inventory, catalog)
|
||||
with anta_progress_bar() as AntaTest.progress:
|
||||
asyncio.run(
|
||||
main(
|
||||
ctx.obj["result_manager"],
|
||||
inventory,
|
||||
catalog,
|
||||
tags=tags,
|
||||
devices=set(device) if device else None,
|
||||
tests=set(test) if test else None,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
)
|
||||
if dry_run:
|
||||
ctx.exit()
|
||||
|
||||
|
||||
def _get_result_manager(ctx: click.Context) -> ResultManager:
|
||||
"""Get a ResultManager instance based on Click context."""
|
||||
return ctx.obj["result_manager"].filter(ctx.obj.get("hide")) if ctx.obj.get("hide") is not None else ctx.obj["result_manager"]
|
||||
|
||||
|
||||
def print_settings(
|
||||
inventory: AntaInventory,
|
||||
catalog: AntaCatalog,
|
||||
) -> None:
|
||||
"""Print ANTA settings before running tests"""
|
||||
message = f"Running ANTA tests:\n- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests"
|
||||
"""Print ANTA settings before running tests."""
|
||||
message = f"- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests"
|
||||
console.print(Panel.fit(message, style="cyan", title="[green]Settings"))
|
||||
console.print()
|
||||
|
||||
|
||||
def print_table(results: ResultManager, device: str | None = None, test: str | None = None, group_by: str | None = None) -> None:
|
||||
"""Print result in a table"""
|
||||
def print_table(ctx: click.Context, group_by: Literal["device", "test"] | None = None) -> None:
|
||||
"""Print result in a table."""
|
||||
reporter = ReportTable()
|
||||
console.print()
|
||||
if device:
|
||||
console.print(reporter.report_all(result_manager=results, host=device))
|
||||
elif test:
|
||||
console.print(reporter.report_all(result_manager=results, testcase=test))
|
||||
elif group_by == "device":
|
||||
console.print(reporter.report_summary_hosts(result_manager=results, host=None))
|
||||
results = _get_result_manager(ctx)
|
||||
|
||||
if group_by == "device":
|
||||
console.print(reporter.report_summary_devices(results))
|
||||
elif group_by == "test":
|
||||
console.print(reporter.report_summary_tests(result_manager=results, testcase=None))
|
||||
console.print(reporter.report_summary_tests(results))
|
||||
else:
|
||||
console.print(reporter.report_all(result_manager=results))
|
||||
console.print(reporter.report_all(results))
|
||||
|
||||
|
||||
def print_json(results: ResultManager, output: pathlib.Path | None = None) -> None:
|
||||
"""Print result in a json format"""
|
||||
def print_json(ctx: click.Context, output: pathlib.Path | None = None) -> None:
|
||||
"""Print results as JSON. If output is provided, save to file instead."""
|
||||
results = _get_result_manager(ctx)
|
||||
|
||||
if output is None:
|
||||
console.print()
|
||||
console.print(Panel("JSON results", style="cyan"))
|
||||
rich.print_json(results.json)
|
||||
else:
|
||||
try:
|
||||
with output.open(mode="w", encoding="utf-8") as file:
|
||||
file.write(results.json)
|
||||
console.print(f"JSON results saved to {output} ✅", style="cyan")
|
||||
except OSError:
|
||||
console.print(f"Failed to save JSON results to {output} ❌", style="cyan")
|
||||
ctx.exit(ExitCode.USAGE_ERROR)
|
||||
|
||||
|
||||
def print_text(ctx: click.Context) -> None:
|
||||
"""Print results as simple text."""
|
||||
console.print()
|
||||
console.print(Panel("JSON results of all tests", style="cyan"))
|
||||
rich.print_json(results.get_json_results())
|
||||
if output is not None:
|
||||
with open(output, "w", encoding="utf-8") as fout:
|
||||
fout.write(results.get_json_results())
|
||||
|
||||
|
||||
def print_list(results: ResultManager, output: pathlib.Path | None = None) -> None:
|
||||
"""Print result in a list"""
|
||||
console.print()
|
||||
console.print(Panel.fit("List results of all tests", style="cyan"))
|
||||
pprint(results.get_results())
|
||||
if output is not None:
|
||||
with open(output, "w", encoding="utf-8") as fout:
|
||||
fout.write(str(results.get_results()))
|
||||
|
||||
|
||||
def print_text(results: ResultManager, search: str | None = None, skip_error: bool = False) -> None:
|
||||
"""Print results as simple text"""
|
||||
console.print()
|
||||
regexp = re.compile(search or ".*")
|
||||
for line in results.get_results():
|
||||
if any(regexp.match(entry) for entry in [line.name, line.test]) and (not skip_error or line.result != "error"):
|
||||
message = f" ({str(line.messages[0])})" if len(line.messages) > 0 else ""
|
||||
console.print(f"{line.name} :: {line.test} :: [{line.result}]{line.result.upper()}[/{line.result}]{message}", highlight=False)
|
||||
for test in _get_result_manager(ctx).results:
|
||||
if len(test.messages) <= 1:
|
||||
message = test.messages[0] if len(test.messages) == 1 else ""
|
||||
console.print(f"{test.name} :: {test.test} :: [{test.result}]{test.result.upper()}[/{test.result}]({message})", highlight=False)
|
||||
else: # len(test.messages) > 1
|
||||
console.print(f"{test.name} :: {test.test} :: [{test.result}]{test.result.upper()}[/{test.result}]", highlight=False)
|
||||
console.print("\n".join(f" {message}" for message in test.messages), highlight=False)
|
||||
|
||||
|
||||
def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib.Path | None = None) -> None:
|
||||
"""Print result based on template."""
|
||||
console.print()
|
||||
reporter = ReportJinja(template_path=template)
|
||||
json_data = json.loads(results.get_json_results())
|
||||
json_data = json.loads(results.json)
|
||||
report = reporter.render(json_data)
|
||||
console.print(report)
|
||||
if output is not None:
|
||||
with open(output, "w", encoding="utf-8") as file:
|
||||
with output.open(mode="w", encoding="utf-8") as file:
|
||||
file.write(report)
|
||||
|
||||
|
||||
def save_to_csv(ctx: click.Context, csv_file: pathlib.Path) -> None:
|
||||
"""Save results to a CSV file."""
|
||||
try:
|
||||
ReportCsv.generate(results=_get_result_manager(ctx), csv_filename=csv_file)
|
||||
console.print(f"CSV report saved to {csv_file} ✅", style="cyan")
|
||||
except OSError:
|
||||
console.print(f"Failed to save CSV report to {csv_file} ❌", style="cyan")
|
||||
ctx.exit(ExitCode.USAGE_ERROR)
|
||||
|
||||
|
||||
def save_markdown_report(ctx: click.Context, md_output: pathlib.Path) -> None:
|
||||
"""Save the markdown report to a file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ctx
|
||||
Click context containing the result manager.
|
||||
md_output
|
||||
Path to save the markdown report.
|
||||
"""
|
||||
try:
|
||||
MDReportGenerator.generate(results=_get_result_manager(ctx), md_filename=md_output)
|
||||
console.print(f"Markdown report saved to {md_output} ✅", style="cyan")
|
||||
except OSError:
|
||||
console.print(f"Failed to save Markdown report to {md_output} ❌", style="cyan")
|
||||
ctx.exit(ExitCode.USAGE_ERROR)
|
||||
|
||||
|
||||
# Adding our own ANTA spinner - overriding rich SPINNERS for our own
|
||||
# so ignore warning for redefinition
|
||||
rich.spinner.SPINNERS = { # type: ignore[attr-defined] # noqa: F811
|
||||
rich.spinner.SPINNERS = { # type: ignore[attr-defined]
|
||||
"anta": {
|
||||
"interval": 150,
|
||||
"frames": [
|
||||
|
@ -112,14 +183,12 @@ rich.spinner.SPINNERS = { # type: ignore[attr-defined] # noqa: F811
|
|||
"( 🐌 )",
|
||||
"( 🐌)",
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def anta_progress_bar() -> Progress:
|
||||
"""
|
||||
Return a customized Progress for progress bar
|
||||
"""
|
||||
"""Return a customized Progress for progress bar."""
|
||||
return Progress(
|
||||
SpinnerColumn("anta"),
|
||||
TextColumn("•"),
|
||||
|
|
|
@ -1,24 +1,22 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Utils functions to use with anta.cli module.
|
||||
"""
|
||||
"""Utils functions to use with anta.cli module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import functools
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
import click
|
||||
from pydantic import ValidationError
|
||||
from yaml import YAMLError
|
||||
|
||||
from anta.catalog import AntaCatalog
|
||||
from anta.inventory import AntaInventory
|
||||
from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError
|
||||
from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from click import Option
|
||||
|
@ -27,10 +25,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
class ExitCode(enum.IntEnum):
|
||||
"""
|
||||
Encodes the valid exit codes by anta
|
||||
inspired from pytest
|
||||
"""
|
||||
"""Encodes the valid exit codes by anta inspired from pytest."""
|
||||
|
||||
# Tests passed.
|
||||
OK = 0
|
||||
|
@ -44,19 +39,17 @@ class ExitCode(enum.IntEnum):
|
|||
TESTS_FAILED = 4
|
||||
|
||||
|
||||
def parse_tags(ctx: click.Context, param: Option, value: str) -> list[str] | None:
|
||||
# pylint: disable=unused-argument
|
||||
"""
|
||||
Click option callback to parse an ANTA inventory tags
|
||||
"""
|
||||
def parse_tags(ctx: click.Context, param: Option, value: str | None) -> set[str] | None:
|
||||
# ruff: noqa: ARG001
|
||||
"""Click option callback to parse an ANTA inventory tags."""
|
||||
if value is not None:
|
||||
return value.split(",") if "," in value else [value]
|
||||
return set(value.split(",")) if "," in value else {value}
|
||||
return None
|
||||
|
||||
|
||||
def exit_with_code(ctx: click.Context) -> None:
|
||||
"""
|
||||
Exit the Click application with an exit code.
|
||||
"""Exit the Click application with an exit code.
|
||||
|
||||
This function determines the global test status to be either `unset`, `skipped`, `success` or `error`
|
||||
from the `ResultManger` instance.
|
||||
If flag `ignore_error` is set, the `error` status will be ignored in all the tests.
|
||||
|
@ -64,10 +57,13 @@ def exit_with_code(ctx: click.Context) -> None:
|
|||
Exit the application with the following exit code:
|
||||
* 0 if `ignore_status` is `True` or global test status is `unset`, `skipped` or `success`
|
||||
* 1 if status is `failure`
|
||||
* 2 if status is `error`
|
||||
* 2 if status is `error`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ctx
|
||||
Click Context.
|
||||
|
||||
Args:
|
||||
ctx: Click Context
|
||||
"""
|
||||
if ctx.obj.get("ignore_status"):
|
||||
ctx.exit(ExitCode.OK)
|
||||
|
@ -83,18 +79,19 @@ def exit_with_code(ctx: click.Context) -> None:
|
|||
ctx.exit(ExitCode.TESTS_ERROR)
|
||||
|
||||
logger.error("Please gather logs and open an issue on Github.")
|
||||
raise ValueError(f"Unknown status returned by the ResultManager: {status}. Please gather logs and open an issue on Github.")
|
||||
msg = f"Unknown status returned by the ResultManager: {status}. Please gather logs and open an issue on Github."
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
class AliasedGroup(click.Group):
|
||||
"""
|
||||
Implements a subclass of Group that accepts a prefix for a command.
|
||||
"""Implements a subclass of Group that accepts a prefix for a command.
|
||||
|
||||
If there were a command called push, it would accept pus as an alias (so long as it was unique)
|
||||
From Click documentation
|
||||
From Click documentation.
|
||||
"""
|
||||
|
||||
def get_command(self, ctx: click.Context, cmd_name: str) -> Any:
|
||||
"""Todo: document code"""
|
||||
"""Todo: document code."""
|
||||
rv = click.Group.get_command(self, ctx, cmd_name)
|
||||
if rv is not None:
|
||||
return rv
|
||||
|
@ -107,15 +104,16 @@ class AliasedGroup(click.Group):
|
|||
return None
|
||||
|
||||
def resolve_command(self, ctx: click.Context, args: Any) -> Any:
|
||||
"""Todo: document code"""
|
||||
"""Todo: document code."""
|
||||
# always return the full command name
|
||||
_, cmd, args = super().resolve_command(ctx, args)
|
||||
return cmd.name, cmd, args # type: ignore
|
||||
if not cmd:
|
||||
return None, None, None
|
||||
return cmd.name, cmd, args
|
||||
|
||||
|
||||
# TODO: check code of click.pass_context that raise mypy errors for types and adapt this decorator
|
||||
def inventory_options(f: Any) -> Any:
|
||||
"""Click common options when requiring an inventory to interact with devices"""
|
||||
def core_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Click common options when requiring an inventory to interact with devices."""
|
||||
|
||||
@click.option(
|
||||
"--username",
|
||||
|
@ -159,76 +157,80 @@ def inventory_options(f: Any) -> Any:
|
|||
)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
help="Global connection timeout",
|
||||
default=30,
|
||||
help="Global API timeout. This value will be used for all devices.",
|
||||
default=30.0,
|
||||
show_envvar=True,
|
||||
envvar="ANTA_TIMEOUT",
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--insecure",
|
||||
help="Disable SSH Host Key validation",
|
||||
help="Disable SSH Host Key validation.",
|
||||
default=False,
|
||||
show_envvar=True,
|
||||
envvar="ANTA_INSECURE",
|
||||
is_flag=True,
|
||||
show_default=True,
|
||||
)
|
||||
@click.option("--disable-cache", help="Disable cache globally", show_envvar=True, envvar="ANTA_DISABLE_CACHE", show_default=True, is_flag=True, default=False)
|
||||
@click.option(
|
||||
"--disable-cache",
|
||||
help="Disable cache globally.",
|
||||
show_envvar=True,
|
||||
envvar="ANTA_DISABLE_CACHE",
|
||||
show_default=True,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
)
|
||||
@click.option(
|
||||
"--inventory",
|
||||
"-i",
|
||||
help="Path to the inventory YAML file",
|
||||
help="Path to the inventory YAML file.",
|
||||
envvar="ANTA_INVENTORY",
|
||||
show_envvar=True,
|
||||
required=True,
|
||||
type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path),
|
||||
)
|
||||
@click.option(
|
||||
"--tags",
|
||||
"-t",
|
||||
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],
|
||||
inventory: Path,
|
||||
tags: list[str] | None,
|
||||
username: str,
|
||||
password: str | None,
|
||||
enable_password: str | None,
|
||||
enable: bool,
|
||||
prompt: bool,
|
||||
timeout: int,
|
||||
timeout: float,
|
||||
insecure: bool,
|
||||
disable_cache: bool,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> Any:
|
||||
# pylint: disable=too-many-arguments
|
||||
# If help is invoke somewhere, do not parse inventory
|
||||
if ctx.obj.get("_anta_help"):
|
||||
return f(*args, inventory=None, tags=tags, **kwargs)
|
||||
return f(*args, inventory=None, **kwargs)
|
||||
if prompt:
|
||||
# User asked for a password prompt
|
||||
if password is None:
|
||||
password = click.prompt("Please enter a password to connect to EOS", type=str, hide_input=True, confirmation_prompt=True)
|
||||
if enable:
|
||||
if enable_password is None:
|
||||
if click.confirm("Is a password required to enter EOS privileged EXEC mode?"):
|
||||
enable_password = click.prompt(
|
||||
"Please enter a password to enter EOS privileged EXEC mode", type=str, hide_input=True, confirmation_prompt=True
|
||||
)
|
||||
password = click.prompt(
|
||||
"Please enter a password to connect to EOS",
|
||||
type=str,
|
||||
hide_input=True,
|
||||
confirmation_prompt=True,
|
||||
)
|
||||
if enable and enable_password is None and click.confirm("Is a password required to enter EOS privileged EXEC mode?"):
|
||||
enable_password = click.prompt(
|
||||
"Please enter a password to enter EOS privileged EXEC mode",
|
||||
type=str,
|
||||
hide_input=True,
|
||||
confirmation_prompt=True,
|
||||
)
|
||||
if password is None:
|
||||
raise click.BadParameter("EOS password needs to be provided by using either the '--password' option or the '--prompt' option.")
|
||||
msg = "EOS password needs to be provided by using either the '--password' option or the '--prompt' option."
|
||||
raise click.BadParameter(msg)
|
||||
if not enable and enable_password:
|
||||
raise click.BadParameter("Providing a password to access EOS Privileged EXEC mode requires '--enable' option.")
|
||||
msg = "Providing a password to access EOS Privileged EXEC mode requires '--enable' option."
|
||||
raise click.BadParameter(msg)
|
||||
try:
|
||||
i = AntaInventory.parse(
|
||||
filename=inventory,
|
||||
|
@ -240,34 +242,84 @@ def inventory_options(f: Any) -> Any:
|
|||
insecure=insecure,
|
||||
disable_cache=disable_cache,
|
||||
)
|
||||
except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchema, InventoryRootKeyError):
|
||||
except (TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError):
|
||||
ctx.exit(ExitCode.USAGE_ERROR)
|
||||
return f(*args, inventory=i, tags=tags, **kwargs)
|
||||
return f(*args, inventory=i, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def catalog_options(f: Any) -> Any:
|
||||
"""Click common options when requiring a test catalog to execute ANTA tests"""
|
||||
def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Click common options when requiring an inventory to interact with devices."""
|
||||
|
||||
@core_options
|
||||
@click.option(
|
||||
"--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
|
||||
|
||||
|
||||
def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Click common options when requiring a test catalog to execute ANTA tests."""
|
||||
|
||||
@click.option(
|
||||
"--catalog",
|
||||
"-c",
|
||||
envvar="ANTA_CATALOG",
|
||||
show_envvar=True,
|
||||
help="Path to the test catalog YAML file",
|
||||
type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path),
|
||||
help="Path to the test catalog file",
|
||||
type=click.Path(
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
exists=True,
|
||||
readable=True,
|
||||
path_type=Path,
|
||||
),
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--catalog-format",
|
||||
envvar="ANTA_CATALOG_FORMAT",
|
||||
show_envvar=True,
|
||||
help="Format of the catalog file, either 'yaml' or 'json'",
|
||||
default="yaml",
|
||||
type=click.Choice(["yaml", "json"], case_sensitive=False),
|
||||
)
|
||||
@click.pass_context
|
||||
@functools.wraps(f)
|
||||
def wrapper(ctx: click.Context, *args: tuple[Any], catalog: Path, **kwargs: dict[str, Any]) -> Any:
|
||||
def wrapper(
|
||||
ctx: click.Context,
|
||||
*args: tuple[Any],
|
||||
catalog: Path,
|
||||
catalog_format: str,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> Any:
|
||||
# If help is invoke somewhere, do not parse catalog
|
||||
if ctx.obj.get("_anta_help"):
|
||||
return f(*args, catalog=None, **kwargs)
|
||||
try:
|
||||
c = AntaCatalog.parse(catalog)
|
||||
except (ValidationError, TypeError, ValueError, YAMLError, OSError):
|
||||
file_format = catalog_format.lower()
|
||||
c = AntaCatalog.parse(catalog, file_format=file_format) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError, YAMLError, OSError):
|
||||
ctx.exit(ExitCode.USAGE_ERROR)
|
||||
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."""
|
|
@ -1,19 +1,44 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Module that provides predefined types for AntaTest.Input instances
|
||||
"""
|
||||
"""Module that provides predefined types for AntaTest.Input instances."""
|
||||
|
||||
import re
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.functional_validators import AfterValidator, BeforeValidator
|
||||
from typing_extensions import Annotated
|
||||
|
||||
# Regular Expression definition
|
||||
# TODO: make this configurable - with an env var maybe?
|
||||
REGEXP_EOS_BLACKLIST_CMDS = [r"^reload.*", r"^conf\w*\s*(terminal|session)*", r"^wr\w*\s*\w+"]
|
||||
"""List of regular expressions to blacklist from eos commands."""
|
||||
REGEXP_PATH_MARKERS = r"[\\\/\s]"
|
||||
"""Match directory path from string."""
|
||||
REGEXP_INTERFACE_ID = r"\d+(\/\d+)*(\.\d+)?"
|
||||
"""Match Interface ID lilke 1/1.1."""
|
||||
REGEXP_TYPE_EOS_INTERFACE = r"^(Dps|Ethernet|Fabric|Loopback|Management|Port-Channel|Tunnel|Vlan|Vxlan)[0-9]+(\/[0-9]+)*(\.[0-9]+)?$"
|
||||
"""Match EOS interface types like Ethernet1/1, Vlan1, Loopback1, etc."""
|
||||
REGEXP_TYPE_VXLAN_SRC_INTERFACE = r"^(Loopback)([0-9]|[1-9][0-9]{1,2}|[1-7][0-9]{3}|8[01][0-9]{2}|819[01])$"
|
||||
"""Match Vxlan source interface like Loopback10."""
|
||||
REGEX_TYPE_PORTCHANNEL = r"^Port-Channel[0-9]{1,6}$"
|
||||
"""Match Port Channel interface like Port-Channel5."""
|
||||
REGEXP_TYPE_HOSTNAME = r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$"
|
||||
"""Match hostname like `my-hostname`, `my-hostname-1`, `my-hostname-1-2`."""
|
||||
|
||||
# Regexp BGP AFI/SAFI
|
||||
REGEXP_BGP_L2VPN_AFI = r"\b(l2[\s\-]?vpn[\s\-]?evpn)\b"
|
||||
"""Match L2VPN EVPN AFI."""
|
||||
REGEXP_BGP_IPV4_MPLS_LABELS = r"\b(ipv4[\s\-]?mpls[\s\-]?label(s)?)\b"
|
||||
"""Match IPv4 MPLS Labels."""
|
||||
REGEX_BGP_IPV4_MPLS_VPN = r"\b(ipv4[\s\-]?mpls[\s\-]?vpn)\b"
|
||||
"""Match IPv4 MPLS VPN."""
|
||||
REGEX_BGP_IPV4_UNICAST = r"\b(ipv4[\s\-]?uni[\s\-]?cast)\b"
|
||||
"""Match IPv4 Unicast."""
|
||||
|
||||
|
||||
def aaa_group_prefix(v: str) -> str:
|
||||
"""Prefix the AAA method with 'group' if it is known"""
|
||||
"""Prefix the AAA method with 'group' if it is known."""
|
||||
built_in_methods = ["local", "none", "logging"]
|
||||
return f"group {v}" if v not in built_in_methods and not v.startswith("group ") else v
|
||||
|
||||
|
@ -24,49 +49,51 @@ def interface_autocomplete(v: str) -> str:
|
|||
Supported alias:
|
||||
- `et`, `eth` will be changed to `Ethernet`
|
||||
- `po` will be changed to `Port-Channel`
|
||||
- `lo` will be changed to `Loopback`"""
|
||||
intf_id_re = re.compile(r"[0-9]+(\/[0-9]+)*(\.[0-9]+)?")
|
||||
- `lo` will be changed to `Loopback`
|
||||
"""
|
||||
intf_id_re = re.compile(REGEXP_INTERFACE_ID)
|
||||
m = intf_id_re.search(v)
|
||||
if m is None:
|
||||
raise ValueError(f"Could not parse interface ID in interface '{v}'")
|
||||
msg = f"Could not parse interface ID in interface '{v}'"
|
||||
raise ValueError(msg)
|
||||
intf_id = m[0]
|
||||
|
||||
alias_map = {"et": "Ethernet", "eth": "Ethernet", "po": "Port-Channel", "lo": "Loopback"}
|
||||
|
||||
for alias, full_name in alias_map.items():
|
||||
if v.lower().startswith(alias):
|
||||
return f"{full_name}{intf_id}"
|
||||
|
||||
return v
|
||||
return next((f"{full_name}{intf_id}" for alias, full_name in alias_map.items() if v.lower().startswith(alias)), v)
|
||||
|
||||
|
||||
def interface_case_sensitivity(v: str) -> str:
|
||||
"""Reformat interface name to match expected case sensitivity.
|
||||
|
||||
Examples:
|
||||
- ethernet -> Ethernet
|
||||
- vlan -> Vlan
|
||||
- loopback -> Loopback
|
||||
Examples
|
||||
--------
|
||||
- ethernet -> Ethernet
|
||||
- vlan -> Vlan
|
||||
- loopback -> Loopback
|
||||
|
||||
"""
|
||||
if isinstance(v, str) and len(v) > 0 and not v[0].isupper():
|
||||
if isinstance(v, str) and v != "" and not v[0].isupper():
|
||||
return f"{v[0].upper()}{v[1:]}"
|
||||
return v
|
||||
|
||||
|
||||
def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str:
|
||||
"""
|
||||
Abbreviations for different BGP multiprotocol capabilities.
|
||||
Examples:
|
||||
- IPv4 Unicast
|
||||
- L2vpnEVPN
|
||||
- ipv4 MPLS Labels
|
||||
- ipv4Mplsvpn
|
||||
"""Abbreviations for different BGP multiprotocol capabilities.
|
||||
|
||||
Examples
|
||||
--------
|
||||
- IPv4 Unicast
|
||||
- L2vpnEVPN
|
||||
- ipv4 MPLS Labels
|
||||
- ipv4Mplsvpn
|
||||
|
||||
"""
|
||||
patterns = {
|
||||
r"\b(l2[\s\-]?vpn[\s\-]?evpn)\b": "l2VpnEvpn",
|
||||
r"\bipv4[\s_-]?mpls[\s_-]?label(s)?\b": "ipv4MplsLabels",
|
||||
r"\bipv4[\s_-]?mpls[\s_-]?vpn\b": "ipv4MplsVpn",
|
||||
r"\bipv4[\s_-]?uni[\s_-]?cast\b": "ipv4Unicast",
|
||||
REGEXP_BGP_L2VPN_AFI: "l2VpnEvpn",
|
||||
REGEXP_BGP_IPV4_MPLS_LABELS: "ipv4MplsLabels",
|
||||
REGEX_BGP_IPV4_MPLS_VPN: "ipv4MplsVpn",
|
||||
REGEX_BGP_IPV4_UNICAST: "ipv4Unicast",
|
||||
}
|
||||
|
||||
for pattern, replacement in patterns.items():
|
||||
|
@ -77,8 +104,15 @@ def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str:
|
|||
return value
|
||||
|
||||
|
||||
# ANTA framework
|
||||
TestStatus = Literal["unset", "success", "failure", "error", "skipped"]
|
||||
def validate_regex(value: str) -> str:
|
||||
"""Validate that the input value is a valid regex format."""
|
||||
try:
|
||||
re.compile(value)
|
||||
except re.error as e:
|
||||
msg = f"Invalid regex: {e}"
|
||||
raise ValueError(msg) from e
|
||||
return value
|
||||
|
||||
|
||||
# AntaTest.Input types
|
||||
AAAAuthMethod = Annotated[str, AfterValidator(aaa_group_prefix)]
|
||||
|
@ -87,21 +121,33 @@ MlagPriority = Annotated[int, Field(ge=1, le=32767)]
|
|||
Vni = Annotated[int, Field(ge=1, le=16777215)]
|
||||
Interface = Annotated[
|
||||
str,
|
||||
Field(pattern=r"^(Dps|Ethernet|Fabric|Loopback|Management|Port-Channel|Tunnel|Vlan|Vxlan)[0-9]+(\/[0-9]+)*(\.[0-9]+)?$"),
|
||||
Field(pattern=REGEXP_TYPE_EOS_INTERFACE),
|
||||
BeforeValidator(interface_autocomplete),
|
||||
BeforeValidator(interface_case_sensitivity),
|
||||
]
|
||||
EthernetInterface = Annotated[
|
||||
str,
|
||||
Field(pattern=r"^Ethernet[0-9]+(\/[0-9]+)*$"),
|
||||
BeforeValidator(interface_autocomplete),
|
||||
BeforeValidator(interface_case_sensitivity),
|
||||
]
|
||||
VxlanSrcIntf = Annotated[
|
||||
str,
|
||||
Field(pattern=r"^(Loopback)([0-9]|[1-9][0-9]{1,2}|[1-7][0-9]{3}|8[01][0-9]{2}|819[01])$"),
|
||||
Field(pattern=REGEXP_TYPE_VXLAN_SRC_INTERFACE),
|
||||
BeforeValidator(interface_autocomplete),
|
||||
BeforeValidator(interface_case_sensitivity),
|
||||
]
|
||||
Afi = Literal["ipv4", "ipv6", "vpn-ipv4", "vpn-ipv6", "evpn", "rt-membership"]
|
||||
Safi = Literal["unicast", "multicast", "labeled-unicast"]
|
||||
PortChannelInterface = Annotated[
|
||||
str,
|
||||
Field(pattern=REGEX_TYPE_PORTCHANNEL),
|
||||
BeforeValidator(interface_autocomplete),
|
||||
BeforeValidator(interface_case_sensitivity),
|
||||
]
|
||||
Afi = Literal["ipv4", "ipv6", "vpn-ipv4", "vpn-ipv6", "evpn", "rt-membership", "path-selection", "link-state"]
|
||||
Safi = Literal["unicast", "multicast", "labeled-unicast", "sr-te"]
|
||||
EncryptionAlgorithm = Literal["RSA", "ECDSA"]
|
||||
RsaKeySize = Literal[2048, 3072, 4096]
|
||||
EcdsaKeySize = Literal[256, 384, 521]
|
||||
EcdsaKeySize = Literal[256, 384, 512]
|
||||
MultiProtocolCaps = Annotated[str, BeforeValidator(bgp_multiprotocol_capabilities_abbreviations)]
|
||||
BfdInterval = Annotated[int, Field(ge=50, le=60000)]
|
||||
BfdMultiplier = Annotated[int, Field(ge=3, le=50)]
|
||||
|
@ -120,3 +166,75 @@ ErrDisableReasons = Literal[
|
|||
"uplink-failure-detection",
|
||||
]
|
||||
ErrDisableInterval = Annotated[int, Field(ge=30, le=86400)]
|
||||
Percent = Annotated[float, Field(ge=0.0, le=100.0)]
|
||||
PositiveInteger = Annotated[int, Field(ge=0)]
|
||||
Revision = Annotated[int, Field(ge=1, le=99)]
|
||||
Hostname = Annotated[str, Field(pattern=REGEXP_TYPE_HOSTNAME)]
|
||||
Port = Annotated[int, Field(ge=1, le=65535)]
|
||||
RegexString = Annotated[str, AfterValidator(validate_regex)]
|
||||
BgpDropStats = Literal[
|
||||
"inDropAsloop",
|
||||
"inDropClusterIdLoop",
|
||||
"inDropMalformedMpbgp",
|
||||
"inDropOrigId",
|
||||
"inDropNhLocal",
|
||||
"inDropNhAfV6",
|
||||
"prefixDroppedMartianV4",
|
||||
"prefixDroppedMaxRouteLimitViolatedV4",
|
||||
"prefixDroppedMartianV6",
|
||||
"prefixDroppedMaxRouteLimitViolatedV6",
|
||||
"prefixLuDroppedV4",
|
||||
"prefixLuDroppedMartianV4",
|
||||
"prefixLuDroppedMaxRouteLimitViolatedV4",
|
||||
"prefixLuDroppedV6",
|
||||
"prefixLuDroppedMartianV6",
|
||||
"prefixLuDroppedMaxRouteLimitViolatedV6",
|
||||
"prefixEvpnDroppedUnsupportedRouteType",
|
||||
"prefixBgpLsDroppedReceptionUnsupported",
|
||||
"outDropV4LocalAddr",
|
||||
"outDropV6LocalAddr",
|
||||
"prefixVpnIpv4DroppedImportMatchFailure",
|
||||
"prefixVpnIpv4DroppedMaxRouteLimitViolated",
|
||||
"prefixVpnIpv6DroppedImportMatchFailure",
|
||||
"prefixVpnIpv6DroppedMaxRouteLimitViolated",
|
||||
"prefixEvpnDroppedImportMatchFailure",
|
||||
"prefixEvpnDroppedMaxRouteLimitViolated",
|
||||
"prefixRtMembershipDroppedLocalAsReject",
|
||||
"prefixRtMembershipDroppedMaxRouteLimitViolated",
|
||||
]
|
||||
BgpUpdateError = Literal["inUpdErrWithdraw", "inUpdErrIgnore", "inUpdErrDisableAfiSafi", "disabledAfiSafi", "lastUpdErrTime"]
|
||||
BfdProtocol = Literal["bgp", "isis", "lag", "ospf", "ospfv3", "pim", "route-input", "static-bfd", "static-route", "vrrp", "vxlan"]
|
||||
SnmpPdu = Literal["inGetPdus", "inGetNextPdus", "inSetPdus", "outGetResponsePdus", "outTrapPdus"]
|
||||
SnmpErrorCounter = Literal[
|
||||
"inVersionErrs", "inBadCommunityNames", "inBadCommunityUses", "inParseErrs", "outTooBigErrs", "outNoSuchNameErrs", "outBadValueErrs", "outGeneralErrs"
|
||||
]
|
||||
|
||||
IPv4RouteType = Literal[
|
||||
"connected",
|
||||
"static",
|
||||
"kernel",
|
||||
"OSPF",
|
||||
"OSPF inter area",
|
||||
"OSPF external type 1",
|
||||
"OSPF external type 2",
|
||||
"OSPF NSSA external type 1",
|
||||
"OSPF NSSA external type2",
|
||||
"Other BGP Routes",
|
||||
"iBGP",
|
||||
"eBGP",
|
||||
"RIP",
|
||||
"IS-IS level 1",
|
||||
"IS-IS level 2",
|
||||
"OSPFv3",
|
||||
"BGP Aggregate",
|
||||
"OSPF Summary",
|
||||
"Nexthop Group Static Route",
|
||||
"VXLAN Control Service",
|
||||
"Martian",
|
||||
"DHCP client installed default route",
|
||||
"Dynamic Policy Route",
|
||||
"VRF Leaked",
|
||||
"gRIBI",
|
||||
"Route Cache Route",
|
||||
"CBF Leaked Route",
|
||||
]
|
||||
|
|
|
@ -2,40 +2,50 @@
|
|||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""decorators for tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, cast
|
||||
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
|
||||
|
||||
from anta.models import AntaTest, logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.result_manager.models import TestResult
|
||||
|
||||
# TODO - should probably use mypy Awaitable in some places rather than this everywhere - @gmuloc
|
||||
# TODO: should probably use mypy Awaitable in some places rather than this everywhere - @gmuloc
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def deprecated_test(new_tests: Optional[list[str]] = None) -> Callable[[F], F]:
|
||||
"""
|
||||
Return a decorator to log a message of WARNING severity when a test is deprecated.
|
||||
# TODO: Remove this decorator in ANTA v2.0.0 in favor of deprecated_test_class
|
||||
def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]: # pragma: no cover
|
||||
"""Return a decorator to log a message of WARNING severity when a test is deprecated.
|
||||
|
||||
Args:
|
||||
new_tests (Optional[list[str]]): A list of new test classes that should replace the deprecated test.
|
||||
Parameters
|
||||
----------
|
||||
new_tests
|
||||
A list of new test classes that should replace the deprecated test.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Callable[[F], F]
|
||||
A decorator that can be used to wrap test functions.
|
||||
|
||||
Returns:
|
||||
Callable[[F], F]: A decorator that can be used to wrap test functions.
|
||||
"""
|
||||
|
||||
def decorator(function: F) -> F:
|
||||
"""
|
||||
Actual decorator that logs the message.
|
||||
"""Actual decorator that logs the message.
|
||||
|
||||
Args:
|
||||
function (F): The test function to be decorated.
|
||||
Parameters
|
||||
----------
|
||||
function
|
||||
The test function to be decorated.
|
||||
|
||||
Returns
|
||||
-------
|
||||
F
|
||||
The decorated function.
|
||||
|
||||
Returns:
|
||||
F: The decorated function.
|
||||
"""
|
||||
|
||||
@wraps(function)
|
||||
|
@ -43,9 +53,9 @@ def deprecated_test(new_tests: Optional[list[str]] = None) -> Callable[[F], F]:
|
|||
anta_test = args[0]
|
||||
if new_tests:
|
||||
new_test_names = ", ".join(new_tests)
|
||||
logger.warning(f"{anta_test.name} test is deprecated. Consider using the following new tests: {new_test_names}.")
|
||||
logger.warning("%s test is deprecated. Consider using the following new tests: %s.", anta_test.name, new_test_names)
|
||||
else:
|
||||
logger.warning(f"{anta_test.name} test is deprecated.")
|
||||
logger.warning("%s test is deprecated.", anta_test.name)
|
||||
return await function(*args, **kwargs)
|
||||
|
||||
return cast(F, wrapper)
|
||||
|
@ -53,35 +63,93 @@ def deprecated_test(new_tests: Optional[list[str]] = None) -> Callable[[F], F]:
|
|||
return decorator
|
||||
|
||||
|
||||
def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]:
|
||||
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.
|
||||
|
||||
"""
|
||||
Return a decorator to skip a test based on the device's hardware model.
|
||||
|
||||
def decorator(cls: type[AntaTest]) -> type[AntaTest]:
|
||||
"""Actual decorator that logs the message.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cls
|
||||
The cls to be decorated.
|
||||
|
||||
Returns
|
||||
-------
|
||||
cls
|
||||
The decorated cls.
|
||||
"""
|
||||
orig_init = cls.__init__
|
||||
|
||||
def new_init(*args: Any, **kwargs: Any) -> None:
|
||||
"""Overload __init__ to generate a warning message for deprecation."""
|
||||
if new_tests:
|
||||
new_test_names = ", ".join(new_tests)
|
||||
logger.warning("%s test is deprecated. Consider using the following new tests: %s.", cls.name, new_test_names)
|
||||
else:
|
||||
logger.warning("%s test is deprecated.", cls.name)
|
||||
orig_init(*args, **kwargs)
|
||||
|
||||
if removal_in_version is not None:
|
||||
cls.__removal_in_version = removal_in_version
|
||||
|
||||
# NOTE: we are ignoring mypy warning as we want to assign to a method here
|
||||
cls.__init__ = new_init # type: ignore[method-assign]
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]:
|
||||
"""Return a decorator to skip a test based on the device's hardware model.
|
||||
|
||||
This decorator factory generates a decorator that will check the hardware model of the device
|
||||
the test is run on. If the model is in the list of platforms specified, the test will be skipped.
|
||||
|
||||
Args:
|
||||
platforms (list[str]): List of hardware models on which the test should be skipped.
|
||||
Parameters
|
||||
----------
|
||||
platforms
|
||||
List of hardware models on which the test should be skipped.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Callable[[F], F]
|
||||
A decorator that can be used to wrap test functions.
|
||||
|
||||
Returns:
|
||||
Callable[[F], F]: A decorator that can be used to wrap test functions.
|
||||
"""
|
||||
|
||||
def decorator(function: F) -> F:
|
||||
"""
|
||||
Actual decorator that either runs the test or skips it based on the device's hardware model.
|
||||
"""Actual decorator that either runs the test or skips it based on the device's hardware model.
|
||||
|
||||
Args:
|
||||
function (F): The test function to be decorated.
|
||||
Parameters
|
||||
----------
|
||||
function
|
||||
The test function to be decorated.
|
||||
|
||||
Returns
|
||||
-------
|
||||
F
|
||||
The decorated function.
|
||||
|
||||
Returns:
|
||||
F: The decorated function.
|
||||
"""
|
||||
|
||||
@wraps(function)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> TestResult:
|
||||
"""
|
||||
Check the device's hardware model and conditionally run or skip the test.
|
||||
"""Check the device's hardware model and conditionally run or skip the test.
|
||||
|
||||
This wrapper inspects the hardware model of the device the test is run on.
|
||||
If the model is in the list of specified platforms, the test is either skipped.
|
||||
|
|
479
anta/device.py
479
anta/device.py
|
@ -1,65 +1,86 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
ANTA Device Abstraction Module
|
||||
"""
|
||||
"""ANTA Device Abstraction Module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator, Literal, Optional, Union
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import asyncssh
|
||||
import httpcore
|
||||
from aiocache import Cache
|
||||
from aiocache.plugins import HitMissRatioPlugin
|
||||
from asyncssh import SSHClientConnection, SSHClientConnectionOptions
|
||||
from httpx import ConnectError, HTTPError
|
||||
from httpx import ConnectError, HTTPError, TimeoutException
|
||||
|
||||
from anta import __DEBUG__, aioeapi
|
||||
import asynceapi
|
||||
from anta import __DEBUG__
|
||||
from anta.logger import anta_log_exception, exc_to_str
|
||||
from anta.models import AntaCommand
|
||||
from anta.tools.misc import exc_to_str
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Do not load the default keypairs multiple times due to a performance issue introduced in cryptography 37.0
|
||||
# https://github.com/pyca/cryptography/issues/7236#issuecomment-1131908472
|
||||
CLIENT_KEYS = asyncssh.public_key.load_default_keypairs()
|
||||
|
||||
|
||||
class AntaDevice(ABC):
|
||||
"""
|
||||
Abstract class representing a device in ANTA.
|
||||
"""Abstract class representing a device in ANTA.
|
||||
|
||||
An implementation of this class must override the abstract coroutines `_collect()` and
|
||||
`refresh()`.
|
||||
|
||||
Attributes:
|
||||
name: Device name
|
||||
is_online: True if the device IP is reachable and a port can be open
|
||||
established: True if remote command execution succeeds
|
||||
hw_model: Hardware model of the device
|
||||
tags: List of tags for this device
|
||||
cache: In-memory cache from aiocache library for this device (None if cache is disabled)
|
||||
cache_locks: Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled
|
||||
Attributes
|
||||
----------
|
||||
name : str
|
||||
Device name.
|
||||
is_online : bool
|
||||
True if the device IP is reachable and a port can be open.
|
||||
established : bool
|
||||
True if remote command execution succeeds.
|
||||
hw_model : str
|
||||
Hardware model of the device.
|
||||
tags : set[str]
|
||||
Tags for this device.
|
||||
cache : Cache | None
|
||||
In-memory cache from aiocache library for this device (None if cache is disabled).
|
||||
cache_locks : dict
|
||||
Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, tags: Optional[list[str]] = None, disable_cache: bool = False) -> None:
|
||||
"""
|
||||
Constructor of AntaDevice
|
||||
def __init__(self, name: str, tags: set[str] | None = None, *, disable_cache: bool = False) -> None:
|
||||
"""Initialize an AntaDevice.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name
|
||||
Device name.
|
||||
tags
|
||||
Tags for this device.
|
||||
disable_cache
|
||||
Disable caching for all commands for this device.
|
||||
|
||||
Args:
|
||||
name: Device name
|
||||
tags: List of tags for this device
|
||||
disable_cache: Disable caching for all commands for this device. Defaults to False.
|
||||
"""
|
||||
self.name: str = name
|
||||
self.hw_model: Optional[str] = None
|
||||
self.tags: list[str] = tags if tags is not None else []
|
||||
self.hw_model: str | None = None
|
||||
self.tags: set[str] = tags if tags is not None else set()
|
||||
# A device always has its own name as tag
|
||||
self.tags.append(self.name)
|
||||
self.tags.add(self.name)
|
||||
self.is_online: bool = False
|
||||
self.established: bool = False
|
||||
self.cache: Optional[Cache] = None
|
||||
self.cache_locks: Optional[defaultdict[str, asyncio.Lock]] = None
|
||||
self.cache: Cache | None = None
|
||||
self.cache_locks: defaultdict[str, asyncio.Lock] | None = None
|
||||
|
||||
# Initialize cache if not disabled
|
||||
if not disable_cache:
|
||||
|
@ -68,34 +89,24 @@ class AntaDevice(ABC):
|
|||
@property
|
||||
@abstractmethod
|
||||
def _keys(self) -> tuple[Any, ...]:
|
||||
"""
|
||||
Read-only property to implement hashing and equality for AntaDevice classes.
|
||||
"""
|
||||
"""Read-only property to implement hashing and equality for AntaDevice classes."""
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
"""
|
||||
Implement equality for AntaDevice objects.
|
||||
"""
|
||||
"""Implement equality for AntaDevice objects."""
|
||||
return self._keys == other._keys if isinstance(other, self.__class__) else False
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""
|
||||
Implement hashing for AntaDevice objects.
|
||||
"""
|
||||
"""Implement hashing for AntaDevice objects."""
|
||||
return hash(self._keys)
|
||||
|
||||
def _init_cache(self) -> None:
|
||||
"""
|
||||
Initialize cache for the device, can be overriden by subclasses to manipulate how it works
|
||||
"""
|
||||
"""Initialize cache for the device, can be overridden by subclasses to manipulate how it works."""
|
||||
self.cache = Cache(cache_class=Cache.MEMORY, ttl=60, namespace=self.name, plugins=[HitMissRatioPlugin()])
|
||||
self.cache_locks = defaultdict(asyncio.Lock)
|
||||
|
||||
@property
|
||||
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
|
||||
# https://github.com/pylint-dev/pylint/issues/7258
|
||||
if self.cache is not None:
|
||||
|
@ -104,9 +115,9 @@ class AntaDevice(ABC):
|
|||
return None
|
||||
|
||||
def __rich_repr__(self) -> Iterator[tuple[str, Any]]:
|
||||
"""
|
||||
Implements Rich Repr Protocol
|
||||
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
|
||||
"""Implement Rich Repr Protocol.
|
||||
|
||||
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol.
|
||||
"""
|
||||
yield "name", self.name
|
||||
yield "tags", self.tags
|
||||
|
@ -115,10 +126,21 @@ class AntaDevice(ABC):
|
|||
yield "established", self.established
|
||||
yield "disable_cache", self.cache is None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a printable representation of an AntaDevice."""
|
||||
return (
|
||||
f"AntaDevice({self.name!r}, "
|
||||
f"tags={self.tags!r}, "
|
||||
f"hw_model={self.hw_model!r}, "
|
||||
f"is_online={self.is_online!r}, "
|
||||
f"established={self.established!r}, "
|
||||
f"disable_cache={self.cache is None!r})"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
async def _collect(self, command: AntaCommand) -> None:
|
||||
"""
|
||||
Collect device command output.
|
||||
async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None:
|
||||
"""Collect device command output.
|
||||
|
||||
This abstract coroutine can be used to implement any command collection method
|
||||
for a device in ANTA.
|
||||
|
||||
|
@ -129,13 +151,16 @@ class AntaDevice(ABC):
|
|||
exception and implement proper logging, the `output` attribute of the
|
||||
`AntaCommand` object passed as argument would be `None` in this case.
|
||||
|
||||
Args:
|
||||
command: the command to collect
|
||||
Parameters
|
||||
----------
|
||||
command
|
||||
The command to collect.
|
||||
collection_id
|
||||
An identifier used to build the eAPI request ID.
|
||||
"""
|
||||
|
||||
async def collect(self, command: AntaCommand) -> None:
|
||||
"""
|
||||
Collects the output for a specified command.
|
||||
async def collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None:
|
||||
"""Collect the output for a specified command.
|
||||
|
||||
When caching is activated on both the device and the command,
|
||||
this method prioritizes retrieving the output from the cache. In cases where the output isn't cached yet,
|
||||
|
@ -145,8 +170,12 @@ class AntaDevice(ABC):
|
|||
When caching is NOT enabled, either at the device or command level, the method directly collects the output
|
||||
via the private `_collect` method without interacting with the cache.
|
||||
|
||||
Args:
|
||||
command (AntaCommand): The command to process.
|
||||
Parameters
|
||||
----------
|
||||
command
|
||||
The command to collect.
|
||||
collection_id
|
||||
An identifier used to build the eAPI request ID.
|
||||
"""
|
||||
# Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough
|
||||
# https://github.com/pylint-dev/pylint/issues/7258
|
||||
|
@ -155,100 +184,125 @@ class AntaDevice(ABC):
|
|||
cached_output = await self.cache.get(command.uid) # pylint: disable=no-member
|
||||
|
||||
if cached_output is not None:
|
||||
logger.debug(f"Cache hit for {command.command} on {self.name}")
|
||||
logger.debug("Cache hit for %s on %s", command.command, self.name)
|
||||
command.output = cached_output
|
||||
else:
|
||||
await self._collect(command=command)
|
||||
await self._collect(command=command, collection_id=collection_id)
|
||||
await self.cache.set(command.uid, command.output) # pylint: disable=no-member
|
||||
else:
|
||||
await self._collect(command=command)
|
||||
await self._collect(command=command, collection_id=collection_id)
|
||||
|
||||
async def collect_commands(self, commands: list[AntaCommand]) -> None:
|
||||
async def collect_commands(self, commands: list[AntaCommand], *, collection_id: str | None = None) -> None:
|
||||
"""Collect multiple commands.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
commands
|
||||
The commands to collect.
|
||||
collection_id
|
||||
An identifier used to build the eAPI request ID.
|
||||
"""
|
||||
Collect multiple commands.
|
||||
|
||||
Args:
|
||||
commands: the commands to collect
|
||||
"""
|
||||
await asyncio.gather(*(self.collect(command=command) for command in commands))
|
||||
|
||||
def supports(self, command: AntaCommand) -> bool:
|
||||
"""Returns True if the command is supported on the device hardware platform, False otherwise."""
|
||||
unsupported = any("not supported on this hardware platform" in e for e in command.errors)
|
||||
logger.debug(command)
|
||||
if unsupported:
|
||||
logger.debug(f"{command.command} is not supported on {self.hw_model}")
|
||||
return not unsupported
|
||||
await asyncio.gather(*(self.collect(command=command, collection_id=collection_id) for command in commands))
|
||||
|
||||
@abstractmethod
|
||||
async def refresh(self) -> None:
|
||||
"""
|
||||
Update attributes of an AntaDevice instance.
|
||||
"""Update attributes of an AntaDevice instance.
|
||||
|
||||
This coroutine must update the following attributes of AntaDevice:
|
||||
- `is_online`: When the device IP is reachable and a port can be open
|
||||
- `established`: When a command execution succeeds
|
||||
- `hw_model`: The hardware model of the device
|
||||
|
||||
- `is_online`: When the device IP is reachable and a port can be open.
|
||||
|
||||
- `established`: When a command execution succeeds.
|
||||
|
||||
- `hw_model`: The hardware model of the device.
|
||||
"""
|
||||
|
||||
async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None:
|
||||
"""
|
||||
Copy files to and from the device, usually through SCP.
|
||||
"""Copy files to and from the device, usually through SCP.
|
||||
|
||||
It is not mandatory to implement this for a valid AntaDevice subclass.
|
||||
|
||||
Args:
|
||||
sources: List of files to copy to or from the device.
|
||||
destination: Local or remote destination when copying the files. Can be a folder.
|
||||
direction: Defines if this coroutine copies files to or from the device.
|
||||
Parameters
|
||||
----------
|
||||
sources
|
||||
List of files to copy to or from the device.
|
||||
destination
|
||||
Local or remote destination when copying the files. Can be a folder.
|
||||
direction
|
||||
Defines if this coroutine copies files to or from the device.
|
||||
|
||||
"""
|
||||
raise NotImplementedError(f"copy() method has not been implemented in {self.__class__.__name__} definition")
|
||||
_ = (sources, destination, direction)
|
||||
msg = f"copy() method has not been implemented in {self.__class__.__name__} definition"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
|
||||
class AsyncEOSDevice(AntaDevice):
|
||||
"""
|
||||
Implementation of AntaDevice for EOS using aio-eapi.
|
||||
"""Implementation of AntaDevice for EOS using aio-eapi.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
name : str
|
||||
Device name.
|
||||
is_online : bool
|
||||
True if the device IP is reachable and a port can be open.
|
||||
established : bool
|
||||
True if remote command execution succeeds.
|
||||
hw_model : str
|
||||
Hardware model of the device.
|
||||
tags : set[str]
|
||||
Tags for this device.
|
||||
|
||||
Attributes:
|
||||
name: Device name
|
||||
is_online: True if the device IP is reachable and a port can be open
|
||||
established: True if remote command execution succeeds
|
||||
hw_model: Hardware model of the device
|
||||
tags: List of tags for this device
|
||||
"""
|
||||
|
||||
def __init__( # pylint: disable=R0913
|
||||
def __init__( # noqa: PLR0913
|
||||
self,
|
||||
host: str,
|
||||
username: str,
|
||||
password: str,
|
||||
name: Optional[str] = None,
|
||||
enable: bool = False,
|
||||
enable_password: Optional[str] = None,
|
||||
port: Optional[int] = None,
|
||||
ssh_port: Optional[int] = 22,
|
||||
tags: Optional[list[str]] = None,
|
||||
timeout: Optional[float] = None,
|
||||
insecure: bool = False,
|
||||
name: str | None = None,
|
||||
enable_password: str | None = None,
|
||||
port: int | None = None,
|
||||
ssh_port: int | None = 22,
|
||||
tags: set[str] | None = None,
|
||||
timeout: float | None = None,
|
||||
proto: Literal["http", "https"] = "https",
|
||||
*,
|
||||
enable: bool = False,
|
||||
insecure: bool = False,
|
||||
disable_cache: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Constructor of AsyncEOSDevice
|
||||
"""Instantiate an AsyncEOSDevice.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
host
|
||||
Device FQDN or IP.
|
||||
username
|
||||
Username to connect to eAPI and SSH.
|
||||
password
|
||||
Password to connect to eAPI and SSH.
|
||||
name
|
||||
Device name.
|
||||
enable
|
||||
Collect commands using privileged mode.
|
||||
enable_password
|
||||
Password used to gain privileged access on EOS.
|
||||
port
|
||||
eAPI port. Defaults to 80 is proto is 'http' or 443 if proto is 'https'.
|
||||
ssh_port
|
||||
SSH port.
|
||||
tags
|
||||
Tags for this device.
|
||||
timeout
|
||||
Timeout value in seconds for outgoing API calls.
|
||||
insecure
|
||||
Disable SSH Host Key validation.
|
||||
proto
|
||||
eAPI protocol. Value can be 'http' or 'https'.
|
||||
disable_cache
|
||||
Disable caching for all commands for this device.
|
||||
|
||||
Args:
|
||||
host: Device FQDN or IP
|
||||
username: Username to connect to eAPI and SSH
|
||||
password: Password to connect to eAPI and SSH
|
||||
name: Device name
|
||||
enable: Device needs privileged access
|
||||
enable_password: Password used to gain privileged access on EOS
|
||||
port: eAPI port. Defaults to 80 is proto is 'http' or 443 if proto is 'https'.
|
||||
ssh_port: SSH port
|
||||
tags: List of tags for this device
|
||||
timeout: Timeout value in seconds for outgoing connections. Default to 10 secs.
|
||||
insecure: Disable SSH Host Key validation
|
||||
proto: eAPI protocol. Value can be 'http' or 'https'
|
||||
disable_cache: Disable caching for all commands for this device. Defaults to False.
|
||||
"""
|
||||
if host is None:
|
||||
message = "'host' is required to create an AsyncEOSDevice"
|
||||
|
@ -256,7 +310,7 @@ class AsyncEOSDevice(AntaDevice):
|
|||
raise ValueError(message)
|
||||
if name is None:
|
||||
name = f"{host}{f':{port}' if port else ''}"
|
||||
super().__init__(name, tags, disable_cache)
|
||||
super().__init__(name, tags, disable_cache=disable_cache)
|
||||
if username is None:
|
||||
message = f"'username' is required to instantiate device '{self.name}'"
|
||||
logger.error(message)
|
||||
|
@ -267,16 +321,18 @@ class AsyncEOSDevice(AntaDevice):
|
|||
raise ValueError(message)
|
||||
self.enable = enable
|
||||
self._enable_password = enable_password
|
||||
self._session: aioeapi.Device = aioeapi.Device(host=host, port=port, username=username, password=password, proto=proto, timeout=timeout)
|
||||
self._session: asynceapi.Device = asynceapi.Device(host=host, port=port, username=username, password=password, proto=proto, timeout=timeout)
|
||||
ssh_params: dict[str, Any] = {}
|
||||
if insecure:
|
||||
ssh_params["known_hosts"] = None
|
||||
self._ssh_opts: SSHClientConnectionOptions = SSHClientConnectionOptions(host=host, port=ssh_port, username=username, password=password, **ssh_params)
|
||||
self._ssh_opts: SSHClientConnectionOptions = SSHClientConnectionOptions(
|
||||
host=host, port=ssh_port, username=username, password=password, client_keys=CLIENT_KEYS, **ssh_params
|
||||
)
|
||||
|
||||
def __rich_repr__(self) -> Iterator[tuple[str, Any]]:
|
||||
"""
|
||||
Implements Rich Repr Protocol
|
||||
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
|
||||
"""Implement Rich Repr Protocol.
|
||||
|
||||
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol.
|
||||
"""
|
||||
yield from super().__rich_repr__()
|
||||
yield ("host", self._session.host)
|
||||
|
@ -286,107 +342,154 @@ class AsyncEOSDevice(AntaDevice):
|
|||
yield ("insecure", self._ssh_opts.known_hosts is None)
|
||||
if __DEBUG__:
|
||||
_ssh_opts = vars(self._ssh_opts).copy()
|
||||
PASSWORD_VALUE = "<removed>"
|
||||
_ssh_opts["password"] = PASSWORD_VALUE
|
||||
_ssh_opts["kwargs"]["password"] = PASSWORD_VALUE
|
||||
removed_pw = "<removed>"
|
||||
_ssh_opts["password"] = removed_pw
|
||||
_ssh_opts["kwargs"]["password"] = removed_pw
|
||||
yield ("_session", vars(self._session))
|
||||
yield ("_ssh_opts", _ssh_opts)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a printable representation of an AsyncEOSDevice."""
|
||||
return (
|
||||
f"AsyncEOSDevice({self.name!r}, "
|
||||
f"tags={self.tags!r}, "
|
||||
f"hw_model={self.hw_model!r}, "
|
||||
f"is_online={self.is_online!r}, "
|
||||
f"established={self.established!r}, "
|
||||
f"disable_cache={self.cache is None!r}, "
|
||||
f"host={self._session.host!r}, "
|
||||
f"eapi_port={self._session.port!r}, "
|
||||
f"username={self._ssh_opts.username!r}, "
|
||||
f"enable={self.enable!r}, "
|
||||
f"insecure={self._ssh_opts.known_hosts is None!r})"
|
||||
)
|
||||
|
||||
@property
|
||||
def _keys(self) -> tuple[Any, ...]:
|
||||
"""
|
||||
Two AsyncEOSDevice objects are equal if the hostname and the port are the same.
|
||||
"""Two AsyncEOSDevice objects are equal if the hostname and the port are the same.
|
||||
|
||||
This covers the use case of port forwarding when the host is localhost and the devices have different ports.
|
||||
"""
|
||||
return (self._session.host, self._session.port)
|
||||
|
||||
async def _collect(self, command: AntaCommand) -> None:
|
||||
"""
|
||||
Collect device command output from EOS using aio-eapi.
|
||||
async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None:
|
||||
"""Collect device command output from EOS using aio-eapi.
|
||||
|
||||
Supports outformat `json` and `text` as output structure.
|
||||
Gain privileged access using the `enable_password` attribute
|
||||
of the `AntaDevice` instance if populated.
|
||||
|
||||
Args:
|
||||
command: the command to collect
|
||||
Parameters
|
||||
----------
|
||||
command
|
||||
The command to collect.
|
||||
collection_id
|
||||
An identifier used to build the eAPI request ID.
|
||||
"""
|
||||
commands = []
|
||||
commands: list[dict[str, str | int]] = []
|
||||
if self.enable and self._enable_password is not None:
|
||||
commands.append(
|
||||
{
|
||||
"cmd": "enable",
|
||||
"input": str(self._enable_password),
|
||||
}
|
||||
},
|
||||
)
|
||||
elif self.enable:
|
||||
# No password
|
||||
commands.append({"cmd": "enable"})
|
||||
if command.revision:
|
||||
commands.append({"cmd": command.command, "revision": command.revision})
|
||||
else:
|
||||
commands.append({"cmd": command.command})
|
||||
commands += [{"cmd": command.command, "revision": command.revision}] if command.revision else [{"cmd": command.command}]
|
||||
try:
|
||||
response: list[dict[str, Any]] = await self._session.cli(
|
||||
response: list[dict[str, Any] | str] = await self._session.cli(
|
||||
commands=commands,
|
||||
ofmt=command.ofmt,
|
||||
version=command.version,
|
||||
)
|
||||
except aioeapi.EapiCommandError as e:
|
||||
command.errors = e.errors
|
||||
if self.supports(command):
|
||||
message = f"Command '{command.command}' failed on {self.name}"
|
||||
logger.error(message)
|
||||
except (HTTPError, ConnectError) as e:
|
||||
command.errors = [str(e)]
|
||||
message = f"Cannot connect to device {self.name}"
|
||||
logger.error(message)
|
||||
else:
|
||||
# selecting only our command output
|
||||
req_id=f"ANTA-{collection_id}-{id(command)}" if collection_id else f"ANTA-{id(command)}",
|
||||
) # type: ignore[assignment] # multiple commands returns a list
|
||||
# Do not keep response of 'enable' command
|
||||
command.output = response[-1]
|
||||
logger.debug(f"{self.name}: {command}")
|
||||
except asynceapi.EapiCommandError as e:
|
||||
# This block catches exceptions related to EOS issuing an error.
|
||||
self._log_eapi_command_error(command, e)
|
||||
except TimeoutException as e:
|
||||
# This block catches Timeout exceptions.
|
||||
command.errors = [exc_to_str(e)]
|
||||
timeouts = self._session.timeout.as_dict()
|
||||
logger.error(
|
||||
"%s occurred while sending a command to %s. Consider increasing the timeout.\nCurrent timeouts: Connect: %s | Read: %s | Write: %s | Pool: %s",
|
||||
exc_to_str(e),
|
||||
self.name,
|
||||
timeouts["connect"],
|
||||
timeouts["read"],
|
||||
timeouts["write"],
|
||||
timeouts["pool"],
|
||||
)
|
||||
except (ConnectError, OSError) as e:
|
||||
# This block catches OSError and socket issues related exceptions.
|
||||
command.errors = [exc_to_str(e)]
|
||||
if (isinstance(exc := e.__cause__, httpcore.ConnectError) and isinstance(os_error := exc.__context__, OSError)) or isinstance(os_error := e, OSError): # pylint: disable=no-member
|
||||
if isinstance(os_error.__cause__, OSError):
|
||||
os_error = os_error.__cause__
|
||||
logger.error("A local OS error occurred while connecting to %s: %s.", self.name, os_error)
|
||||
else:
|
||||
anta_log_exception(e, f"An error occurred while issuing an eAPI request to {self.name}", logger)
|
||||
except HTTPError as e:
|
||||
# This block catches most of the httpx Exceptions and logs a general message.
|
||||
command.errors = [exc_to_str(e)]
|
||||
anta_log_exception(e, f"An error occurred while issuing an eAPI request to {self.name}", logger)
|
||||
logger.debug("%s: %s", self.name, command)
|
||||
|
||||
def _log_eapi_command_error(self, command: AntaCommand, e: asynceapi.EapiCommandError) -> None:
|
||||
"""Appropriately log the eapi command error."""
|
||||
command.errors = e.errors
|
||||
if command.requires_privileges:
|
||||
logger.error("Command '%s' requires privileged mode on %s. Verify user permissions and if the `enable` option is required.", command.command, self.name)
|
||||
if not command.supported:
|
||||
logger.debug("Command '%s' is not supported on '%s' (%s)", command.command, self.name, self.hw_model)
|
||||
elif command.returned_known_eos_error:
|
||||
logger.debug("Command '%s' returned a known error '%s': %s", command.command, self.name, command.errors)
|
||||
else:
|
||||
logger.error("Command '%s' failed on %s: %s", command.command, self.name, e.errors[0] if len(e.errors) == 1 else e.errors)
|
||||
|
||||
async def refresh(self) -> None:
|
||||
"""
|
||||
Update attributes of an AsyncEOSDevice instance.
|
||||
"""Update attributes of an AsyncEOSDevice instance.
|
||||
|
||||
This coroutine must update the following attributes of AsyncEOSDevice:
|
||||
- is_online: When a device IP is reachable and a port can be open
|
||||
- established: When a command execution succeeds
|
||||
- hw_model: The hardware model of the device
|
||||
"""
|
||||
logger.debug(f"Refreshing device {self.name}")
|
||||
logger.debug("Refreshing device %s", self.name)
|
||||
self.is_online = await self._session.check_connection()
|
||||
if self.is_online:
|
||||
COMMAND: str = "show version"
|
||||
HW_MODEL_KEY: str = "modelName"
|
||||
try:
|
||||
response = await self._session.cli(command=COMMAND)
|
||||
except aioeapi.EapiCommandError as e:
|
||||
logger.warning(f"Cannot get hardware information from device {self.name}: {e.errmsg}")
|
||||
|
||||
except (HTTPError, ConnectError) as e:
|
||||
logger.warning(f"Cannot get hardware information from device {self.name}: {exc_to_str(e)}")
|
||||
|
||||
show_version = AntaCommand(command="show version")
|
||||
await self._collect(show_version)
|
||||
if not show_version.collected:
|
||||
logger.warning("Cannot get hardware information from device %s", self.name)
|
||||
else:
|
||||
if HW_MODEL_KEY in response:
|
||||
self.hw_model = response[HW_MODEL_KEY]
|
||||
else:
|
||||
logger.warning(f"Cannot get hardware information from device {self.name}: cannot parse '{COMMAND}'")
|
||||
|
||||
self.hw_model = show_version.json_output.get("modelName", None)
|
||||
if self.hw_model is None:
|
||||
logger.critical("Cannot parse 'show version' returned by device %s", self.name)
|
||||
# in some cases it is possible that 'modelName' comes back empty
|
||||
# and it is nice to get a meaninfule error message
|
||||
elif self.hw_model == "":
|
||||
logger.critical("Got an empty 'modelName' in the 'show version' returned by device %s", self.name)
|
||||
else:
|
||||
logger.warning(f"Could not connect to device {self.name}: cannot open eAPI port")
|
||||
logger.warning("Could not connect to device %s: cannot open eAPI port", self.name)
|
||||
|
||||
self.established = bool(self.is_online and self.hw_model)
|
||||
|
||||
async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None:
|
||||
"""
|
||||
Copy files to and from the device using asyncssh.scp().
|
||||
"""Copy files to and from the device using asyncssh.scp().
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sources
|
||||
List of files to copy to or from the device.
|
||||
destination
|
||||
Local or remote destination when copying the files. Can be a folder.
|
||||
direction
|
||||
Defines if this coroutine copies files to or from the device.
|
||||
|
||||
Args:
|
||||
sources: List of files to copy to or from the device.
|
||||
destination: Local or remote destination when copying the files. Can be a folder.
|
||||
direction: Defines if this coroutine copies files to or from the device.
|
||||
"""
|
||||
async with asyncssh.connect(
|
||||
host=self._ssh_opts.host,
|
||||
|
@ -396,22 +499,24 @@ class AsyncEOSDevice(AntaDevice):
|
|||
local_addr=self._ssh_opts.local_addr,
|
||||
options=self._ssh_opts,
|
||||
) as conn:
|
||||
src: Union[list[tuple[SSHClientConnection, Path]], list[Path]]
|
||||
dst: Union[tuple[SSHClientConnection, Path], Path]
|
||||
src: list[tuple[SSHClientConnection, Path]] | list[Path]
|
||||
dst: tuple[SSHClientConnection, Path] | Path
|
||||
if direction == "from":
|
||||
src = [(conn, file) for file in sources]
|
||||
dst = destination
|
||||
for file in sources:
|
||||
logger.info(f"Copying '{file}' from device {self.name} to '{destination}' locally")
|
||||
message = f"Copying '{file}' from device {self.name} to '{destination}' locally"
|
||||
logger.info(message)
|
||||
|
||||
elif direction == "to":
|
||||
src = sources
|
||||
dst = conn, destination
|
||||
for file in src:
|
||||
logger.info(f"Copying '{file}' to device {self.name} to '{destination}' remotely")
|
||||
message = f"Copying '{file}' to device {self.name} to '{destination}' remotely"
|
||||
logger.info(message)
|
||||
|
||||
else:
|
||||
logger.critical(f"'direction' argument to copy() fonction is invalid: {direction}")
|
||||
logger.critical("'direction' argument to copy() function is invalid: %s", direction)
|
||||
|
||||
return
|
||||
await asyncssh.scp(src, dst)
|
||||
|
|
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})"
|
|
@ -1,9 +1,7 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Inventory Module for ANTA.
|
||||
"""
|
||||
"""Inventory module for ANTA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
@ -11,32 +9,29 @@ import asyncio
|
|||
import logging
|
||||
from ipaddress import ip_address, ip_network
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pydantic import ValidationError
|
||||
from yaml import YAMLError, safe_load
|
||||
|
||||
from anta.device import AntaDevice, AsyncEOSDevice
|
||||
from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError
|
||||
from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError
|
||||
from anta.inventory.models import AntaInventoryInput
|
||||
from anta.logger import anta_log_exception
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AntaInventory(dict): # type: ignore
|
||||
# dict[str, AntaDevice] - not working in python 3.8 hence the ignore
|
||||
"""
|
||||
Inventory abstraction for ANTA framework.
|
||||
"""
|
||||
class AntaInventory(dict[str, AntaDevice]):
|
||||
"""Inventory abstraction for ANTA framework."""
|
||||
|
||||
# Root key of inventory part of the inventory file
|
||||
INVENTORY_ROOT_KEY = "anta_inventory"
|
||||
# Supported Output format
|
||||
INVENTORY_OUTPUT_FORMAT = ["native", "json"]
|
||||
INVENTORY_OUTPUT_FORMAT: ClassVar[list[str]] = ["native", "json"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Human readable string representing the inventory"""
|
||||
"""Human readable string representing the inventory."""
|
||||
devs = {}
|
||||
for dev in self.values():
|
||||
if (dev_type := dev.__class__.__name__) not in devs:
|
||||
|
@ -46,80 +41,119 @@ class AntaInventory(dict): # type: ignore
|
|||
return f"ANTA Inventory contains {' '.join([f'{n} devices ({t})' for t, n in devs.items()])}"
|
||||
|
||||
@staticmethod
|
||||
def _update_disable_cache(inventory_disable_cache: bool, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Return new dictionary, replacing kwargs with added disable_cache value from inventory_value
|
||||
if disable_cache has not been set by CLI.
|
||||
def _update_disable_cache(kwargs: dict[str, Any], *, inventory_disable_cache: bool) -> dict[str, Any]:
|
||||
"""Return new dictionary, replacing kwargs with added disable_cache value from inventory_value if disable_cache has not been set by CLI.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
inventory_disable_cache
|
||||
The value of disable_cache in the inventory.
|
||||
kwargs
|
||||
The kwargs to instantiate the device.
|
||||
|
||||
Args:
|
||||
inventory_disable_cache (bool): The value of disable_cache in the inventory
|
||||
kwargs: The kwargs to instantiate the device
|
||||
"""
|
||||
updated_kwargs = kwargs.copy()
|
||||
updated_kwargs["disable_cache"] = inventory_disable_cache or kwargs.get("disable_cache")
|
||||
return updated_kwargs
|
||||
|
||||
@staticmethod
|
||||
def _parse_hosts(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None:
|
||||
"""
|
||||
Parses the host section of an AntaInventoryInput and add the devices to the inventory
|
||||
def _parse_hosts(
|
||||
inventory_input: AntaInventoryInput,
|
||||
inventory: AntaInventory,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> None:
|
||||
"""Parse the host section of an AntaInventoryInput and add the devices to the inventory.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
inventory_input
|
||||
AntaInventoryInput used to parse the devices.
|
||||
inventory
|
||||
AntaInventory to add the parsed devices to.
|
||||
**kwargs
|
||||
Additional keyword arguments to pass to the device constructor.
|
||||
|
||||
Args:
|
||||
inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices
|
||||
inventory (AntaInventory): AntaInventory to add the parsed devices to
|
||||
"""
|
||||
if inventory_input.hosts is None:
|
||||
return
|
||||
|
||||
for host in inventory_input.hosts:
|
||||
updated_kwargs = AntaInventory._update_disable_cache(host.disable_cache, kwargs)
|
||||
device = AsyncEOSDevice(name=host.name, host=str(host.host), port=host.port, tags=host.tags, **updated_kwargs)
|
||||
updated_kwargs = AntaInventory._update_disable_cache(kwargs, inventory_disable_cache=host.disable_cache)
|
||||
device = AsyncEOSDevice(
|
||||
name=host.name,
|
||||
host=str(host.host),
|
||||
port=host.port,
|
||||
tags=host.tags,
|
||||
**updated_kwargs,
|
||||
)
|
||||
inventory.add_device(device)
|
||||
|
||||
@staticmethod
|
||||
def _parse_networks(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None:
|
||||
"""
|
||||
Parses the network section of an AntaInventoryInput and add the devices to the inventory.
|
||||
def _parse_networks(
|
||||
inventory_input: AntaInventoryInput,
|
||||
inventory: AntaInventory,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> None:
|
||||
"""Parse the network section of an AntaInventoryInput and add the devices to the inventory.
|
||||
|
||||
Args:
|
||||
inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices
|
||||
inventory (AntaInventory): AntaInventory to add the parsed devices to
|
||||
Parameters
|
||||
----------
|
||||
inventory_input
|
||||
AntaInventoryInput used to parse the devices.
|
||||
inventory
|
||||
AntaInventory to add the parsed devices to.
|
||||
**kwargs
|
||||
Additional keyword arguments to pass to the device constructor.
|
||||
|
||||
Raises
|
||||
------
|
||||
InventoryIncorrectSchemaError
|
||||
Inventory file is not following AntaInventory Schema.
|
||||
|
||||
Raises:
|
||||
InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema.
|
||||
"""
|
||||
if inventory_input.networks is None:
|
||||
return
|
||||
|
||||
for network in inventory_input.networks:
|
||||
try:
|
||||
updated_kwargs = AntaInventory._update_disable_cache(network.disable_cache, kwargs)
|
||||
try:
|
||||
for network in inventory_input.networks:
|
||||
updated_kwargs = AntaInventory._update_disable_cache(kwargs, inventory_disable_cache=network.disable_cache)
|
||||
for host_ip in ip_network(str(network.network)):
|
||||
device = AsyncEOSDevice(host=str(host_ip), tags=network.tags, **updated_kwargs)
|
||||
inventory.add_device(device)
|
||||
except ValueError as e:
|
||||
message = "Could not parse network {network.network} in the inventory"
|
||||
anta_log_exception(e, message, logger)
|
||||
raise InventoryIncorrectSchema(message) from e
|
||||
except ValueError as e:
|
||||
message = "Could not parse the network section in the inventory"
|
||||
anta_log_exception(e, message, logger)
|
||||
raise InventoryIncorrectSchemaError(message) from e
|
||||
|
||||
@staticmethod
|
||||
def _parse_ranges(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None:
|
||||
"""
|
||||
Parses the range section of an AntaInventoryInput and add the devices to the inventory.
|
||||
def _parse_ranges(
|
||||
inventory_input: AntaInventoryInput,
|
||||
inventory: AntaInventory,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> None:
|
||||
"""Parse the range section of an AntaInventoryInput and add the devices to the inventory.
|
||||
|
||||
Args:
|
||||
inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices
|
||||
inventory (AntaInventory): AntaInventory to add the parsed devices to
|
||||
Parameters
|
||||
----------
|
||||
inventory_input
|
||||
AntaInventoryInput used to parse the devices.
|
||||
inventory
|
||||
AntaInventory to add the parsed devices to.
|
||||
**kwargs
|
||||
Additional keyword arguments to pass to the device constructor.
|
||||
|
||||
Raises
|
||||
------
|
||||
InventoryIncorrectSchemaError
|
||||
Inventory file is not following AntaInventory Schema.
|
||||
|
||||
Raises:
|
||||
InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema.
|
||||
"""
|
||||
if inventory_input.ranges is None:
|
||||
return
|
||||
|
||||
for range_def in inventory_input.ranges:
|
||||
try:
|
||||
updated_kwargs = AntaInventory._update_disable_cache(range_def.disable_cache, kwargs)
|
||||
try:
|
||||
for range_def in inventory_input.ranges:
|
||||
updated_kwargs = AntaInventory._update_disable_cache(kwargs, inventory_disable_cache=range_def.disable_cache)
|
||||
range_increment = ip_address(str(range_def.start))
|
||||
range_stop = ip_address(str(range_def.end))
|
||||
while range_increment <= range_stop: # type: ignore[operator]
|
||||
|
@ -128,46 +162,58 @@ class AntaInventory(dict): # type: ignore
|
|||
device = AsyncEOSDevice(host=str(range_increment), tags=range_def.tags, **updated_kwargs)
|
||||
inventory.add_device(device)
|
||||
range_increment += 1
|
||||
except ValueError as e:
|
||||
message = f"Could not parse the following range in the inventory: {range_def.start} - {range_def.end}"
|
||||
anta_log_exception(e, message, logger)
|
||||
raise InventoryIncorrectSchema(message) from e
|
||||
except TypeError as e:
|
||||
message = f"A range in the inventory has different address families for start and end: {range_def.start} - {range_def.end}"
|
||||
anta_log_exception(e, message, logger)
|
||||
raise InventoryIncorrectSchema(message) from e
|
||||
except ValueError as e:
|
||||
message = "Could not parse the range section in the inventory"
|
||||
anta_log_exception(e, message, logger)
|
||||
raise InventoryIncorrectSchemaError(message) from e
|
||||
except TypeError as e:
|
||||
message = "A range in the inventory has different address families (IPv4 vs IPv6)"
|
||||
anta_log_exception(e, message, logger)
|
||||
raise InventoryIncorrectSchemaError(message) from e
|
||||
|
||||
@staticmethod
|
||||
def parse(
|
||||
filename: str | Path,
|
||||
username: str,
|
||||
password: str,
|
||||
enable_password: str | None = None,
|
||||
timeout: float | None = None,
|
||||
*,
|
||||
enable: bool = False,
|
||||
enable_password: Optional[str] = None,
|
||||
timeout: Optional[float] = None,
|
||||
insecure: bool = False,
|
||||
disable_cache: bool = False,
|
||||
) -> AntaInventory:
|
||||
# pylint: disable=too-many-arguments
|
||||
"""
|
||||
Create an AntaInventory instance from an inventory file.
|
||||
"""Create an AntaInventory instance from an inventory file.
|
||||
|
||||
The inventory devices are AsyncEOSDevice instances.
|
||||
|
||||
Args:
|
||||
filename (str): Path to device inventory YAML file
|
||||
username (str): Username to use to connect to devices
|
||||
password (str): Password to use to connect to devices
|
||||
enable (bool): Whether or not the commands need to be run in enable mode towards the devices
|
||||
enable_password (str, optional): Enable password to use if required
|
||||
timeout (float, optional): timeout in seconds for every API call.
|
||||
insecure (bool): Disable SSH Host Key validation
|
||||
disable_cache (bool): Disable cache globally
|
||||
Parameters
|
||||
----------
|
||||
filename
|
||||
Path to device inventory YAML file.
|
||||
username
|
||||
Username to use to connect to devices.
|
||||
password
|
||||
Password to use to connect to devices.
|
||||
enable_password
|
||||
Enable password to use if required.
|
||||
timeout
|
||||
Timeout value in seconds for outgoing API calls.
|
||||
enable
|
||||
Whether or not the commands need to be run in enable mode towards the devices.
|
||||
insecure
|
||||
Disable SSH Host Key validation.
|
||||
disable_cache
|
||||
Disable cache globally.
|
||||
|
||||
Raises
|
||||
------
|
||||
InventoryRootKeyError
|
||||
Root key of inventory is missing.
|
||||
InventoryIncorrectSchemaError
|
||||
Inventory file is not following AntaInventory Schema.
|
||||
|
||||
Raises:
|
||||
InventoryRootKeyError: Root key of inventory is missing.
|
||||
InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema.
|
||||
"""
|
||||
|
||||
inventory = AntaInventory()
|
||||
kwargs: dict[str, Any] = {
|
||||
"username": username,
|
||||
|
@ -188,7 +234,8 @@ class AntaInventory(dict): # type: ignore
|
|||
raise ValueError(message)
|
||||
|
||||
try:
|
||||
with open(file=filename, mode="r", encoding="UTF-8") as file:
|
||||
filename = Path(filename)
|
||||
with filename.open(encoding="UTF-8") as file:
|
||||
data = safe_load(file)
|
||||
except (TypeError, YAMLError, OSError) as e:
|
||||
message = f"Unable to parse ANTA Device Inventory file '{filename}'"
|
||||
|
@ -213,6 +260,11 @@ class AntaInventory(dict): # type: ignore
|
|||
|
||||
return inventory
|
||||
|
||||
@property
|
||||
def devices(self) -> list[AntaDevice]:
|
||||
"""List of AntaDevice in this inventory."""
|
||||
return list(self.values())
|
||||
|
||||
###########################################################################
|
||||
# Public methods
|
||||
###########################################################################
|
||||
|
@ -221,30 +273,35 @@ class AntaInventory(dict): # type: ignore
|
|||
# GET methods
|
||||
###########################################################################
|
||||
|
||||
def get_inventory(self, established_only: bool = False, tags: Optional[list[str]] = None) -> AntaInventory:
|
||||
"""
|
||||
Returns a filtered inventory.
|
||||
def get_inventory(self, *, established_only: bool = False, tags: set[str] | None = None, devices: set[str] | None = None) -> AntaInventory:
|
||||
"""Return a filtered inventory.
|
||||
|
||||
Args:
|
||||
established_only: Whether or not to include only established devices. Default False.
|
||||
tags: List of tags to filter devices.
|
||||
Parameters
|
||||
----------
|
||||
established_only
|
||||
Whether or not to include only established devices.
|
||||
tags
|
||||
Tags to filter devices.
|
||||
devices
|
||||
Names to filter devices.
|
||||
|
||||
Returns:
|
||||
AntaInventory: An inventory with filtered AntaDevice objects.
|
||||
Returns
|
||||
-------
|
||||
AntaInventory
|
||||
An inventory with filtered AntaDevice objects.
|
||||
"""
|
||||
|
||||
def _filter_devices(device: AntaDevice) -> bool:
|
||||
"""
|
||||
Helper function to select the devices based on the input tags
|
||||
and the requirement for an established connection.
|
||||
"""
|
||||
"""Select the devices based on the inputs `tags`, `devices` and `established_only`."""
|
||||
if tags is not None and all(tag not in tags for tag in device.tags):
|
||||
return False
|
||||
return bool(not established_only or device.established)
|
||||
if devices is None or device.name in devices:
|
||||
return bool(not established_only or device.established)
|
||||
return False
|
||||
|
||||
devices: list[AntaDevice] = list(filter(_filter_devices, self.values()))
|
||||
filtered_devices: list[AntaDevice] = list(filter(_filter_devices, self.values()))
|
||||
result = AntaInventory()
|
||||
for device in devices:
|
||||
for device in filtered_devices:
|
||||
result.add_device(device)
|
||||
return result
|
||||
|
||||
|
@ -253,15 +310,20 @@ class AntaInventory(dict): # type: ignore
|
|||
###########################################################################
|
||||
|
||||
def __setitem__(self, key: str, value: AntaDevice) -> None:
|
||||
"""Set a device in the inventory."""
|
||||
if key != value.name:
|
||||
raise RuntimeError(f"The key must be the device name for device '{value.name}'. Use AntaInventory.add_device().")
|
||||
msg = f"The key must be the device name for device '{value.name}'. Use AntaInventory.add_device()."
|
||||
raise RuntimeError(msg)
|
||||
return super().__setitem__(key, value)
|
||||
|
||||
def add_device(self, device: AntaDevice) -> None:
|
||||
"""Add a device to final inventory.
|
||||
|
||||
Args:
|
||||
device: Device object to be added
|
||||
Parameters
|
||||
----------
|
||||
device
|
||||
Device object to be added.
|
||||
|
||||
"""
|
||||
self[device.name] = device
|
||||
|
||||
|
|
|
@ -8,5 +8,5 @@ class InventoryRootKeyError(Exception):
|
|||
"""Error raised when inventory root key is not found."""
|
||||
|
||||
|
||||
class InventoryIncorrectSchema(Exception):
|
||||
class InventoryIncorrectSchemaError(Exception):
|
||||
"""Error when user data does not follow ANTA schema."""
|
||||
|
|
|
@ -6,87 +6,107 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Union
|
||||
import math
|
||||
|
||||
# Need to keep List for pydantic in python 3.8
|
||||
from pydantic import BaseModel, ConfigDict, IPvAnyAddress, IPvAnyNetwork, conint, constr
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, IPvAnyAddress, IPvAnyNetwork
|
||||
|
||||
from anta.custom_types import Hostname, Port
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pydantic models for input validation
|
||||
|
||||
RFC_1123_REGEX = r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$"
|
||||
|
||||
|
||||
class AntaInventoryHost(BaseModel):
|
||||
"""
|
||||
Host definition for user's inventory.
|
||||
"""Host entry of AntaInventoryInput.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
host : Hostname | IPvAnyAddress
|
||||
IP Address or FQDN of the device.
|
||||
port : Port | None
|
||||
Custom eAPI port to use.
|
||||
name : str | None
|
||||
Custom name of the device.
|
||||
tags : set[str]
|
||||
Tags of the device.
|
||||
disable_cache : bool
|
||||
Disable cache for this device.
|
||||
|
||||
Attributes:
|
||||
host (IPvAnyAddress): IPv4 or IPv6 address of the device
|
||||
port (int): (Optional) eAPI port to use Default is 443.
|
||||
name (str): (Optional) Name to display during tests report. Default is hostname:port
|
||||
tags (list[str]): List of attached tags read from inventory file.
|
||||
disable_cache (bool): Disable cache per host. Defaults to False.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: Optional[str] = None
|
||||
host: Union[constr(pattern=RFC_1123_REGEX), IPvAnyAddress] # type: ignore
|
||||
port: Optional[conint(gt=1, lt=65535)] = None # type: ignore
|
||||
tags: Optional[List[str]] = None
|
||||
name: str | None = None
|
||||
host: Hostname | IPvAnyAddress
|
||||
port: Port | None = None
|
||||
tags: set[str] | None = None
|
||||
disable_cache: bool = False
|
||||
|
||||
|
||||
class AntaInventoryNetwork(BaseModel):
|
||||
"""
|
||||
Network definition for user's inventory.
|
||||
"""Network entry of AntaInventoryInput.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
network : IPvAnyNetwork
|
||||
Subnet to use for scanning.
|
||||
tags : set[str]
|
||||
Tags of the devices in this network.
|
||||
disable_cache : bool
|
||||
Disable cache for all devices in this network.
|
||||
|
||||
Attributes:
|
||||
network (IPvAnyNetwork): Subnet to use for testing.
|
||||
tags (list[str]): List of attached tags read from inventory file.
|
||||
disable_cache (bool): Disable cache per network. Defaults to False.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
network: IPvAnyNetwork
|
||||
tags: Optional[List[str]] = None
|
||||
tags: set[str] | None = None
|
||||
disable_cache: bool = False
|
||||
|
||||
|
||||
class AntaInventoryRange(BaseModel):
|
||||
"""
|
||||
IP Range definition for user's inventory.
|
||||
"""IP Range entry of AntaInventoryInput.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
start : IPvAnyAddress
|
||||
IPv4 or IPv6 address for the beginning of the range.
|
||||
stop : IPvAnyAddress
|
||||
IPv4 or IPv6 address for the end of the range.
|
||||
tags : set[str]
|
||||
Tags of the devices in this IP range.
|
||||
disable_cache : bool
|
||||
Disable cache for all devices in this IP range.
|
||||
|
||||
Attributes:
|
||||
start (IPvAnyAddress): IPv4 or IPv6 address for the begining of the range.
|
||||
stop (IPvAnyAddress): IPv4 or IPv6 address for the end of the range.
|
||||
tags (list[str]): List of attached tags read from inventory file.
|
||||
disable_cache (bool): Disable cache per range of hosts. Defaults to False.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
start: IPvAnyAddress
|
||||
end: IPvAnyAddress
|
||||
tags: Optional[List[str]] = None
|
||||
tags: set[str] | None = None
|
||||
disable_cache: bool = False
|
||||
|
||||
|
||||
class AntaInventoryInput(BaseModel):
|
||||
"""
|
||||
User's inventory model.
|
||||
|
||||
Attributes:
|
||||
networks (list[AntaInventoryNetwork],Optional): List of AntaInventoryNetwork objects for networks.
|
||||
hosts (list[AntaInventoryHost],Optional): List of AntaInventoryHost objects for hosts.
|
||||
range (list[AntaInventoryRange],Optional): List of AntaInventoryRange objects for ranges.
|
||||
"""
|
||||
"""Device inventory input model."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
networks: Optional[List[AntaInventoryNetwork]] = None
|
||||
hosts: Optional[List[AntaInventoryHost]] = None
|
||||
ranges: Optional[List[AntaInventoryRange]] = None
|
||||
networks: list[AntaInventoryNetwork] | None = None
|
||||
hosts: list[AntaInventoryHost] | None = None
|
||||
ranges: list[AntaInventoryRange] | None = None
|
||||
|
||||
def yaml(self) -> str:
|
||||
"""Return a YAML representation string of this model.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The YAML representation string of this model.
|
||||
"""
|
||||
# TODO: Pydantic and YAML serialization/deserialization is not supported natively.
|
||||
# This could be improved.
|
||||
# https://github.com/pydantic/pydantic/issues/1043
|
||||
# Explore if this worth using this: https://github.com/NowanIlfideme/pydantic-yaml
|
||||
return yaml.safe_dump(yaml.safe_load(self.model_dump_json(serialize_as_any=True, exclude_unset=True)), indent=2, width=math.inf)
|
||||
|
|
|
@ -1,26 +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.
|
||||
"""
|
||||
Configure logging for ANTA
|
||||
"""
|
||||
"""Configure logging for ANTA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import timedelta
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Literal, Optional
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from rich.logging import RichHandler
|
||||
|
||||
from anta import __DEBUG__
|
||||
from anta.tools.misc import exc_to_str
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Log(str, Enum):
|
||||
"""Represent log levels from logging module as immutable strings"""
|
||||
"""Represent log levels from logging module as immutable strings."""
|
||||
|
||||
CRITICAL = logging.getLevelName(logging.CRITICAL)
|
||||
ERROR = logging.getLevelName(logging.ERROR)
|
||||
|
@ -33,8 +35,8 @@ LogLevel = Literal[Log.CRITICAL, Log.ERROR, Log.WARNING, Log.INFO, Log.DEBUG]
|
|||
|
||||
|
||||
def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None:
|
||||
"""
|
||||
Configure logging for ANTA.
|
||||
"""Configure logging for ANTA.
|
||||
|
||||
By default, the logging level is INFO for all loggers except for httpx and asyncssh which are too verbose:
|
||||
their logging level is WARNING.
|
||||
|
||||
|
@ -47,13 +49,17 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None:
|
|||
If a file is provided and logging level is DEBUG, only the logging level INFO and higher will
|
||||
be logged to stdout while all levels will be logged in the file.
|
||||
|
||||
Args:
|
||||
level: ANTA logging level
|
||||
file: Send logs to a file
|
||||
Parameters
|
||||
----------
|
||||
level
|
||||
ANTA logging level
|
||||
file
|
||||
Send logs to a file
|
||||
|
||||
"""
|
||||
# Init root logger
|
||||
root = logging.getLogger()
|
||||
# In ANTA debug mode, level is overriden to DEBUG
|
||||
# In ANTA debug mode, level is overridden to DEBUG
|
||||
loglevel = logging.DEBUG if __DEBUG__ else getattr(logging, level.upper())
|
||||
root.setLevel(loglevel)
|
||||
# Silence the logging of chatty Python modules when level is INFO
|
||||
|
@ -64,44 +70,60 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None:
|
|||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
# Add RichHandler for stdout
|
||||
richHandler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False)
|
||||
# In ANTA debug mode, show Python module in stdout
|
||||
if __DEBUG__:
|
||||
fmt_string = r"[grey58]\[%(name)s][/grey58] %(message)s"
|
||||
else:
|
||||
fmt_string = "%(message)s"
|
||||
rich_handler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False)
|
||||
# Show Python module in stdout at DEBUG level
|
||||
fmt_string = "[grey58]\\[%(name)s][/grey58] %(message)s" if loglevel == logging.DEBUG else "%(message)s"
|
||||
formatter = logging.Formatter(fmt=fmt_string, datefmt="[%X]")
|
||||
richHandler.setFormatter(formatter)
|
||||
root.addHandler(richHandler)
|
||||
rich_handler.setFormatter(formatter)
|
||||
root.addHandler(rich_handler)
|
||||
# Add FileHandler if file is provided
|
||||
if file:
|
||||
fileHandler = logging.FileHandler(file)
|
||||
file_handler = logging.FileHandler(file)
|
||||
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
fileHandler.setFormatter(formatter)
|
||||
root.addHandler(fileHandler)
|
||||
file_handler.setFormatter(formatter)
|
||||
root.addHandler(file_handler)
|
||||
# If level is DEBUG and file is provided, do not send DEBUG level to stdout
|
||||
if loglevel == logging.DEBUG:
|
||||
richHandler.setLevel(logging.INFO)
|
||||
rich_handler.setLevel(logging.INFO)
|
||||
|
||||
if __DEBUG__:
|
||||
logger.debug("ANTA Debug Mode enabled")
|
||||
|
||||
|
||||
def anta_log_exception(exception: BaseException, message: Optional[str] = None, calling_logger: Optional[logging.Logger] = None) -> None:
|
||||
"""
|
||||
Helper function to help log exceptions:
|
||||
* if anta.__DEBUG__ is True then the logger.exception method is called to get the traceback
|
||||
* otherwise logger.error is called
|
||||
def format_td(seconds: float, digits: int = 3) -> str:
|
||||
"""Return a formatted string from a float number representing seconds and a number of digits."""
|
||||
isec, fsec = divmod(round(seconds * 10**digits), 10**digits)
|
||||
return f"{timedelta(seconds=isec)}.{fsec:0{digits}.0f}"
|
||||
|
||||
Args:
|
||||
exception (BAseException): The Exception being logged
|
||||
message (str): An optional message
|
||||
calling_logger (logging.Logger): A logger to which the exception should be logged
|
||||
if not present, the logger in this file is used.
|
||||
|
||||
def exc_to_str(exception: BaseException) -> str:
|
||||
"""Return a human readable string from an BaseException object."""
|
||||
return f"{type(exception).__name__}{f': {exception}' if str(exception) else ''}"
|
||||
|
||||
|
||||
def anta_log_exception(exception: BaseException, message: str | None = None, calling_logger: logging.Logger | None = None) -> None:
|
||||
"""Log exception.
|
||||
|
||||
If `anta.__DEBUG__` is True then the `logger.exception` method is called to get the traceback, otherwise `logger.error` is called.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
exception
|
||||
The Exception being logged.
|
||||
message
|
||||
An optional message.
|
||||
calling_logger
|
||||
A logger to which the exception should be logged. If not present, the logger in this file is used.
|
||||
|
||||
"""
|
||||
if calling_logger is None:
|
||||
calling_logger = logger
|
||||
calling_logger.critical(f"{message}\n{exc_to_str(exception)}" if message else exc_to_str(exception))
|
||||
if __DEBUG__:
|
||||
calling_logger.exception(f"[ANTA Debug Mode]{f' {message}' if message else ''}", exc_info=exception)
|
||||
msg = f"[ANTA Debug Mode]{f' {message}' if message else ''}"
|
||||
calling_logger.exception(msg, exc_info=exception)
|
||||
|
||||
|
||||
def tb_to_str(exception: BaseException) -> str:
|
||||
"""Return a traceback string from an BaseException object."""
|
||||
return "Traceback (most recent call last):\n" + "".join(traceback.format_tb(exception.__traceback__))
|
||||
|
|
599
anta/models.py
599
anta/models.py
|
@ -1,102 +1,135 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Models to define a TestStructure
|
||||
"""
|
||||
"""Models to define a TestStructure."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from copy import deepcopy
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
from string import Formatter
|
||||
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, TypeVar
|
||||
|
||||
# Need to keep Dict and List for pydantic in python 3.8
|
||||
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Coroutine, Dict, List, Literal, Optional, TypeVar, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError, conint
|
||||
from rich.progress import Progress, TaskID
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError, create_model
|
||||
|
||||
from anta import GITHUB_SUGGESTION
|
||||
from anta.logger import anta_log_exception
|
||||
from anta.result_manager.models import TestResult
|
||||
from anta.tools.misc import exc_to_str
|
||||
from anta.constants import KNOWN_EOS_ERRORS
|
||||
from anta.custom_types import REGEXP_EOS_BLACKLIST_CMDS, Revision
|
||||
from anta.logger import anta_log_exception, exc_to_str
|
||||
from anta.result_manager.models import AntaTestStatus, TestResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Coroutine
|
||||
|
||||
from rich.progress import Progress, TaskID
|
||||
|
||||
from anta.device import AntaDevice
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
# Proper way to type input class - revisit this later if we get any issue @gmuloc
|
||||
# This would imply overhead to define classes
|
||||
# https://stackoverflow.com/questions/74103528/type-hinting-an-instance-of-a-nested-class
|
||||
# N = TypeVar("N", bound="AntaTest.Input")
|
||||
|
||||
|
||||
# TODO - make this configurable - with an env var maybe?
|
||||
BLACKLIST_REGEX = [r"^reload.*", r"^conf\w*\s*(terminal|session)*", r"^wr\w*\s*\w+"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AntaMissingParamException(Exception):
|
||||
"""
|
||||
This Exception should be used when an expected key in an AntaCommand.params dictionary
|
||||
was not found.
|
||||
class AntaParamsBaseModel(BaseModel):
|
||||
"""Extends BaseModel and overwrite __getattr__ to return None on missing attribute."""
|
||||
|
||||
This Exception should in general never be raised in normal usage of ANTA.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
self.message = "\n".join([message, GITHUB_SUGGESTION])
|
||||
super().__init__(self.message)
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AntaTemplate(BaseModel):
|
||||
class AntaTemplate:
|
||||
"""Class to define a command template as Python f-string.
|
||||
|
||||
Can render a command from parameters.
|
||||
|
||||
Attributes:
|
||||
template: Python f-string. Example: 'show vlan {vlan_id}'
|
||||
version: eAPI version - valid values are 1 or "latest" - default is "latest"
|
||||
revision: Revision of the command. Valid values are 1 to 99. Revision has precedence over version.
|
||||
ofmt: eAPI output - json or text - default is json
|
||||
use_cache: Enable or disable caching for this AntaTemplate if the AntaDevice supports it - default is True
|
||||
Attributes
|
||||
----------
|
||||
template
|
||||
Python f-string. Example: 'show vlan {vlan_id}'.
|
||||
version
|
||||
eAPI version - valid values are 1 or "latest".
|
||||
revision
|
||||
Revision of the command. Valid values are 1 to 99. Revision has precedence over version.
|
||||
ofmt
|
||||
eAPI output - json or text.
|
||||
use_cache
|
||||
Enable or disable caching for this AntaTemplate if the AntaDevice supports it.
|
||||
"""
|
||||
|
||||
template: str
|
||||
version: Literal[1, "latest"] = "latest"
|
||||
revision: Optional[conint(ge=1, le=99)] = None # type: ignore
|
||||
ofmt: Literal["json", "text"] = "json"
|
||||
use_cache: bool = True
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
def render(self, **params: dict[str, Any]) -> AntaCommand:
|
||||
def __init__(
|
||||
self,
|
||||
template: str,
|
||||
version: Literal[1, "latest"] = "latest",
|
||||
revision: Revision | None = None,
|
||||
ofmt: Literal["json", "text"] = "json",
|
||||
*,
|
||||
use_cache: bool = True,
|
||||
) -> None:
|
||||
self.template = template
|
||||
self.version = version
|
||||
self.revision = revision
|
||||
self.ofmt = ofmt
|
||||
self.use_cache = use_cache
|
||||
|
||||
# Create a AntaTemplateParams model to elegantly store AntaTemplate variables
|
||||
field_names = [fname for _, fname, _, _ in Formatter().parse(self.template) if fname]
|
||||
# Extracting the type from the params based on the expected field_names from the template
|
||||
fields: dict[str, Any] = {key: (Any, ...) for key in field_names}
|
||||
self.params_schema = create_model(
|
||||
"AntaParams",
|
||||
__base__=AntaParamsBaseModel,
|
||||
**fields,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return the representation of the class.
|
||||
|
||||
Copying pydantic model style, excluding `params_schema`
|
||||
"""
|
||||
return " ".join(f"{a}={v!r}" for a, v in vars(self).items() if a != "params_schema")
|
||||
|
||||
def render(self, **params: str | int | bool) -> AntaCommand:
|
||||
"""Render an AntaCommand from an AntaTemplate instance.
|
||||
|
||||
Keep the parameters used in the AntaTemplate instance.
|
||||
|
||||
Args:
|
||||
params: dictionary of variables with string values to render the Python f-string
|
||||
Parameters
|
||||
----------
|
||||
params
|
||||
Dictionary of variables with string values to render the Python f-string.
|
||||
|
||||
Returns:
|
||||
command: The rendered AntaCommand.
|
||||
This AntaCommand instance have a template attribute that references this
|
||||
AntaTemplate instance.
|
||||
Returns
|
||||
-------
|
||||
AntaCommand
|
||||
The rendered AntaCommand.
|
||||
This AntaCommand instance have a template attribute that references this
|
||||
AntaTemplate instance.
|
||||
|
||||
Raises
|
||||
------
|
||||
AntaTemplateRenderError
|
||||
If a parameter is missing to render the AntaTemplate instance.
|
||||
"""
|
||||
try:
|
||||
return AntaCommand(
|
||||
command=self.template.format(**params),
|
||||
ofmt=self.ofmt,
|
||||
version=self.version,
|
||||
revision=self.revision,
|
||||
template=self,
|
||||
params=params,
|
||||
use_cache=self.use_cache,
|
||||
)
|
||||
except KeyError as e:
|
||||
command = self.template.format(**params)
|
||||
except (KeyError, SyntaxError) as e:
|
||||
raise AntaTemplateRenderError(self, e.args[0]) from e
|
||||
return AntaCommand(
|
||||
command=command,
|
||||
ofmt=self.ofmt,
|
||||
version=self.version,
|
||||
revision=self.revision,
|
||||
template=self,
|
||||
params=self.params_schema(**params),
|
||||
use_cache=self.use_cache,
|
||||
)
|
||||
|
||||
|
||||
class AntaCommand(BaseModel):
|
||||
|
@ -113,70 +146,147 @@ class AntaCommand(BaseModel):
|
|||
|
||||
__Revision has precedence over version.__
|
||||
|
||||
Attributes:
|
||||
command: Device command
|
||||
version: eAPI version - valid values are 1 or "latest" - default is "latest"
|
||||
revision: eAPI revision of the command. Valid values are 1 to 99. Revision has precedence over version.
|
||||
ofmt: eAPI output - json or text - default is json
|
||||
output: Output of the command populated by the collect() function
|
||||
template: AntaTemplate object used to render this command
|
||||
params: Dictionary of variables with string values to render the template
|
||||
errors: If the command execution fails, eAPI returns a list of strings detailing the error
|
||||
use_cache: Enable or disable caching for this AntaCommand if the AntaDevice supports it - default is True
|
||||
Attributes
|
||||
----------
|
||||
command
|
||||
Device command.
|
||||
version
|
||||
eAPI version - valid values are 1 or "latest".
|
||||
revision
|
||||
eAPI revision of the command. Valid values are 1 to 99. Revision has precedence over version.
|
||||
ofmt
|
||||
eAPI output - json or text.
|
||||
output
|
||||
Output of the command. Only defined if there was no errors.
|
||||
template
|
||||
AntaTemplate object used to render this command.
|
||||
errors
|
||||
If the command execution fails, eAPI returns a list of strings detailing the error(s).
|
||||
params
|
||||
Pydantic Model containing the variables values used to render the template.
|
||||
use_cache
|
||||
Enable or disable caching for this AntaCommand if the AntaDevice supports it.
|
||||
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
command: str
|
||||
version: Literal[1, "latest"] = "latest"
|
||||
revision: Optional[conint(ge=1, le=99)] = None # type: ignore
|
||||
revision: Revision | None = None
|
||||
ofmt: Literal["json", "text"] = "json"
|
||||
output: Optional[Union[Dict[str, Any], str]] = None
|
||||
template: Optional[AntaTemplate] = None
|
||||
errors: List[str] = []
|
||||
params: Dict[str, Any] = {}
|
||||
output: dict[str, Any] | str | None = None
|
||||
template: AntaTemplate | None = None
|
||||
errors: list[str] = []
|
||||
params: AntaParamsBaseModel = AntaParamsBaseModel()
|
||||
use_cache: bool = True
|
||||
|
||||
@property
|
||||
def uid(self) -> str:
|
||||
"""Generate a unique identifier for this command"""
|
||||
"""Generate a unique identifier for this command."""
|
||||
uid_str = f"{self.command}_{self.version}_{self.revision or 'NA'}_{self.ofmt}"
|
||||
return hashlib.sha1(uid_str.encode()).hexdigest()
|
||||
# Ignoring S324 probable use of insecure hash function - sha1 is enough for our needs.
|
||||
return hashlib.sha1(uid_str.encode()).hexdigest() # noqa: S324
|
||||
|
||||
@property
|
||||
def json_output(self) -> dict[str, Any]:
|
||||
"""Get the command output as JSON"""
|
||||
"""Get the command output as JSON."""
|
||||
if self.output is None:
|
||||
raise RuntimeError(f"There is no output for command {self.command}")
|
||||
msg = f"There is no output for command '{self.command}'"
|
||||
raise RuntimeError(msg)
|
||||
if self.ofmt != "json" or not isinstance(self.output, dict):
|
||||
raise RuntimeError(f"Output of command {self.command} is invalid")
|
||||
msg = f"Output of command '{self.command}' is invalid"
|
||||
raise RuntimeError(msg)
|
||||
return dict(self.output)
|
||||
|
||||
@property
|
||||
def text_output(self) -> str:
|
||||
"""Get the command output as a string"""
|
||||
"""Get the command output as a string."""
|
||||
if self.output is None:
|
||||
raise RuntimeError(f"There is no output for command {self.command}")
|
||||
msg = f"There is no output for command '{self.command}'"
|
||||
raise RuntimeError(msg)
|
||||
if self.ofmt != "text" or not isinstance(self.output, str):
|
||||
raise RuntimeError(f"Output of command {self.command} is invalid")
|
||||
msg = f"Output of command '{self.command}' is invalid"
|
||||
raise RuntimeError(msg)
|
||||
return str(self.output)
|
||||
|
||||
@property
|
||||
def error(self) -> bool:
|
||||
"""Return True if the command returned an error, False otherwise."""
|
||||
return len(self.errors) > 0
|
||||
|
||||
@property
|
||||
def collected(self) -> bool:
|
||||
"""Return True if the command has been collected"""
|
||||
return self.output is not None and not self.errors
|
||||
"""Return True if the command has been collected, False otherwise.
|
||||
|
||||
A command that has not been collected could have returned an error.
|
||||
See error property.
|
||||
"""
|
||||
return not self.error and self.output is not None
|
||||
|
||||
@property
|
||||
def requires_privileges(self) -> bool:
|
||||
"""Return True if the command requires privileged mode, False otherwise.
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError
|
||||
If the command has not been collected and has not returned an error.
|
||||
AntaDevice.collect() must be called before this property.
|
||||
"""
|
||||
if not self.collected and not self.error:
|
||||
msg = f"Command '{self.command}' has not been collected and has not returned an error. Call AntaDevice.collect()."
|
||||
raise RuntimeError(msg)
|
||||
return any("privileged mode required" in e for e in self.errors)
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Indicates if the command is supported on the device.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if the command is supported on the device hardware platform, False otherwise.
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError
|
||||
If the command has not been collected and has not returned an error.
|
||||
AntaDevice.collect() must be called before this property.
|
||||
"""
|
||||
if not self.collected and not self.error:
|
||||
msg = f"Command '{self.command}' has not been collected and has not returned an error. Call AntaDevice.collect()."
|
||||
|
||||
raise RuntimeError(msg)
|
||||
return all("not supported on this hardware platform" not in e for e in self.errors)
|
||||
|
||||
@property
|
||||
def returned_known_eos_error(self) -> bool:
|
||||
"""Return True if the command returned a known_eos_error on the device, False otherwise.
|
||||
|
||||
RuntimeError
|
||||
If the command has not been collected and has not returned an error.
|
||||
AntaDevice.collect() must be called before this property.
|
||||
"""
|
||||
if not self.collected and not self.error:
|
||||
msg = f"Command '{self.command}' has not been collected and has not returned an error. Call AntaDevice.collect()."
|
||||
raise RuntimeError(msg)
|
||||
return any(any(re.match(pattern, e) for e in self.errors) for pattern in KNOWN_EOS_ERRORS)
|
||||
|
||||
|
||||
class AntaTemplateRenderError(RuntimeError):
|
||||
"""
|
||||
Raised when an AntaTemplate object could not be rendered
|
||||
because of missing parameters
|
||||
"""
|
||||
"""Raised when an AntaTemplate object could not be rendered because of missing parameters."""
|
||||
|
||||
def __init__(self, template: AntaTemplate, key: str):
|
||||
"""Constructor for AntaTemplateRenderError
|
||||
def __init__(self, template: AntaTemplate, key: str) -> None:
|
||||
"""Initialize an AntaTemplateRenderError.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
template
|
||||
The AntaTemplate instance that failed to render.
|
||||
key
|
||||
Key that has not been provided to render the template.
|
||||
|
||||
Args:
|
||||
template: The AntaTemplate instance that failed to render
|
||||
key: Key that has not been provided to render the template
|
||||
"""
|
||||
self.template = template
|
||||
self.key = key
|
||||
|
@ -184,17 +294,17 @@ class AntaTemplateRenderError(RuntimeError):
|
|||
|
||||
|
||||
class AntaTest(ABC):
|
||||
"""Abstract class defining a test in ANTA
|
||||
"""Abstract class defining a test in ANTA.
|
||||
|
||||
The goal of this class is to handle the heavy lifting and make
|
||||
writing a test as simple as possible.
|
||||
|
||||
Examples:
|
||||
Examples
|
||||
--------
|
||||
The following is an example of an AntaTest subclass implementation:
|
||||
```python
|
||||
class VerifyReachability(AntaTest):
|
||||
name = "VerifyReachability"
|
||||
description = "Test the network reachability to one or many destination IP(s)."
|
||||
'''Test the network reachability to one or many destination IP(s).'''
|
||||
categories = ["connectivity"]
|
||||
commands = [AntaTemplate(template="ping vrf {vrf} {dst} source {src} repeat 2")]
|
||||
|
||||
|
@ -206,14 +316,13 @@ class AntaTest(ABC):
|
|||
vrf: str = "default"
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
return [template.render({"dst": host.dst, "src": host.src, "vrf": host.vrf}) for host in self.inputs.hosts]
|
||||
return [template.render(dst=host.dst, src=host.src, vrf=host.vrf) for host in self.inputs.hosts]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
failures = []
|
||||
for command in self.instance_commands:
|
||||
if command.params and ("src" and "dst") in command.params:
|
||||
src, dst = command.params["src"], command.params["dst"]
|
||||
src, dst = command.params.src, command.params.dst
|
||||
if "2 received" not in command.json_output["messages"][0]:
|
||||
failures.append((str(src), str(dst)))
|
||||
if not failures:
|
||||
|
@ -221,28 +330,43 @@ class AntaTest(ABC):
|
|||
else:
|
||||
self.result.is_failure(f"Connectivity test failed for the following source-destination pairs: {failures}")
|
||||
```
|
||||
Attributes:
|
||||
device: AntaDevice instance on which this test is run
|
||||
inputs: AntaTest.Input instance carrying the test inputs
|
||||
instance_commands: List of AntaCommand instances of this test
|
||||
result: TestResult instance representing the result of this test
|
||||
logger: Python logger for this test instance
|
||||
|
||||
Attributes
|
||||
----------
|
||||
device
|
||||
AntaDevice instance on which this test is run.
|
||||
inputs
|
||||
AntaTest.Input instance carrying the test inputs.
|
||||
instance_commands
|
||||
List of AntaCommand instances of this test.
|
||||
result
|
||||
TestResult instance representing the result of this test.
|
||||
logger
|
||||
Python logger for this test instance.
|
||||
"""
|
||||
|
||||
# Mandatory class attributes
|
||||
# TODO - find a way to tell mypy these are mandatory for child classes - maybe Protocol
|
||||
# Optional class attributes
|
||||
name: ClassVar[str]
|
||||
description: ClassVar[str]
|
||||
__removal_in_version: ClassVar[str]
|
||||
"""Internal class variable set by the `deprecated_test_class` decorator."""
|
||||
|
||||
# Mandatory class attributes
|
||||
# TODO: find a way to tell mypy these are mandatory for child classes
|
||||
# follow this https://discuss.python.org/t/provide-a-canonical-way-to-declare-an-abstract-class-variable/69416
|
||||
# for now only enforced at runtime with __init_subclass__
|
||||
categories: ClassVar[list[str]]
|
||||
commands: ClassVar[list[Union[AntaTemplate, AntaCommand]]]
|
||||
commands: ClassVar[list[AntaTemplate | AntaCommand]]
|
||||
|
||||
# Class attributes to handle the progress bar of ANTA CLI
|
||||
progress: Optional[Progress] = None
|
||||
nrfu_task: Optional[TaskID] = None
|
||||
progress: Progress | None = None
|
||||
nrfu_task: TaskID | None = None
|
||||
|
||||
class Input(BaseModel):
|
||||
"""Class defining inputs for a test in ANTA.
|
||||
|
||||
Examples:
|
||||
Examples
|
||||
--------
|
||||
A valid test catalog will look like the following:
|
||||
```yaml
|
||||
<Python module>:
|
||||
|
@ -253,74 +377,94 @@ class AntaTest(ABC):
|
|||
description: "Test with overwritten description"
|
||||
custom_field: "Test run by John Doe"
|
||||
```
|
||||
Attributes:
|
||||
result_overwrite: Define fields to overwrite in the TestResult object
|
||||
|
||||
Attributes
|
||||
----------
|
||||
result_overwrite
|
||||
Define fields to overwrite in the TestResult object.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
result_overwrite: Optional[ResultOverwrite] = None
|
||||
filters: Optional[Filters] = None
|
||||
result_overwrite: ResultOverwrite | None = None
|
||||
filters: Filters | None = None
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""
|
||||
Implement generic hashing for AntaTest.Input.
|
||||
"""Implement generic hashing for AntaTest.Input.
|
||||
|
||||
This will work in most cases but this does not consider 2 lists with different ordering as equal.
|
||||
"""
|
||||
return hash(self.model_dump_json())
|
||||
|
||||
class ResultOverwrite(BaseModel):
|
||||
"""Test inputs model to overwrite result fields
|
||||
"""Test inputs model to overwrite result fields.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
description
|
||||
Overwrite `TestResult.description`.
|
||||
categories
|
||||
Overwrite `TestResult.categories`.
|
||||
custom_field
|
||||
A free string that will be included in the TestResult object.
|
||||
|
||||
Attributes:
|
||||
description: overwrite TestResult.description
|
||||
categories: overwrite TestResult.categories
|
||||
custom_field: a free string that will be included in the TestResult object
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
description: Optional[str] = None
|
||||
categories: Optional[List[str]] = None
|
||||
custom_field: Optional[str] = None
|
||||
description: str | None = None
|
||||
categories: list[str] | None = None
|
||||
custom_field: str | None = None
|
||||
|
||||
class Filters(BaseModel):
|
||||
"""Runtime filters to map tests with list of tags or devices
|
||||
"""Runtime filters to map tests with list of tags or devices.
|
||||
|
||||
Attributes:
|
||||
tags: List of device's tags for the test.
|
||||
Attributes
|
||||
----------
|
||||
tags
|
||||
Tag of devices on which to run the test.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
tags: Optional[List[str]] = None
|
||||
tags: set[str] | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: AntaDevice,
|
||||
inputs: dict[str, Any] | AntaTest.Input | None = None,
|
||||
eos_data: list[dict[Any, Any] | str] | None = None,
|
||||
):
|
||||
"""AntaTest Constructor
|
||||
) -> None:
|
||||
"""AntaTest Constructor.
|
||||
|
||||
Args:
|
||||
device: AntaDevice instance on which the test will be run
|
||||
inputs: dictionary of attributes used to instantiate the AntaTest.Input instance
|
||||
eos_data: Populate outputs of the test commands instead of collecting from devices.
|
||||
This list must have the same length and order than the `instance_commands` instance attribute.
|
||||
Parameters
|
||||
----------
|
||||
device
|
||||
AntaDevice instance on which the test will be run.
|
||||
inputs
|
||||
Dictionary of attributes used to instantiate the AntaTest.Input instance.
|
||||
eos_data
|
||||
Populate outputs of the test commands instead of collecting from devices.
|
||||
This list must have the same length and order than the `instance_commands` instance attribute.
|
||||
"""
|
||||
self.logger: logging.Logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}")
|
||||
self.logger: logging.Logger = logging.getLogger(f"{self.module}.{self.__class__.__name__}")
|
||||
self.device: AntaDevice = device
|
||||
self.inputs: AntaTest.Input
|
||||
self.instance_commands: list[AntaCommand] = []
|
||||
self.result: TestResult = TestResult(name=device.name, test=self.name, categories=self.categories, description=self.description)
|
||||
self.result: TestResult = TestResult(
|
||||
name=device.name,
|
||||
test=self.name,
|
||||
categories=self.categories,
|
||||
description=self.description,
|
||||
)
|
||||
self._init_inputs(inputs)
|
||||
if self.result.result == "unset":
|
||||
if self.result.result == AntaTestStatus.UNSET:
|
||||
self._init_commands(eos_data)
|
||||
|
||||
def _init_inputs(self, inputs: dict[str, Any] | AntaTest.Input | None) -> None:
|
||||
"""Instantiate the `inputs` instance attribute with an `AntaTest.Input` instance
|
||||
to validate test inputs from defined model.
|
||||
"""Instantiate the `inputs` instance attribute with an `AntaTest.Input` instance to validate test inputs using the model.
|
||||
|
||||
Overwrite result fields based on `ResultOverwrite` input definition.
|
||||
|
||||
Any input validation error will set this test result status as 'error'."""
|
||||
Any input validation error will set this test result status as 'error'.
|
||||
"""
|
||||
try:
|
||||
if inputs is None:
|
||||
self.inputs = self.Input()
|
||||
|
@ -329,7 +473,7 @@ class AntaTest(ABC):
|
|||
elif isinstance(inputs, dict):
|
||||
self.inputs = self.Input(**inputs)
|
||||
except ValidationError as e:
|
||||
message = f"{self.__module__}.{self.__class__.__name__}: Inputs are not valid\n{e}"
|
||||
message = f"{self.module}.{self.name}: Inputs are not valid\n{e}"
|
||||
self.logger.error(message)
|
||||
self.result.is_error(message=message)
|
||||
return
|
||||
|
@ -340,10 +484,11 @@ class AntaTest(ABC):
|
|||
self.result.description = res_ow.description
|
||||
self.result.custom_field = res_ow.custom_field
|
||||
|
||||
def _init_commands(self, eos_data: Optional[list[dict[Any, Any] | str]]) -> None:
|
||||
def _init_commands(self, eos_data: list[dict[Any, Any] | str] | None) -> None:
|
||||
"""Instantiate the `instance_commands` instance attribute from the `commands` class attribute.
|
||||
|
||||
- Copy of the `AntaCommand` instances
|
||||
- Render all `AntaTemplate` instances using the `render()` method
|
||||
- Render all `AntaTemplate` instances using the `render()` method.
|
||||
|
||||
Any template rendering error will set this test result status as 'error'.
|
||||
Any exception in user code in `render()` will set this test result status as 'error'.
|
||||
|
@ -351,7 +496,7 @@ class AntaTest(ABC):
|
|||
if self.__class__.commands:
|
||||
for cmd in self.__class__.commands:
|
||||
if isinstance(cmd, AntaCommand):
|
||||
self.instance_commands.append(deepcopy(cmd))
|
||||
self.instance_commands.append(cmd.model_copy())
|
||||
elif isinstance(cmd, AntaTemplate):
|
||||
try:
|
||||
self.instance_commands.extend(self.render(cmd))
|
||||
|
@ -361,21 +506,21 @@ class AntaTest(ABC):
|
|||
except NotImplementedError as e:
|
||||
self.result.is_error(message=e.args[0])
|
||||
return
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
except Exception as e: # noqa: BLE001
|
||||
# render() is user-defined code.
|
||||
# We need to catch everything if we want the AntaTest object
|
||||
# to live until the reporting
|
||||
message = f"Exception in {self.__module__}.{self.__class__.__name__}.render()"
|
||||
message = f"Exception in {self.module}.{self.__class__.__name__}.render()"
|
||||
anta_log_exception(e, message, self.logger)
|
||||
self.result.is_error(message=f"{message}: {exc_to_str(e)}")
|
||||
return
|
||||
|
||||
if eos_data is not None:
|
||||
self.logger.debug(f"Test {self.name} initialized with input data")
|
||||
self.logger.debug("Test %s initialized with input data", self.name)
|
||||
self.save_commands_data(eos_data)
|
||||
|
||||
def save_commands_data(self, eos_data: list[dict[str, Any] | str]) -> None:
|
||||
"""Populate output of all AntaCommand instances in `instance_commands`"""
|
||||
"""Populate output of all AntaCommand instances in `instance_commands`."""
|
||||
if len(eos_data) > len(self.instance_commands):
|
||||
self.result.is_error(message="Test initialization error: Trying to save more data than there are commands for the test")
|
||||
return
|
||||
|
@ -386,50 +531,67 @@ class AntaTest(ABC):
|
|||
self.instance_commands[index].output = data
|
||||
|
||||
def __init_subclass__(cls) -> None:
|
||||
"""Verify that the mandatory class attributes are defined"""
|
||||
mandatory_attributes = ["name", "description", "categories", "commands"]
|
||||
for attr in mandatory_attributes:
|
||||
if not hasattr(cls, attr):
|
||||
raise NotImplementedError(f"Class {cls.__module__}.{cls.__name__} is missing required class attribute {attr}")
|
||||
"""Verify that the mandatory class attributes are defined and set name and description if not set."""
|
||||
mandatory_attributes = ["categories", "commands"]
|
||||
if missing_attrs := [attr for attr in mandatory_attributes if not hasattr(cls, attr)]:
|
||||
msg = f"Class {cls.__module__}.{cls.__name__} is missing required class attribute(s): {', '.join(missing_attrs)}"
|
||||
raise AttributeError(msg)
|
||||
|
||||
cls.name = getattr(cls, "name", cls.__name__)
|
||||
if not hasattr(cls, "description"):
|
||||
if not cls.__doc__ or cls.__doc__.strip() == "":
|
||||
# No doctsring or empty doctsring - raise
|
||||
msg = f"Cannot set the description for class {cls.name}, either set it in the class definition or add a docstring to the class."
|
||||
raise AttributeError(msg)
|
||||
cls.description = cls.__doc__.split(sep="\n", maxsplit=1)[0]
|
||||
|
||||
@property
|
||||
def module(self) -> str:
|
||||
"""Return the Python module in which this AntaTest class is defined."""
|
||||
return self.__module__
|
||||
|
||||
@property
|
||||
def collected(self) -> bool:
|
||||
"""Returns True if all commands for this test have been collected."""
|
||||
"""Return True if all commands for this test have been collected."""
|
||||
return all(command.collected for command in self.instance_commands)
|
||||
|
||||
@property
|
||||
def failed_commands(self) -> list[AntaCommand]:
|
||||
"""Returns a list of all the commands that have failed."""
|
||||
return [command for command in self.instance_commands if command.errors]
|
||||
"""Return a list of all the commands that have failed."""
|
||||
return [command for command in self.instance_commands if command.error]
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render an AntaTemplate instance of this AntaTest using the provided
|
||||
AntaTest.Input instance at self.inputs.
|
||||
"""Render an AntaTemplate instance of this AntaTest using the provided AntaTest.Input instance at self.inputs.
|
||||
|
||||
This is not an abstract method because it does not need to be implemented if there is
|
||||
no AntaTemplate for this test."""
|
||||
raise NotImplementedError(f"AntaTemplate are provided but render() method has not been implemented for {self.__module__}.{self.name}")
|
||||
no AntaTemplate for this test.
|
||||
"""
|
||||
_ = template
|
||||
msg = f"AntaTemplate are provided but render() method has not been implemented for {self.module}.{self.__class__.__name__}"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
@property
|
||||
def blocked(self) -> bool:
|
||||
"""Check if CLI commands contain a blocked keyword."""
|
||||
state = False
|
||||
for command in self.instance_commands:
|
||||
for pattern in BLACKLIST_REGEX:
|
||||
for pattern in REGEXP_EOS_BLACKLIST_CMDS:
|
||||
if re.match(pattern, command.command):
|
||||
self.logger.error(f"Command <{command.command}> is blocked for security reason matching {BLACKLIST_REGEX}")
|
||||
self.logger.error(
|
||||
"Command <%s> is blocked for security reason matching %s",
|
||||
command.command,
|
||||
REGEXP_EOS_BLACKLIST_CMDS,
|
||||
)
|
||||
self.result.is_error(f"<{command.command}> is blocked for security reason")
|
||||
state = True
|
||||
return state
|
||||
|
||||
async def collect(self) -> None:
|
||||
"""
|
||||
Method used to collect outputs of all commands of this test class from the device of this test instance.
|
||||
"""
|
||||
"""Collect outputs of all commands of this test class from the device of this test instance."""
|
||||
try:
|
||||
if self.blocked is False:
|
||||
await self.device.collect_commands(self.instance_commands)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
await self.device.collect_commands(self.instance_commands, collection_id=self.name)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# device._collect() is user-defined code.
|
||||
# We need to catch everything if we want the AntaTest object
|
||||
# to live until the reporting
|
||||
|
@ -439,8 +601,7 @@ class AntaTest(ABC):
|
|||
|
||||
@staticmethod
|
||||
def anta_test(function: F) -> Callable[..., Coroutine[Any, Any, TestResult]]:
|
||||
"""
|
||||
Decorator for the `test()` method.
|
||||
"""Decorate the `test()` method in child classes.
|
||||
|
||||
This decorator implements (in this order):
|
||||
|
||||
|
@ -454,50 +615,50 @@ class AntaTest(ABC):
|
|||
async def wrapper(
|
||||
self: AntaTest,
|
||||
eos_data: list[dict[Any, Any] | str] | None = None,
|
||||
**kwargs: Any,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> TestResult:
|
||||
"""Inner function for the anta_test decorator.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
self
|
||||
The test instance.
|
||||
eos_data
|
||||
Populate outputs of the test commands instead of collecting from devices.
|
||||
This list must have the same length and order than the `instance_commands` instance attribute.
|
||||
kwargs
|
||||
Any keyword argument to pass to the test.
|
||||
|
||||
Returns
|
||||
-------
|
||||
TestResult
|
||||
The TestResult instance attribute populated with error status if any.
|
||||
|
||||
"""
|
||||
Args:
|
||||
eos_data: Populate outputs of the test commands instead of collecting from devices.
|
||||
This list must have the same length and order than the `instance_commands` instance attribute.
|
||||
|
||||
Returns:
|
||||
result: TestResult instance attribute populated with error status if any
|
||||
"""
|
||||
|
||||
def format_td(seconds: float, digits: int = 3) -> str:
|
||||
isec, fsec = divmod(round(seconds * 10**digits), 10**digits)
|
||||
return f"{timedelta(seconds=isec)}.{fsec:0{digits}.0f}"
|
||||
|
||||
start_time = time.time()
|
||||
if self.result.result != "unset":
|
||||
return self.result
|
||||
|
||||
# Data
|
||||
if eos_data is not None:
|
||||
self.save_commands_data(eos_data)
|
||||
self.logger.debug(f"Test {self.name} initialized with input data {eos_data}")
|
||||
self.logger.debug("Test %s initialized with input data %s", self.name, eos_data)
|
||||
|
||||
# If some data is missing, try to collect
|
||||
if not self.collected:
|
||||
await self.collect()
|
||||
if self.result.result != "unset":
|
||||
AntaTest.update_progress()
|
||||
return self.result
|
||||
|
||||
if cmds := self.failed_commands:
|
||||
self.logger.debug(self.device.supports)
|
||||
unsupported_commands = [f"Skipped because {c.command} is not supported on {self.device.hw_model}" for c in cmds if not self.device.supports(c)]
|
||||
self.logger.debug(unsupported_commands)
|
||||
if unsupported_commands:
|
||||
self.logger.warning(f"Test {self.name} has been skipped because it is not supported on {self.device.hw_model}: {GITHUB_SUGGESTION}")
|
||||
self.result.is_skipped("\n".join(unsupported_commands))
|
||||
return self.result
|
||||
self.result.is_error(message="\n".join([f"{c.command} has failed: {', '.join(c.errors)}" for c in cmds]))
|
||||
if self.failed_commands:
|
||||
self._handle_failed_commands()
|
||||
|
||||
AntaTest.update_progress()
|
||||
return self.result
|
||||
|
||||
try:
|
||||
function(self, **kwargs)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
except Exception as e: # noqa: BLE001
|
||||
# test() is user-defined code.
|
||||
# We need to catch everything if we want the AntaTest object
|
||||
# to live until the reporting
|
||||
|
@ -505,30 +666,49 @@ class AntaTest(ABC):
|
|||
anta_log_exception(e, message, self.logger)
|
||||
self.result.is_error(message=exc_to_str(e))
|
||||
|
||||
test_duration = time.time() - start_time
|
||||
self.logger.debug(f"Executing test {self.name} on device {self.device.name} took {format_td(test_duration)}")
|
||||
|
||||
# TODO: find a correct way to time test execution
|
||||
AntaTest.update_progress()
|
||||
return self.result
|
||||
|
||||
return wrapper
|
||||
|
||||
def _handle_failed_commands(self) -> None:
|
||||
"""Handle failed commands inside a test.
|
||||
|
||||
There can be 3 types:
|
||||
* unsupported on hardware commands which set the test status to 'skipped'
|
||||
* known EOS error which set the test status to 'failure'
|
||||
* unknown failure which set the test status to 'error'
|
||||
"""
|
||||
cmds = self.failed_commands
|
||||
unsupported_commands = [f"'{c.command}' is not supported on {self.device.hw_model}" for c in cmds if not c.supported]
|
||||
if unsupported_commands:
|
||||
msg = f"Test {self.name} has been skipped because it is not supported on {self.device.hw_model}: {GITHUB_SUGGESTION}"
|
||||
self.logger.warning(msg)
|
||||
self.result.is_skipped("\n".join(unsupported_commands))
|
||||
return
|
||||
returned_known_eos_error = [f"'{c.command}' failed on {self.device.name}: {', '.join(c.errors)}" for c in cmds if c.returned_known_eos_error]
|
||||
if returned_known_eos_error:
|
||||
self.result.is_failure("\n".join(returned_known_eos_error))
|
||||
return
|
||||
|
||||
self.result.is_error(message="\n".join([f"{c.command} has failed: {', '.join(c.errors)}" for c in cmds]))
|
||||
|
||||
@classmethod
|
||||
def update_progress(cls) -> None:
|
||||
"""
|
||||
Update progress bar for all AntaTest objects if it exists
|
||||
"""
|
||||
def update_progress(cls: type[AntaTest]) -> None:
|
||||
"""Update progress bar for all AntaTest objects if it exists."""
|
||||
if cls.progress and (cls.nrfu_task is not None):
|
||||
cls.progress.update(cls.nrfu_task, advance=1)
|
||||
|
||||
@abstractmethod
|
||||
def test(self) -> Coroutine[Any, Any, TestResult]:
|
||||
"""
|
||||
This abstract method is the core of the test logic.
|
||||
It must set the correct status of the `result` instance attribute
|
||||
with the appropriate outcome of the test.
|
||||
"""Core of the test logic.
|
||||
|
||||
Examples:
|
||||
This is an abstractmethod that must be implemented by child classes.
|
||||
It must set the correct status of the `result` instance attribute with the appropriate outcome of the test.
|
||||
|
||||
Examples
|
||||
--------
|
||||
It must be implemented using the `AntaTest.anta_test` decorator:
|
||||
```python
|
||||
@AntaTest.anta_test
|
||||
|
@ -536,6 +716,7 @@ class AntaTest(ABC):
|
|||
self.result.is_success()
|
||||
for command in self.instance_commands:
|
||||
if not self._test_command(command): # _test_command() is an arbitrary test logic
|
||||
self.result.is_failure("Failure reson")
|
||||
self.result.is_failure("Failure reason")
|
||||
```
|
||||
|
||||
"""
|
||||
|
|
0
anta/py.typed
Normal file
0
anta/py.typed
Normal file
|
@ -1,23 +1,26 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Report management for ANTA.
|
||||
"""
|
||||
"""Report management for ANTA."""
|
||||
|
||||
# pylint: disable = too-few-public-methods
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os.path
|
||||
import pathlib
|
||||
from typing import Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from jinja2 import Template
|
||||
from rich.table import Table
|
||||
|
||||
from anta import RICH_COLOR_PALETTE, RICH_COLOR_THEME
|
||||
from anta.custom_types import TestStatus
|
||||
from anta.result_manager import ResultManager
|
||||
from anta.tools import convert_categories
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pathlib
|
||||
|
||||
from anta.result_manager import ResultManager
|
||||
from anta.result_manager.models import AntaTestStatus, TestResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -25,185 +28,201 @@ logger = logging.getLogger(__name__)
|
|||
class ReportTable:
|
||||
"""TableReport Generate a Table based on TestResult."""
|
||||
|
||||
def _split_list_to_txt_list(self, usr_list: list[str], delimiter: Optional[str] = None) -> str:
|
||||
"""
|
||||
Split list to multi-lines string
|
||||
@dataclass()
|
||||
class Headers: # pylint: disable=too-many-instance-attributes
|
||||
"""Headers for the table report."""
|
||||
|
||||
Args:
|
||||
usr_list (list[str]): List of string to concatenate
|
||||
delimiter (str, optional): A delimiter to use to start string. Defaults to None.
|
||||
device: str = "Device"
|
||||
test_case: str = "Test Name"
|
||||
number_of_success: str = "# of success"
|
||||
number_of_failure: str = "# of failure"
|
||||
number_of_skipped: str = "# of skipped"
|
||||
number_of_errors: str = "# of errors"
|
||||
list_of_error_nodes: str = "List of failed or error nodes"
|
||||
list_of_error_tests: str = "List of failed or error test cases"
|
||||
|
||||
def _split_list_to_txt_list(self, usr_list: list[str], delimiter: str | None = None) -> str:
|
||||
"""Split list to multi-lines string.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
usr_list : list[str]
|
||||
List of string to concatenate.
|
||||
delimiter : str, optional
|
||||
A delimiter to use to start string. Defaults to None.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Multi-lines string.
|
||||
|
||||
Returns:
|
||||
str: Multi-lines string
|
||||
"""
|
||||
if delimiter is not None:
|
||||
return "\n".join(f"{delimiter} {line}" for line in usr_list)
|
||||
return "\n".join(f"{line}" for line in usr_list)
|
||||
|
||||
def _build_headers(self, headers: list[str], table: Table) -> Table:
|
||||
"""
|
||||
Create headers for a table.
|
||||
"""Create headers for a table.
|
||||
|
||||
First key is considered as header and is colored using RICH_COLOR_PALETTE.HEADER
|
||||
|
||||
Args:
|
||||
headers (list[str]): List of headers
|
||||
table (Table): A rich Table instance
|
||||
Parameters
|
||||
----------
|
||||
headers
|
||||
List of headers.
|
||||
table
|
||||
A rich Table instance.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Table
|
||||
A rich `Table` instance with headers.
|
||||
|
||||
Returns:
|
||||
Table: A rich Table instance with headers
|
||||
"""
|
||||
for idx, header in enumerate(headers):
|
||||
if idx == 0:
|
||||
table.add_column(header, justify="left", style=RICH_COLOR_PALETTE.HEADER, no_wrap=True)
|
||||
elif header == "Test Name":
|
||||
# We always want the full test name
|
||||
table.add_column(header, justify="left", no_wrap=True)
|
||||
else:
|
||||
table.add_column(header, justify="left")
|
||||
return table
|
||||
|
||||
def _color_result(self, status: TestStatus) -> str:
|
||||
"""
|
||||
Return a colored string based on the status value.
|
||||
def _color_result(self, status: AntaTestStatus) -> str:
|
||||
"""Return a colored string based on an AntaTestStatus.
|
||||
|
||||
Args:
|
||||
status (TestStatus): status value to color
|
||||
Parameters
|
||||
----------
|
||||
status
|
||||
AntaTestStatus enum to color.
|
||||
|
||||
Returns:
|
||||
str: the colored string
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The colored string.
|
||||
"""
|
||||
color = RICH_COLOR_THEME.get(status, "")
|
||||
color = RICH_COLOR_THEME.get(str(status), "")
|
||||
return f"[{color}]{status}" if color != "" else str(status)
|
||||
|
||||
def report_all(
|
||||
self,
|
||||
result_manager: ResultManager,
|
||||
host: Optional[str] = None,
|
||||
testcase: Optional[str] = None,
|
||||
title: str = "All tests results",
|
||||
) -> Table:
|
||||
"""
|
||||
Create a table report with all tests for one or all devices.
|
||||
def report_all(self, manager: ResultManager, title: str = "All tests results") -> Table:
|
||||
"""Create a table report with all tests for one or all devices.
|
||||
|
||||
Create table with full output: Host / Test / Status / Message
|
||||
Create table with full output: Device | Test Name | Test Status | Message(s) | Test description | Test category
|
||||
|
||||
Args:
|
||||
result_manager (ResultManager): A manager with a list of tests.
|
||||
host (str, optional): IP Address of a host to search for. Defaults to None.
|
||||
testcase (str, optional): A test name to search for. Defaults to None.
|
||||
title (str, optional): Title for the report. Defaults to 'All tests results'.
|
||||
Parameters
|
||||
----------
|
||||
manager
|
||||
A ResultManager instance.
|
||||
title
|
||||
Title for the report. Defaults to 'All tests results'.
|
||||
|
||||
Returns:
|
||||
Table: A fully populated rich Table
|
||||
Returns
|
||||
-------
|
||||
Table
|
||||
A fully populated rich `Table`.
|
||||
"""
|
||||
table = Table(title=title, show_lines=True)
|
||||
headers = ["Device", "Test Name", "Test Status", "Message(s)", "Test description", "Test category"]
|
||||
table = self._build_headers(headers=headers, table=table)
|
||||
|
||||
for result in result_manager.get_results():
|
||||
# pylint: disable=R0916
|
||||
if (host is None and testcase is None) or (host is not None and str(result.name) == host) or (testcase is not None and testcase == str(result.test)):
|
||||
state = self._color_result(result.result)
|
||||
message = self._split_list_to_txt_list(result.messages) if len(result.messages) > 0 else ""
|
||||
categories = ", ".join(result.categories)
|
||||
table.add_row(str(result.name), result.test, state, message, result.description, categories)
|
||||
def add_line(result: TestResult) -> None:
|
||||
state = self._color_result(result.result)
|
||||
message = self._split_list_to_txt_list(result.messages) if len(result.messages) > 0 else ""
|
||||
categories = ", ".join(convert_categories(result.categories))
|
||||
table.add_row(str(result.name), result.test, state, message, result.description, categories)
|
||||
|
||||
for result in manager.results:
|
||||
add_line(result)
|
||||
return table
|
||||
|
||||
def report_summary_tests(
|
||||
self,
|
||||
result_manager: ResultManager,
|
||||
testcase: Optional[str] = None,
|
||||
title: str = "Summary per test case",
|
||||
manager: ResultManager,
|
||||
tests: list[str] | None = None,
|
||||
title: str = "Summary per test",
|
||||
) -> Table:
|
||||
"""Create a table report with result aggregated per test.
|
||||
|
||||
Create table with full output:
|
||||
Test Name | # of success | # of skipped | # of failure | # of errors | List of failed or error nodes
|
||||
|
||||
Parameters
|
||||
----------
|
||||
manager
|
||||
A ResultManager instance.
|
||||
tests
|
||||
List of test names to include. None to select all tests.
|
||||
title
|
||||
Title of the report.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Table
|
||||
A fully populated rich `Table`.
|
||||
"""
|
||||
Create a table report with result agregated per test.
|
||||
|
||||
Create table with full output: Test / Number of success / Number of failure / Number of error / List of nodes in error or failure
|
||||
|
||||
Args:
|
||||
result_manager (ResultManager): A manager with a list of tests.
|
||||
testcase (str, optional): A test name to search for. Defaults to None.
|
||||
title (str, optional): Title for the report. Defaults to 'All tests results'.
|
||||
|
||||
Returns:
|
||||
Table: A fully populated rich Table
|
||||
"""
|
||||
# sourcery skip: class-extract-method
|
||||
table = Table(title=title, show_lines=True)
|
||||
headers = [
|
||||
"Test Case",
|
||||
"# of success",
|
||||
"# of skipped",
|
||||
"# of failure",
|
||||
"# of errors",
|
||||
"List of failed or error nodes",
|
||||
self.Headers.test_case,
|
||||
self.Headers.number_of_success,
|
||||
self.Headers.number_of_skipped,
|
||||
self.Headers.number_of_failure,
|
||||
self.Headers.number_of_errors,
|
||||
self.Headers.list_of_error_nodes,
|
||||
]
|
||||
table = self._build_headers(headers=headers, table=table)
|
||||
for testcase_read in result_manager.get_testcases():
|
||||
if testcase is None or str(testcase_read) == testcase:
|
||||
results = result_manager.get_result_by_test(testcase_read)
|
||||
nb_failure = len([result for result in results if result.result == "failure"])
|
||||
nb_error = len([result for result in results if result.result == "error"])
|
||||
list_failure = [str(result.name) for result in results if result.result in ["failure", "error"]]
|
||||
nb_success = len([result for result in results if result.result == "success"])
|
||||
nb_skipped = len([result for result in results if result.result == "skipped"])
|
||||
for test, stats in sorted(manager.test_stats.items()):
|
||||
if tests is None or test in tests:
|
||||
table.add_row(
|
||||
testcase_read,
|
||||
str(nb_success),
|
||||
str(nb_skipped),
|
||||
str(nb_failure),
|
||||
str(nb_error),
|
||||
str(list_failure),
|
||||
test,
|
||||
str(stats.devices_success_count),
|
||||
str(stats.devices_skipped_count),
|
||||
str(stats.devices_failure_count),
|
||||
str(stats.devices_error_count),
|
||||
", ".join(stats.devices_failure),
|
||||
)
|
||||
return table
|
||||
|
||||
def report_summary_hosts(
|
||||
def report_summary_devices(
|
||||
self,
|
||||
result_manager: ResultManager,
|
||||
host: Optional[str] = None,
|
||||
title: str = "Summary per host",
|
||||
manager: ResultManager,
|
||||
devices: list[str] | None = None,
|
||||
title: str = "Summary per device",
|
||||
) -> Table:
|
||||
"""
|
||||
Create a table report with result agregated per host.
|
||||
"""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:
|
||||
result_manager (ResultManager): A manager with a list of tests.
|
||||
host (str, optional): IP Address of a host to search for. Defaults to None.
|
||||
title (str, optional): Title for the report. Defaults to 'All tests results'.
|
||||
Parameters
|
||||
----------
|
||||
manager
|
||||
A ResultManager instance.
|
||||
devices
|
||||
List of device names to include. None to select all devices.
|
||||
title
|
||||
Title of the report.
|
||||
|
||||
Returns:
|
||||
Table: A fully populated rich Table
|
||||
Returns
|
||||
-------
|
||||
Table
|
||||
A fully populated rich `Table`.
|
||||
"""
|
||||
table = Table(title=title, show_lines=True)
|
||||
headers = [
|
||||
"Device",
|
||||
"# of success",
|
||||
"# of skipped",
|
||||
"# of failure",
|
||||
"# of errors",
|
||||
"List of failed or error test cases",
|
||||
self.Headers.device,
|
||||
self.Headers.number_of_success,
|
||||
self.Headers.number_of_skipped,
|
||||
self.Headers.number_of_failure,
|
||||
self.Headers.number_of_errors,
|
||||
self.Headers.list_of_error_tests,
|
||||
]
|
||||
table = self._build_headers(headers=headers, table=table)
|
||||
for host_read in result_manager.get_hosts():
|
||||
if host is None or str(host_read) == host:
|
||||
results = result_manager.get_result_by_host(host_read)
|
||||
logger.debug("data to use for computation")
|
||||
logger.debug(f"{host}: {results}")
|
||||
nb_failure = len([result for result in results if result.result == "failure"])
|
||||
nb_error = len([result for result in results if result.result == "error"])
|
||||
list_failure = [str(result.test) for result in results if result.result in ["failure", "error"]]
|
||||
nb_success = len([result for result in results if result.result == "success"])
|
||||
nb_skipped = len([result for result in results if result.result == "skipped"])
|
||||
for device, stats in sorted(manager.device_stats.items()):
|
||||
if devices is None or device in devices:
|
||||
table.add_row(
|
||||
str(host_read),
|
||||
str(nb_success),
|
||||
str(nb_skipped),
|
||||
str(nb_failure),
|
||||
str(nb_error),
|
||||
str(list_failure),
|
||||
device,
|
||||
str(stats.tests_success_count),
|
||||
str(stats.tests_skipped_count),
|
||||
str(stats.tests_failure_count),
|
||||
str(stats.tests_error_count),
|
||||
", ".join(stats.tests_failure),
|
||||
)
|
||||
return table
|
||||
|
||||
|
@ -212,20 +231,23 @@ class ReportJinja:
|
|||
"""Report builder based on a Jinja2 template."""
|
||||
|
||||
def __init__(self, template_path: pathlib.Path) -> None:
|
||||
if os.path.isfile(template_path):
|
||||
self.tempalte_path = template_path
|
||||
else:
|
||||
raise FileNotFoundError(f"template file is not found: {template_path}")
|
||||
"""Create a ReportJinja instance."""
|
||||
if not template_path.is_file():
|
||||
msg = f"template file is not found: {template_path}"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
def render(self, data: list[dict[str, Any]], trim_blocks: bool = True, lstrip_blocks: bool = True) -> str:
|
||||
"""
|
||||
Build a report based on a Jinja2 template
|
||||
self.template_path = template_path
|
||||
|
||||
def render(self, data: list[dict[str, Any]], *, trim_blocks: bool = True, lstrip_blocks: bool = True) -> str:
|
||||
"""Build a report based on a Jinja2 template.
|
||||
|
||||
Report is built based on a J2 template provided by user.
|
||||
Data structure sent to template is:
|
||||
|
||||
>>> data = ResultManager.get_json_results()
|
||||
>>> print(data)
|
||||
Example
|
||||
-------
|
||||
```
|
||||
>>> print(ResultManager.json)
|
||||
[
|
||||
{
|
||||
name: ...,
|
||||
|
@ -236,16 +258,24 @@ class ReportJinja:
|
|||
description: ...,
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Args:
|
||||
data (list[dict[str, Any]]): List of results from ResultManager.get_results
|
||||
trim_blocks (bool, optional): enable trim_blocks for J2 rendering. Defaults to True.
|
||||
lstrip_blocks (bool, optional): enable lstrip_blocks for J2 rendering. Defaults to True.
|
||||
Parameters
|
||||
----------
|
||||
data
|
||||
List of results from `ResultManager.results`.
|
||||
trim_blocks
|
||||
enable trim_blocks for J2 rendering.
|
||||
lstrip_blocks
|
||||
enable lstrip_blocks for J2 rendering.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Rendered template
|
||||
|
||||
Returns:
|
||||
str: rendered template
|
||||
"""
|
||||
with open(self.tempalte_path, encoding="utf-8") as file_:
|
||||
with self.template_path.open(encoding="utf-8") as file_:
|
||||
template = Template(file_.read(), trim_blocks=trim_blocks, lstrip_blocks=lstrip_blocks)
|
||||
|
||||
return template.render({"data": data})
|
||||
|
|
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)
|
|
@ -1,73 +1,89 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Result Manager Module for ANTA.
|
||||
"""
|
||||
"""Result Manager module for ANTA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from functools import cached_property
|
||||
from itertools import chain
|
||||
from typing import Any
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from anta.result_manager.models import AntaTestStatus, TestResult
|
||||
|
||||
from anta.custom_types import TestStatus
|
||||
from anta.result_manager.models import TestResult
|
||||
from .models import CategoryStats, DeviceStats, TestStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class ResultManager:
|
||||
"""
|
||||
Helper to manage Test Results and generate reports.
|
||||
"""Helper to manage Test Results and generate reports.
|
||||
|
||||
Examples:
|
||||
Examples
|
||||
--------
|
||||
Create Inventory:
|
||||
|
||||
Create Inventory:
|
||||
inventory_anta = AntaInventory.parse(
|
||||
filename='examples/inventory.yml',
|
||||
username='ansible',
|
||||
password='ansible',
|
||||
)
|
||||
|
||||
inventory_anta = AntaInventory.parse(
|
||||
filename='examples/inventory.yml',
|
||||
username='ansible',
|
||||
password='ansible',
|
||||
timeout=0.5
|
||||
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()
|
||||
|
||||
Run tests for all connected devices:
|
||||
|
||||
for device in inventory_anta.get_inventory():
|
||||
manager.add_test_result(
|
||||
VerifyNTP(device=device).test()
|
||||
)
|
||||
manager.add_test_result(
|
||||
VerifyEOSVersion(device=device).test(version='4.28.3M')
|
||||
)
|
||||
|
||||
Print result in native format:
|
||||
|
||||
manager.get_results()
|
||||
[
|
||||
TestResult(
|
||||
host=IPv4Address('192.168.0.10'),
|
||||
test='VerifyNTP',
|
||||
result='failure',
|
||||
message="device is not running NTP correctly"
|
||||
),
|
||||
TestResult(
|
||||
host=IPv4Address('192.168.0.10'),
|
||||
test='VerifyEOSVersion',
|
||||
result='success',
|
||||
message=None
|
||||
),
|
||||
]
|
||||
manager.results
|
||||
[
|
||||
TestResult(
|
||||
name="pf1",
|
||||
test="VerifyZeroTouch",
|
||||
categories=["configuration"],
|
||||
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:
|
||||
"""
|
||||
Class constructor.
|
||||
"""Class constructor.
|
||||
|
||||
The status of the class is initialized to "unset"
|
||||
|
||||
|
@ -87,125 +103,287 @@ class ResultManager:
|
|||
If the status of the added test is error, the status is untouched and the
|
||||
error_status is set to True.
|
||||
"""
|
||||
self.reset()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Create or reset the attributes of the ResultManager instance."""
|
||||
self._result_entries: list[TestResult] = []
|
||||
# Initialize status
|
||||
self.status: TestStatus = "unset"
|
||||
self.status: AntaTestStatus = AntaTestStatus.UNSET
|
||||
self.error_status = False
|
||||
|
||||
# Initialize the statistics attributes
|
||||
self._reset_stats()
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""
|
||||
Implement __len__ method to count number of results.
|
||||
"""
|
||||
"""Implement __len__ method to count number of results."""
|
||||
return len(self._result_entries)
|
||||
|
||||
def _update_status(self, test_status: TestStatus) -> None:
|
||||
@property
|
||||
def results(self) -> list[TestResult]:
|
||||
"""Get the list of TestResult."""
|
||||
return self._result_entries
|
||||
|
||||
@results.setter
|
||||
def results(self, value: list[TestResult]) -> None:
|
||||
"""Set the list of TestResult."""
|
||||
# When setting the results, we need to reset the state of the current instance
|
||||
self.reset()
|
||||
|
||||
for result in value:
|
||||
self.add(result)
|
||||
|
||||
@property
|
||||
def dump(self) -> list[dict[str, Any]]:
|
||||
"""Get a list of dictionary of the results."""
|
||||
return [result.model_dump() for result in self._result_entries]
|
||||
|
||||
@property
|
||||
def json(self) -> str:
|
||||
"""Get a JSON representation of the results."""
|
||||
return json.dumps(self.dump, indent=4)
|
||||
|
||||
@property
|
||||
def device_stats(self) -> defaultdict[str, DeviceStats]:
|
||||
"""Get the device statistics."""
|
||||
self._ensure_stats_in_sync()
|
||||
return self._device_stats
|
||||
|
||||
@property
|
||||
def category_stats(self) -> defaultdict[str, CategoryStats]:
|
||||
"""Get the category statistics."""
|
||||
self._ensure_stats_in_sync()
|
||||
return self._category_stats
|
||||
|
||||
@property
|
||||
def test_stats(self) -> defaultdict[str, TestStats]:
|
||||
"""Get the test statistics."""
|
||||
self._ensure_stats_in_sync()
|
||||
return self._test_stats
|
||||
|
||||
@property
|
||||
def sorted_category_stats(self) -> dict[str, CategoryStats]:
|
||||
"""A property that returns the category_stats dictionary sorted by key name."""
|
||||
self._ensure_stats_in_sync()
|
||||
return dict(sorted(self.category_stats.items()))
|
||||
|
||||
@cached_property
|
||||
def results_by_status(self) -> dict[AntaTestStatus, list[TestResult]]:
|
||||
"""A cached property that returns the results grouped by status."""
|
||||
return {status: [result for result in self._result_entries if result.result == status] for status in AntaTestStatus}
|
||||
|
||||
def _update_status(self, test_status: AntaTestStatus) -> None:
|
||||
"""Update the status of the ResultManager instance based on the test status.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
test_status
|
||||
AntaTestStatus to update the ResultManager status.
|
||||
"""
|
||||
Update ResultManager status based on the table above.
|
||||
"""
|
||||
ResultValidator = TypeAdapter(TestStatus)
|
||||
ResultValidator.validate_python(test_status)
|
||||
if test_status == "error":
|
||||
self.error_status = True
|
||||
return
|
||||
if self.status == "unset":
|
||||
self.status = test_status
|
||||
elif self.status == "skipped" and test_status in {"success", "failure"}:
|
||||
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.status = AntaTestStatus.FAILURE
|
||||
|
||||
def add_test_result(self, entry: TestResult) -> None:
|
||||
"""Add a result to the list
|
||||
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
|
||||
|
||||
Args:
|
||||
entry (TestResult): TestResult data to add to the report
|
||||
def _update_stats(self, result: TestResult) -> None:
|
||||
"""Update the statistics based on the test result.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
result
|
||||
TestResult to update the statistics.
|
||||
"""
|
||||
logger.debug(entry)
|
||||
self._result_entries.append(entry)
|
||||
self._update_status(entry.result)
|
||||
count_attr = f"tests_{result.result}_count"
|
||||
|
||||
def add_test_results(self, entries: list[TestResult]) -> None:
|
||||
"""Add a list of results to the list
|
||||
# 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)
|
||||
|
||||
Args:
|
||||
entries (list[TestResult]): List of TestResult data to add to the report
|
||||
"""
|
||||
for e in entries:
|
||||
self.add_test_result(e)
|
||||
# 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)
|
||||
|
||||
def get_status(self, ignore_error: bool = False) -> str:
|
||||
# Update test stats
|
||||
count_attr = f"devices_{result.result}_count"
|
||||
test_stats: TestStats = self._test_stats[result.test]
|
||||
setattr(test_stats, count_attr, getattr(test_stats, count_attr) + 1)
|
||||
if result.result in ("failure", "error"):
|
||||
test_stats.devices_failure.add(result.name)
|
||||
|
||||
def _compute_stats(self) -> None:
|
||||
"""Compute all statistics from the current results."""
|
||||
logger.info("Computing statistics for all results.")
|
||||
|
||||
# Reset all stats
|
||||
self._reset_stats()
|
||||
|
||||
# Recompute stats for all results
|
||||
for result in self._result_entries:
|
||||
self._update_stats(result)
|
||||
|
||||
self._stats_in_sync = True
|
||||
|
||||
def _ensure_stats_in_sync(self) -> None:
|
||||
"""Ensure statistics are in sync with current results."""
|
||||
if not self._stats_in_sync:
|
||||
self._compute_stats()
|
||||
|
||||
def add(self, result: TestResult) -> None:
|
||||
"""Add a result to the ResultManager instance.
|
||||
|
||||
The result is added to the internal list of results and the overall status
|
||||
of the ResultManager instance is updated based on the added test status.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
result
|
||||
TestResult to add to the ResultManager instance.
|
||||
"""
|
||||
Returns the current status including error_status if ignore_error is False
|
||||
self._result_entries.append(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:
|
||||
"""Return the current status including error_status if ignore_error is False."""
|
||||
return "error" if self.error_status and not ignore_error else self.status
|
||||
|
||||
def get_results(self) -> list[TestResult]:
|
||||
def filter(self, hide: set[AntaTestStatus]) -> ResultManager:
|
||||
"""Get a filtered ResultManager based on test status.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
hide
|
||||
Set of AntaTestStatus enum members to select tests to hide based on their status.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ResultManager
|
||||
A filtered `ResultManager`.
|
||||
"""
|
||||
Expose list of all test results in different format
|
||||
possible_statuses = set(AntaTestStatus)
|
||||
manager = ResultManager()
|
||||
manager.results = self.get_results(possible_statuses - hide)
|
||||
return manager
|
||||
|
||||
Returns:
|
||||
any: List of results.
|
||||
def filter_by_tests(self, tests: set[str]) -> ResultManager:
|
||||
"""Get a filtered ResultManager that only contains specific tests.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
tests
|
||||
Set of test names to filter the results.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ResultManager
|
||||
A filtered `ResultManager`.
|
||||
"""
|
||||
return self._result_entries
|
||||
manager = ResultManager()
|
||||
manager.results = [result for result in self._result_entries if result.test in tests]
|
||||
return manager
|
||||
|
||||
def get_json_results(self) -> str:
|
||||
def filter_by_devices(self, devices: set[str]) -> ResultManager:
|
||||
"""Get a filtered ResultManager that only contains specific devices.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
devices
|
||||
Set of device names to filter the results.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ResultManager
|
||||
A filtered `ResultManager`.
|
||||
"""
|
||||
Expose list of all test results in JSON
|
||||
manager = ResultManager()
|
||||
manager.results = [result for result in self._result_entries if result.name in devices]
|
||||
return manager
|
||||
|
||||
Returns:
|
||||
str: JSON dumps of the list of results
|
||||
def get_tests(self) -> set[str]:
|
||||
"""Get the set of all the test names.
|
||||
|
||||
Returns
|
||||
-------
|
||||
set[str]
|
||||
Set of test names.
|
||||
"""
|
||||
result = [result.model_dump() for result in self._result_entries]
|
||||
return json.dumps(result, indent=4)
|
||||
return {str(result.test) for result in self._result_entries}
|
||||
|
||||
def get_result_by_test(self, test_name: str) -> list[TestResult]:
|
||||
def get_devices(self) -> set[str]:
|
||||
"""Get the set of all the device names.
|
||||
|
||||
Returns
|
||||
-------
|
||||
set[str]
|
||||
Set of device names.
|
||||
"""
|
||||
Get list of test result for a given test.
|
||||
|
||||
Args:
|
||||
test_name (str): Test name to use to filter results
|
||||
output_format (str, optional): format selector. Can be either native/list. Defaults to 'native'.
|
||||
|
||||
Returns:
|
||||
list[TestResult]: List of results related to the test.
|
||||
"""
|
||||
return [result for result in self._result_entries if str(result.test) == test_name]
|
||||
|
||||
def get_result_by_host(self, host_ip: str) -> list[TestResult]:
|
||||
"""
|
||||
Get list of test result for a given host.
|
||||
|
||||
Args:
|
||||
host_ip (str): IP Address of the host to use to filter results.
|
||||
output_format (str, optional): format selector. Can be either native/list. Defaults to 'native'.
|
||||
|
||||
Returns:
|
||||
list[TestResult]: List of results related to the host.
|
||||
"""
|
||||
return [result for result in self._result_entries if str(result.name) == host_ip]
|
||||
|
||||
def get_testcases(self) -> list[str]:
|
||||
"""
|
||||
Get list of name of all test cases in current manager.
|
||||
|
||||
Returns:
|
||||
list[str]: List of names for all tests.
|
||||
"""
|
||||
result_list = []
|
||||
for testcase in self._result_entries:
|
||||
if str(testcase.test) not in result_list:
|
||||
result_list.append(str(testcase.test))
|
||||
return result_list
|
||||
|
||||
def get_hosts(self) -> list[str]:
|
||||
"""
|
||||
Get list of IP addresses in current manager.
|
||||
|
||||
Returns:
|
||||
list[str]: List of IP addresses.
|
||||
"""
|
||||
result_list = []
|
||||
for testcase in self._result_entries:
|
||||
if str(testcase.name) not in result_list:
|
||||
result_list.append(str(testcase.name))
|
||||
return result_list
|
||||
return {str(result.name) for result in self._result_entries}
|
||||
|
|
|
@ -2,85 +2,160 @@
|
|||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""Models related to anta.result_manager module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Need to keep List for pydantic in 3.8
|
||||
from typing import List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from anta.custom_types import TestStatus
|
||||
|
||||
class AntaTestStatus(str, Enum):
|
||||
"""Test status Enum for the TestResult.
|
||||
|
||||
NOTE: This could be updated to StrEnum when Python 3.11 is the minimum supported version in ANTA.
|
||||
"""
|
||||
|
||||
UNSET = "unset"
|
||||
SUCCESS = "success"
|
||||
FAILURE = "failure"
|
||||
ERROR = "error"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Override the __str__ method to return the value of the Enum, mimicking the behavior of StrEnum."""
|
||||
return self.value
|
||||
|
||||
|
||||
class TestResult(BaseModel):
|
||||
"""
|
||||
Describe the result of a test from a single device.
|
||||
"""Describe the result of a test from a single device.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
name : str
|
||||
Name of the device where the test was run.
|
||||
test : str
|
||||
Name of the test run on the device.
|
||||
categories : list[str]
|
||||
List of categories the TestResult belongs to. Defaults to the AntaTest categories.
|
||||
description : str
|
||||
Description of the TestResult. Defaults to the AntaTest description.
|
||||
result : AntaTestStatus
|
||||
Result of the test. Must be one of the AntaTestStatus Enum values: unset, success, failure, error or skipped.
|
||||
messages : list[str]
|
||||
Messages to report after the test, if any.
|
||||
custom_field : str | None
|
||||
Custom field to store a string for flexibility in integrating with ANTA.
|
||||
|
||||
Attributes:
|
||||
name: Device name where the test has run.
|
||||
test: Test name runs on the device.
|
||||
categories: List of categories the TestResult belongs to, by default the AntaTest categories.
|
||||
description: TestResult description, by default the AntaTest description.
|
||||
result: Result of the test. Can be one of "unset", "success", "failure", "error" or "skipped".
|
||||
messages: Message to report after the test if any.
|
||||
custom_field: Custom field to store a string for flexibility in integrating with ANTA
|
||||
"""
|
||||
|
||||
name: str
|
||||
test: str
|
||||
categories: List[str]
|
||||
categories: list[str]
|
||||
description: str
|
||||
result: TestStatus = "unset"
|
||||
messages: List[str] = []
|
||||
custom_field: Optional[str] = None
|
||||
result: AntaTestStatus = AntaTestStatus.UNSET
|
||||
messages: list[str] = []
|
||||
custom_field: str | None = None
|
||||
|
||||
def is_success(self, message: str | None = None) -> None:
|
||||
"""
|
||||
Helper to set status to success
|
||||
"""Set status to success.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
message
|
||||
Optional message related to the test.
|
||||
|
||||
Args:
|
||||
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:
|
||||
"""
|
||||
Helper to set status to failure
|
||||
"""Set status to failure.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
message
|
||||
Optional message related to the test.
|
||||
|
||||
Args:
|
||||
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:
|
||||
"""
|
||||
Helper to set status to skipped
|
||||
"""Set status to skipped.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
message
|
||||
Optional message related to the test.
|
||||
|
||||
Args:
|
||||
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:
|
||||
"""
|
||||
Helper to set status to error
|
||||
"""
|
||||
self._set_status("error", message)
|
||||
"""Set status to error.
|
||||
|
||||
def _set_status(self, status: TestStatus, message: str | None = None) -> None:
|
||||
"""
|
||||
Set status and insert optional message
|
||||
Parameters
|
||||
----------
|
||||
message
|
||||
Optional message related to the test.
|
||||
|
||||
"""
|
||||
self._set_status(AntaTestStatus.ERROR, message)
|
||||
|
||||
def _set_status(self, status: AntaTestStatus, message: str | None = None) -> None:
|
||||
"""Set status and insert optional message.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
status
|
||||
Status of the test.
|
||||
message
|
||||
Optional message.
|
||||
|
||||
Args:
|
||||
status: status of the test
|
||||
message: optional message
|
||||
"""
|
||||
self.result = status
|
||||
if message is not None:
|
||||
self.messages.append(message)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
Returns 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}"
|
||||
|
||||
|
||||
# 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)
|
||||
|
|
375
anta/runner.py
375
anta/runner.py
|
@ -1,109 +1,312 @@
|
|||
# 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.
|
||||
# pylint: disable=too-many-branches
|
||||
"""
|
||||
ANTA runner function
|
||||
"""
|
||||
"""ANTA runner function."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Tuple
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from anta import GITHUB_SUGGESTION
|
||||
from anta.catalog import AntaCatalog, AntaTestDefinition
|
||||
from anta.device import AntaDevice
|
||||
from anta.inventory import AntaInventory
|
||||
from anta.logger import anta_log_exception
|
||||
from anta.logger import anta_log_exception, exc_to_str
|
||||
from anta.models import AntaTest
|
||||
from anta.result_manager import ResultManager
|
||||
from anta.tools import Catchtime, cprofile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Coroutine
|
||||
|
||||
from anta.catalog import AntaCatalog, AntaTestDefinition
|
||||
from anta.device import AntaDevice
|
||||
from anta.inventory import AntaInventory
|
||||
from anta.result_manager import ResultManager
|
||||
from anta.result_manager.models import TestResult
|
||||
|
||||
if os.name == "posix":
|
||||
import resource
|
||||
|
||||
DEFAULT_NOFILE = 16384
|
||||
|
||||
def adjust_rlimit_nofile() -> tuple[int, int]:
|
||||
"""Adjust the maximum number of open file descriptors for the ANTA process.
|
||||
|
||||
The limit is set to the lower of the current hard limit and the value of the ANTA_NOFILE environment variable.
|
||||
|
||||
If the `ANTA_NOFILE` environment variable is not set or is invalid, `DEFAULT_NOFILE` is used.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[int, int]
|
||||
The new soft and hard limits for open file descriptors.
|
||||
"""
|
||||
try:
|
||||
nofile = int(os.environ.get("ANTA_NOFILE", DEFAULT_NOFILE))
|
||||
except ValueError as exception:
|
||||
logger.warning("The ANTA_NOFILE environment variable value is invalid: %s\nDefault to %s.", exc_to_str(exception), DEFAULT_NOFILE)
|
||||
nofile = DEFAULT_NOFILE
|
||||
|
||||
limits = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||
logger.debug("Initial limit numbers for open file descriptors for the current ANTA process: Soft Limit: %s | Hard Limit: %s", limits[0], limits[1])
|
||||
nofile = min(limits[1], nofile)
|
||||
logger.debug("Setting soft limit for open file descriptors for the current ANTA process to %s", nofile)
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (nofile, limits[1]))
|
||||
return resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AntaTestRunner = Tuple[AntaTestDefinition, AntaDevice]
|
||||
|
||||
def log_cache_statistics(devices: list[AntaDevice]) -> None:
|
||||
"""Log cache statistics for each device in the inventory.
|
||||
|
||||
async def main(manager: ResultManager, inventory: AntaInventory, catalog: AntaCatalog, tags: list[str] | None = None, established_only: bool = True) -> None:
|
||||
Parameters
|
||||
----------
|
||||
devices
|
||||
List of devices in the inventory.
|
||||
"""
|
||||
Main coroutine to run ANTA.
|
||||
Use this as an entrypoint to the test framwork in your script.
|
||||
|
||||
Args:
|
||||
manager: ResultManager object to populate with the test results.
|
||||
inventory: AntaInventory object that includes the device(s).
|
||||
catalog: AntaCatalog object that includes the list of tests.
|
||||
tags: List of tags to filter devices from the inventory. Defaults to None.
|
||||
established_only: Include only established device(s). Defaults to True.
|
||||
|
||||
Returns:
|
||||
any: ResultManager object gets updated with the test results.
|
||||
"""
|
||||
if not catalog.tests:
|
||||
logger.info("The list of tests is empty, exiting")
|
||||
return
|
||||
if len(inventory) == 0:
|
||||
logger.info("The inventory is empty, exiting")
|
||||
return
|
||||
await inventory.connect_inventory()
|
||||
devices: list[AntaDevice] = list(inventory.get_inventory(established_only=established_only, tags=tags).values())
|
||||
|
||||
if not devices:
|
||||
logger.info(
|
||||
f"No device in the established state '{established_only}' "
|
||||
f"{f'matching the tags {tags} ' if tags else ''}was found. There is no device to run tests against, exiting"
|
||||
)
|
||||
|
||||
return
|
||||
coros = []
|
||||
# Using a set to avoid inserting duplicate tests
|
||||
tests_set: set[AntaTestRunner] = set()
|
||||
for device in devices:
|
||||
if tags:
|
||||
# If there are CLI tags, only execute tests with matching tags
|
||||
tests_set.update((test, device) for test in catalog.get_tests_by_tags(tags))
|
||||
else:
|
||||
# If there is no CLI tags, execute all tests without filters
|
||||
tests_set.update((t, device) for t in catalog.tests if t.inputs.filters is None or t.inputs.filters.tags is None)
|
||||
|
||||
# Then add the tests with matching tags from device tags
|
||||
tests_set.update((t, device) for t in catalog.get_tests_by_tags(device.tags))
|
||||
|
||||
tests: list[AntaTestRunner] = list(tests_set)
|
||||
|
||||
if not tests:
|
||||
logger.info(f"There is no tests{f' matching the tags {tags} ' if tags else ' '}to run on current inventory. " "Exiting...")
|
||||
return
|
||||
|
||||
for test_definition, device in tests:
|
||||
try:
|
||||
test_instance = test_definition.test(device=device, inputs=test_definition.inputs)
|
||||
|
||||
coros.append(test_instance.test())
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
# An AntaTest instance is potentially user-defined code.
|
||||
# We need to catch everything and exit gracefully with an
|
||||
# error message
|
||||
message = "\n".join(
|
||||
[
|
||||
f"There is an error when creating test {test_definition.test.__module__}.{test_definition.test.__name__}.",
|
||||
f"If this is not a custom test implementation: {GITHUB_SUGGESTION}",
|
||||
]
|
||||
)
|
||||
anta_log_exception(e, message, logger)
|
||||
if AntaTest.progress is not None:
|
||||
AntaTest.nrfu_task = AntaTest.progress.add_task("Running NRFU Tests...", total=len(coros))
|
||||
|
||||
logger.info("Running ANTA tests...")
|
||||
test_results = await asyncio.gather(*coros)
|
||||
for r in test_results:
|
||||
manager.add_test_result(r)
|
||||
for device in devices:
|
||||
if device.cache_statistics is not None:
|
||||
logger.info(
|
||||
msg = (
|
||||
f"Cache statistics for '{device.name}': "
|
||||
f"{device.cache_statistics['cache_hits']} hits / {device.cache_statistics['total_commands_sent']} "
|
||||
f"command(s) ({device.cache_statistics['cache_hit_ratio']})"
|
||||
)
|
||||
logger.info(msg)
|
||||
else:
|
||||
logger.info(f"Caching is not enabled on {device.name}")
|
||||
logger.info("Caching is not enabled on %s", device.name)
|
||||
|
||||
|
||||
async def setup_inventory(inventory: AntaInventory, tags: set[str] | None, devices: set[str] | None, *, established_only: bool) -> AntaInventory | None:
|
||||
"""Set up the inventory for the ANTA run.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
inventory
|
||||
AntaInventory object that includes the device(s).
|
||||
tags
|
||||
Tags to filter devices from the inventory.
|
||||
devices
|
||||
Devices on which to run tests. None means all devices.
|
||||
established_only
|
||||
If True use return only devices where a connection is established.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AntaInventory | None
|
||||
The filtered inventory or None if there are no devices to run tests on.
|
||||
"""
|
||||
if len(inventory) == 0:
|
||||
logger.info("The inventory is empty, exiting")
|
||||
return None
|
||||
|
||||
# Filter the inventory based on the CLI provided tags and devices if any
|
||||
selected_inventory = inventory.get_inventory(tags=tags, devices=devices) if tags or devices else inventory
|
||||
|
||||
with Catchtime(logger=logger, message="Connecting to devices"):
|
||||
# Connect to the devices
|
||||
await selected_inventory.connect_inventory()
|
||||
|
||||
# Remove devices that are unreachable
|
||||
selected_inventory = selected_inventory.get_inventory(established_only=established_only)
|
||||
|
||||
# If there are no devices in the inventory after filtering, exit
|
||||
if not selected_inventory.devices:
|
||||
msg = f'No reachable device {f"matching the tags {tags} " if tags else ""}was found.{f" Selected devices: {devices} " if devices is not None else ""}'
|
||||
logger.warning(msg)
|
||||
return None
|
||||
|
||||
return selected_inventory
|
||||
|
||||
|
||||
def prepare_tests(
|
||||
inventory: AntaInventory, catalog: AntaCatalog, tests: set[str] | None, tags: set[str] | None
|
||||
) -> defaultdict[AntaDevice, set[AntaTestDefinition]] | None:
|
||||
"""Prepare the tests to run.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
inventory
|
||||
AntaInventory object that includes the device(s).
|
||||
catalog
|
||||
AntaCatalog object that includes the list of tests.
|
||||
tests
|
||||
Tests to run against devices. None means all tests.
|
||||
tags
|
||||
Tags to filter devices from the inventory.
|
||||
|
||||
Returns
|
||||
-------
|
||||
defaultdict[AntaDevice, set[AntaTestDefinition]] | None
|
||||
A mapping of devices to the tests to run or None if there are no tests to run.
|
||||
"""
|
||||
# Build indexes for the catalog. If `tests` is set, filter the indexes based on these tests
|
||||
catalog.build_indexes(filtered_tests=tests)
|
||||
|
||||
# Using a set to avoid inserting duplicate tests
|
||||
device_to_tests: defaultdict[AntaDevice, set[AntaTestDefinition]] = defaultdict(set)
|
||||
|
||||
total_test_count = 0
|
||||
|
||||
# Create the device to tests mapping from the tags
|
||||
for device in inventory.devices:
|
||||
if tags:
|
||||
# If there are CLI tags, execute tests with matching tags for this device
|
||||
if not (matching_tags := tags.intersection(device.tags)):
|
||||
# The device does not have any selected tag, skipping
|
||||
continue
|
||||
device_to_tests[device].update(catalog.get_tests_by_tags(matching_tags))
|
||||
else:
|
||||
# If there is no CLI tags, execute all tests that do not have any tags
|
||||
device_to_tests[device].update(catalog.tag_to_tests[None])
|
||||
|
||||
# Then add the tests with matching tags from device tags
|
||||
device_to_tests[device].update(catalog.get_tests_by_tags(device.tags))
|
||||
|
||||
total_test_count += len(device_to_tests[device])
|
||||
|
||||
if total_test_count == 0:
|
||||
msg = (
|
||||
f"There are no tests{f' matching the tags {tags} ' if tags else ' '}to run in the current "
|
||||
"test catalog and device inventory, please verify your inputs."
|
||||
)
|
||||
logger.warning(msg)
|
||||
return None
|
||||
|
||||
return device_to_tests
|
||||
|
||||
|
||||
def get_coroutines(selected_tests: defaultdict[AntaDevice, set[AntaTestDefinition]], manager: ResultManager | None = None) -> list[Coroutine[Any, Any, TestResult]]:
|
||||
"""Get the coroutines for the ANTA run.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
selected_tests
|
||||
A mapping of devices to the tests to run. The selected tests are generated by the `prepare_tests` function.
|
||||
manager
|
||||
An optional ResultManager object to pre-populate with the test results. Used in dry-run mode.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[Coroutine[Any, Any, TestResult]]
|
||||
The list of coroutines to run.
|
||||
"""
|
||||
coros = []
|
||||
for device, test_definitions in selected_tests.items():
|
||||
for test in test_definitions:
|
||||
try:
|
||||
test_instance = test.test(device=device, inputs=test.inputs)
|
||||
if manager is not None:
|
||||
manager.add(test_instance.result)
|
||||
coros.append(test_instance.test())
|
||||
except Exception as e: # noqa: PERF203, BLE001
|
||||
# An AntaTest instance is potentially user-defined code.
|
||||
# We need to catch everything and exit gracefully with an error message.
|
||||
message = "\n".join(
|
||||
[
|
||||
f"There is an error when creating test {test.test.__module__}.{test.test.__name__}.",
|
||||
f"If this is not a custom test implementation: {GITHUB_SUGGESTION}",
|
||||
],
|
||||
)
|
||||
anta_log_exception(e, message, logger)
|
||||
return coros
|
||||
|
||||
|
||||
@cprofile()
|
||||
async def main(
|
||||
manager: ResultManager,
|
||||
inventory: AntaInventory,
|
||||
catalog: AntaCatalog,
|
||||
devices: set[str] | None = None,
|
||||
tests: set[str] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
*,
|
||||
established_only: bool = True,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""Run ANTA.
|
||||
|
||||
Use this as an entrypoint to the test framework in your script.
|
||||
ResultManager object gets updated with the test results.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
manager
|
||||
ResultManager object to populate with the test results.
|
||||
inventory
|
||||
AntaInventory object that includes the device(s).
|
||||
catalog
|
||||
AntaCatalog object that includes the list of tests.
|
||||
devices
|
||||
Devices on which to run tests. None means all devices. These may come from the `--device / -d` CLI option in NRFU.
|
||||
tests
|
||||
Tests to run against devices. None means all tests. These may come from the `--test / -t` CLI option in NRFU.
|
||||
tags
|
||||
Tags to filter devices from the inventory. These may come from the `--tags` CLI option in NRFU.
|
||||
established_only
|
||||
Include only established device(s).
|
||||
dry_run
|
||||
Build the list of coroutine to run and stop before test execution.
|
||||
"""
|
||||
if not catalog.tests:
|
||||
logger.info("The list of tests is empty, exiting")
|
||||
return
|
||||
|
||||
with Catchtime(logger=logger, message="Preparing ANTA NRFU Run"):
|
||||
# Setup the inventory
|
||||
selected_inventory = inventory if dry_run else await setup_inventory(inventory, tags, devices, established_only=established_only)
|
||||
if selected_inventory is None:
|
||||
return
|
||||
|
||||
with Catchtime(logger=logger, message="Preparing the tests"):
|
||||
selected_tests = prepare_tests(selected_inventory, catalog, tests, tags)
|
||||
if selected_tests is None:
|
||||
return
|
||||
final_tests_count = sum(len(tests) for tests in selected_tests.values())
|
||||
|
||||
run_info = (
|
||||
"--- ANTA NRFU Run Information ---\n"
|
||||
f"Number of devices: {len(inventory)} ({len(selected_inventory)} established)\n"
|
||||
f"Total number of selected tests: {final_tests_count}\n"
|
||||
)
|
||||
|
||||
if 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)
|
||||
|
||||
if final_tests_count > limits[0]:
|
||||
logger.warning(
|
||||
"The number of concurrent tests is higher than the open file descriptors limit for this ANTA process.\n"
|
||||
"Errors may occur while running the tests.\n"
|
||||
"Please consult the ANTA FAQ."
|
||||
)
|
||||
|
||||
coroutines = get_coroutines(selected_tests, manager if dry_run else None)
|
||||
|
||||
if dry_run:
|
||||
logger.info("Dry-run mode, exiting before running the tests.")
|
||||
for coro in coroutines:
|
||||
coro.close()
|
||||
return
|
||||
|
||||
if AntaTest.progress is not None:
|
||||
AntaTest.nrfu_task = AntaTest.progress.add_task("Running NRFU Tests...", total=len(coroutines))
|
||||
|
||||
with Catchtime(logger=logger, message="Running ANTA tests"):
|
||||
results = await asyncio.gather(*coroutines)
|
||||
for result in results:
|
||||
manager.add(result)
|
||||
|
||||
log_cache_statistics(selected_inventory.devices)
|
||||
|
|
|
@ -1,3 +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.
|
||||
"""Module related to all ANTA tests."""
|
||||
|
|
|
@ -1,44 +1,54 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to the EOS various AAA settings
|
||||
"""
|
||||
"""Module related to the EOS various AAA tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
# Need to keep List and Set for pydantic in python 3.8
|
||||
from typing import List, Literal, Set
|
||||
from typing import TYPE_CHECKING, ClassVar, Literal
|
||||
|
||||
from anta.custom_types import AAAAuthMethod
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyTacacsSourceIntf(AntaTest):
|
||||
"""
|
||||
Verifies TACACS source-interface for a specified VRF.
|
||||
"""Verifies TACACS source-interface for a specified VRF.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided TACACS source-interface is configured in the specified VRF.
|
||||
* failure: The test will fail if the provided TACACS source-interface is NOT configured in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided TACACS source-interface is configured in the specified VRF.
|
||||
* Failure: The test will fail if the provided TACACS source-interface is NOT configured in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.aaa:
|
||||
- VerifyTacacsSourceIntf:
|
||||
intf: Management0
|
||||
vrf: MGMT
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTacacsSourceIntf"
|
||||
description = "Verifies TACACS source-interface for a specified VRF."
|
||||
categories = ["aaa"]
|
||||
commands = [AntaCommand(command="show tacacs")]
|
||||
categories: ClassVar[list[str]] = ["aaa"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyTacacsSourceIntf test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
intf: str
|
||||
"""Source-interface to use as source IP of TACACS messages"""
|
||||
"""Source-interface to use as source IP of TACACS messages."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF to transport TACACS messages"""
|
||||
"""The name of the VRF to transport TACACS messages. Defaults to `default`."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTacacsSourceIntf."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
try:
|
||||
if command_output["srcIntf"][self.inputs.vrf] == self.inputs.intf:
|
||||
|
@ -50,27 +60,39 @@ class VerifyTacacsSourceIntf(AntaTest):
|
|||
|
||||
|
||||
class VerifyTacacsServers(AntaTest):
|
||||
"""
|
||||
Verifies TACACS servers are configured for a specified VRF.
|
||||
"""Verifies TACACS servers are configured for a specified VRF.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided TACACS servers are configured in the specified VRF.
|
||||
* failure: The test will fail if the provided TACACS servers are NOT configured in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided TACACS servers are configured in the specified VRF.
|
||||
* Failure: The test will fail if the provided TACACS servers are NOT configured in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.aaa:
|
||||
- VerifyTacacsServers:
|
||||
servers:
|
||||
- 10.10.10.21
|
||||
- 10.10.10.22
|
||||
vrf: MGMT
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTacacsServers"
|
||||
description = "Verifies TACACS servers are configured for a specified VRF."
|
||||
categories = ["aaa"]
|
||||
commands = [AntaCommand(command="show tacacs")]
|
||||
categories: ClassVar[list[str]] = ["aaa"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
servers: List[IPv4Address]
|
||||
"""List of TACACS servers"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyTacacsServers test."""
|
||||
|
||||
servers: list[IPv4Address]
|
||||
"""List of TACACS servers."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF to transport TACACS messages"""
|
||||
"""The name of the VRF to transport TACACS messages. Defaults to `default`."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTacacsServers."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
tacacs_servers = command_output["tacacsServers"]
|
||||
if not tacacs_servers:
|
||||
|
@ -90,25 +112,36 @@ class VerifyTacacsServers(AntaTest):
|
|||
|
||||
|
||||
class VerifyTacacsServerGroups(AntaTest):
|
||||
"""
|
||||
Verifies if the provided TACACS server group(s) are configured.
|
||||
"""Verifies if the provided TACACS server group(s) are configured.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided TACACS server group(s) are configured.
|
||||
* failure: The test will fail if one or all the provided TACACS server group(s) are NOT configured.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided TACACS server group(s) are configured.
|
||||
* Failure: The test will fail if one or all the provided TACACS server group(s) are NOT configured.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.aaa:
|
||||
- VerifyTacacsServerGroups:
|
||||
groups:
|
||||
- TACACS-GROUP1
|
||||
- TACACS-GROUP2
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTacacsServerGroups"
|
||||
description = "Verifies if the provided TACACS server group(s) are configured."
|
||||
categories = ["aaa"]
|
||||
commands = [AntaCommand(command="show tacacs")]
|
||||
categories: ClassVar[list[str]] = ["aaa"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
groups: List[str]
|
||||
"""List of TACACS server group"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyTacacsServerGroups test."""
|
||||
|
||||
groups: list[str]
|
||||
"""List of TACACS server groups."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTacacsServerGroups."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
tacacs_groups = command_output["groups"]
|
||||
if not tacacs_groups:
|
||||
|
@ -122,29 +155,45 @@ class VerifyTacacsServerGroups(AntaTest):
|
|||
|
||||
|
||||
class VerifyAuthenMethods(AntaTest):
|
||||
"""
|
||||
Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x).
|
||||
"""Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided AAA authentication method list is matching in the configured authentication types.
|
||||
* failure: The test will fail if the provided AAA authentication method list is NOT matching in the configured authentication types.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided AAA authentication method list is matching in the configured authentication types.
|
||||
* Failure: The test will fail if the provided AAA authentication method list is NOT matching in the configured authentication types.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.aaa:
|
||||
- VerifyAuthenMethods:
|
||||
methods:
|
||||
- local
|
||||
- none
|
||||
- logging
|
||||
types:
|
||||
- login
|
||||
- enable
|
||||
- dot1x
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAuthenMethods"
|
||||
description = "Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x)."
|
||||
categories = ["aaa"]
|
||||
commands = [AntaCommand(command="show aaa methods authentication")]
|
||||
categories: ClassVar[list[str]] = ["aaa"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authentication", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
methods: List[AAAAuthMethod]
|
||||
"""List of AAA authentication methods. Methods should be in the right order"""
|
||||
types: Set[Literal["login", "enable", "dot1x"]]
|
||||
"""List of authentication types to verify"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyAuthenMethods test."""
|
||||
|
||||
methods: list[AAAAuthMethod]
|
||||
"""List of AAA authentication methods. Methods should be in the right order."""
|
||||
types: set[Literal["login", "enable", "dot1x"]]
|
||||
"""List of authentication types to verify."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAuthenMethods."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
not_matching = []
|
||||
not_matching: list[str] = []
|
||||
for k, v in command_output.items():
|
||||
auth_type = k.replace("AuthenMethods", "")
|
||||
if auth_type not in self.inputs.types:
|
||||
|
@ -157,9 +206,8 @@ class VerifyAuthenMethods(AntaTest):
|
|||
if v["login"]["methods"] != self.inputs.methods:
|
||||
self.result.is_failure(f"AAA authentication methods {self.inputs.methods} are not matching for login console")
|
||||
return
|
||||
for methods in v.values():
|
||||
if methods["methods"] != self.inputs.methods:
|
||||
not_matching.append(auth_type)
|
||||
not_matching.extend(auth_type for methods in v.values() if methods["methods"] != self.inputs.methods)
|
||||
|
||||
if not not_matching:
|
||||
self.result.is_success()
|
||||
else:
|
||||
|
@ -167,37 +215,51 @@ class VerifyAuthenMethods(AntaTest):
|
|||
|
||||
|
||||
class VerifyAuthzMethods(AntaTest):
|
||||
"""
|
||||
Verifies the AAA authorization method lists for different authorization types (commands, exec).
|
||||
"""Verifies the AAA authorization method lists for different authorization types (commands, exec).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided AAA authorization method list is matching in the configured authorization types.
|
||||
* failure: The test will fail if the provided AAA authorization method list is NOT matching in the configured authorization types.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided AAA authorization method list is matching in the configured authorization types.
|
||||
* Failure: The test will fail if the provided AAA authorization method list is NOT matching in the configured authorization types.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.aaa:
|
||||
- VerifyAuthzMethods:
|
||||
methods:
|
||||
- local
|
||||
- none
|
||||
- logging
|
||||
types:
|
||||
- commands
|
||||
- exec
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAuthzMethods"
|
||||
description = "Verifies the AAA authorization method lists for different authorization types (commands, exec)."
|
||||
categories = ["aaa"]
|
||||
commands = [AntaCommand(command="show aaa methods authorization")]
|
||||
categories: ClassVar[list[str]] = ["aaa"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authorization", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
methods: List[AAAAuthMethod]
|
||||
"""List of AAA authorization methods. Methods should be in the right order"""
|
||||
types: Set[Literal["commands", "exec"]]
|
||||
"""List of authorization types to verify"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyAuthzMethods test."""
|
||||
|
||||
methods: list[AAAAuthMethod]
|
||||
"""List of AAA authorization methods. Methods should be in the right order."""
|
||||
types: set[Literal["commands", "exec"]]
|
||||
"""List of authorization types to verify."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAuthzMethods."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
not_matching = []
|
||||
not_matching: list[str] = []
|
||||
for k, v in command_output.items():
|
||||
authz_type = k.replace("AuthzMethods", "")
|
||||
if authz_type not in self.inputs.types:
|
||||
# We do not need to verify this accounting type
|
||||
continue
|
||||
for methods in v.values():
|
||||
if methods["methods"] != self.inputs.methods:
|
||||
not_matching.append(authz_type)
|
||||
not_matching.extend(authz_type for methods in v.values() if methods["methods"] != self.inputs.methods)
|
||||
|
||||
if not not_matching:
|
||||
self.result.is_success()
|
||||
else:
|
||||
|
@ -205,27 +267,44 @@ class VerifyAuthzMethods(AntaTest):
|
|||
|
||||
|
||||
class VerifyAcctDefaultMethods(AntaTest):
|
||||
"""
|
||||
Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x).
|
||||
"""Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided AAA accounting default method list is matching in the configured accounting types.
|
||||
* failure: The test will fail if the provided AAA accounting default method list is NOT matching in the configured accounting types.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided AAA accounting default method list is matching in the configured accounting types.
|
||||
* Failure: The test will fail if the provided AAA accounting default method list is NOT matching in the configured accounting types.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.aaa:
|
||||
- VerifyAcctDefaultMethods:
|
||||
methods:
|
||||
- local
|
||||
- none
|
||||
- logging
|
||||
types:
|
||||
- system
|
||||
- exec
|
||||
- commands
|
||||
- dot1x
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAcctDefaultMethods"
|
||||
description = "Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x)."
|
||||
categories = ["aaa"]
|
||||
commands = [AntaCommand(command="show aaa methods accounting")]
|
||||
categories: ClassVar[list[str]] = ["aaa"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
methods: List[AAAAuthMethod]
|
||||
"""List of AAA accounting methods. Methods should be in the right order"""
|
||||
types: Set[Literal["commands", "exec", "system", "dot1x"]]
|
||||
"""List of accounting types to verify"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyAcctDefaultMethods test."""
|
||||
|
||||
methods: list[AAAAuthMethod]
|
||||
"""List of AAA accounting methods. Methods should be in the right order."""
|
||||
types: set[Literal["commands", "exec", "system", "dot1x"]]
|
||||
"""List of accounting types to verify."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAcctDefaultMethods."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
not_matching = []
|
||||
not_configured = []
|
||||
|
@ -249,27 +328,44 @@ class VerifyAcctDefaultMethods(AntaTest):
|
|||
|
||||
|
||||
class VerifyAcctConsoleMethods(AntaTest):
|
||||
"""
|
||||
Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x).
|
||||
"""Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided AAA accounting console method list is matching in the configured accounting types.
|
||||
* failure: The test will fail if the provided AAA accounting console method list is NOT matching in the configured accounting types.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided AAA accounting console method list is matching in the configured accounting types.
|
||||
* Failure: The test will fail if the provided AAA accounting console method list is NOT matching in the configured accounting types.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.aaa:
|
||||
- VerifyAcctConsoleMethods:
|
||||
methods:
|
||||
- local
|
||||
- none
|
||||
- logging
|
||||
types:
|
||||
- system
|
||||
- exec
|
||||
- commands
|
||||
- dot1x
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAcctConsoleMethods"
|
||||
description = "Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x)."
|
||||
categories = ["aaa"]
|
||||
commands = [AntaCommand(command="show aaa methods accounting")]
|
||||
categories: ClassVar[list[str]] = ["aaa"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
methods: List[AAAAuthMethod]
|
||||
"""List of AAA accounting console methods. Methods should be in the right order"""
|
||||
types: Set[Literal["commands", "exec", "system", "dot1x"]]
|
||||
"""List of accounting console types to verify"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyAcctConsoleMethods test."""
|
||||
|
||||
methods: list[AAAAuthMethod]
|
||||
"""List of AAA accounting console methods. Methods should be in the right order."""
|
||||
types: set[Literal["commands", "exec", "system", "dot1x"]]
|
||||
"""List of accounting console types to verify."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAcctConsoleMethods."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
not_matching = []
|
||||
not_configured = []
|
||||
|
|
195
anta/tests/avt.py
Normal file
195
anta/tests/avt.py
Normal file
|
@ -0,0 +1,195 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""Module related to Adaptive virtual topology tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from anta.decorators import skip_on_platforms
|
||||
from anta.input_models.avt import AVTPath
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
from anta.tools import get_value
|
||||
|
||||
|
||||
class VerifyAVTPathHealth(AntaTest):
|
||||
"""Verifies the status of all Adaptive Virtual Topology (AVT) paths for all VRFs.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all AVT paths for all VRFs are active and valid.
|
||||
* Failure: The test will fail if the AVT path is not configured or if any AVT path under any VRF is either inactive or invalid.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.avt:
|
||||
- VerifyAVTPathHealth:
|
||||
```
|
||||
"""
|
||||
|
||||
description = "Verifies the status of all AVT paths for all VRFs."
|
||||
categories: ClassVar[list[str]] = ["avt"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show adaptive-virtual-topology path")]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAVTPathHealth."""
|
||||
# Initialize the test result as success
|
||||
self.result.is_success()
|
||||
|
||||
# Get the command output
|
||||
command_output = self.instance_commands[0].json_output.get("vrfs", {})
|
||||
|
||||
# Check if AVT is configured
|
||||
if not command_output:
|
||||
self.result.is_failure("Adaptive virtual topology paths are not configured.")
|
||||
return
|
||||
|
||||
# Iterate over each VRF
|
||||
for vrf, vrf_data in command_output.items():
|
||||
# Iterate over each AVT path
|
||||
for profile, avt_path in vrf_data.get("avts", {}).items():
|
||||
for path, flags in avt_path.get("avtPaths", {}).items():
|
||||
# Get the status of the AVT path
|
||||
valid = flags["flags"]["valid"]
|
||||
active = flags["flags"]["active"]
|
||||
|
||||
# Check the status of the AVT path
|
||||
if not valid and not active:
|
||||
self.result.is_failure(f"AVT path {path} for profile {profile} in VRF {vrf} is invalid and not active.")
|
||||
elif not valid:
|
||||
self.result.is_failure(f"AVT path {path} for profile {profile} in VRF {vrf} is invalid.")
|
||||
elif not active:
|
||||
self.result.is_failure(f"AVT path {path} for profile {profile} in VRF {vrf} is not active.")
|
||||
|
||||
|
||||
class VerifyAVTSpecificPath(AntaTest):
|
||||
"""Verifies the Adaptive Virtual Topology (AVT) path.
|
||||
|
||||
This test performs the following checks for each specified LLDP neighbor:
|
||||
|
||||
1. Confirming that the AVT paths are associated with the specified VRF.
|
||||
2. Verifying that each AVT path is active and valid.
|
||||
3. Ensuring that the AVT path matches the specified type (direct/multihop) if provided.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all of the following conditions are met:
|
||||
- All AVT paths for the specified VRF are active, valid, and match the specified path type (direct/multihop), if provided.
|
||||
- If multiple paths are configured, the test will pass only if all paths meet these criteria.
|
||||
* Failure: The test will fail if any of the following conditions are met:
|
||||
- No AVT paths are configured for the specified VRF.
|
||||
- Any configured path is inactive, invalid, or does not match the specified type.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.avt:
|
||||
- VerifyAVTSpecificPath:
|
||||
avt_paths:
|
||||
- avt_name: CONTROL-PLANE-PROFILE
|
||||
vrf: default
|
||||
destination: 10.101.255.2
|
||||
next_hop: 10.101.255.1
|
||||
path_type: direct
|
||||
```
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["avt"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show adaptive-virtual-topology path", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyAVTSpecificPath test."""
|
||||
|
||||
avt_paths: list[AVTPath]
|
||||
"""List of AVT paths to verify."""
|
||||
AVTPaths: ClassVar[type[AVTPath]] = AVTPath
|
||||
"""To maintain backward compatibility."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAVTSpecificPath."""
|
||||
# Assume the test is successful until a failure is detected
|
||||
self.result.is_success()
|
||||
|
||||
command_output = self.instance_commands[0].json_output
|
||||
for avt_path in self.inputs.avt_paths:
|
||||
if (path_output := get_value(command_output, f"vrfs.{avt_path.vrf}.avts.{avt_path.avt_name}.avtPaths")) is None:
|
||||
self.result.is_failure(f"{avt_path} - No AVT path configured")
|
||||
return
|
||||
|
||||
path_found = path_type_found = False
|
||||
|
||||
# Check each AVT path
|
||||
for path, path_data in path_output.items():
|
||||
dest = path_data.get("destination")
|
||||
nexthop = path_data.get("nexthopAddr")
|
||||
path_type = "direct" if get_value(path_data, "flags.directPath") else "multihop"
|
||||
|
||||
if not avt_path.path_type:
|
||||
path_found = all([dest == str(avt_path.destination), nexthop == str(avt_path.next_hop)])
|
||||
|
||||
else:
|
||||
path_type_found = all([dest == str(avt_path.destination), nexthop == str(avt_path.next_hop), path_type == avt_path.path_type])
|
||||
if path_type_found:
|
||||
path_found = True
|
||||
# Check the path status and type against the expected values
|
||||
valid = get_value(path_data, "flags.valid")
|
||||
active = get_value(path_data, "flags.active")
|
||||
if not all([valid, active]):
|
||||
self.result.is_failure(f"{avt_path} - Incorrect path {path} - Valid: {valid}, Active: {active}")
|
||||
|
||||
# If no matching path found, mark the test as failed
|
||||
if not path_found:
|
||||
if avt_path.path_type and not path_type_found:
|
||||
self.result.is_failure(f"{avt_path} Path Type: {avt_path.path_type} - Path not found")
|
||||
else:
|
||||
self.result.is_failure(f"{avt_path} - Path not found")
|
||||
|
||||
|
||||
class VerifyAVTRole(AntaTest):
|
||||
"""Verifies the Adaptive Virtual Topology (AVT) role of a device.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the AVT role of the device matches the expected role.
|
||||
* Failure: The test will fail if the AVT is not configured or if the AVT role does not match the expected role.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.avt:
|
||||
- VerifyAVTRole:
|
||||
role: edge
|
||||
```
|
||||
"""
|
||||
|
||||
description = "Verifies the AVT role of a device."
|
||||
categories: ClassVar[list[str]] = ["avt"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show adaptive-virtual-topology path")]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyAVTRole test."""
|
||||
|
||||
role: str
|
||||
"""Expected AVT role of the device."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAVTRole."""
|
||||
# Initialize the test result as success
|
||||
self.result.is_success()
|
||||
|
||||
# Get the command output
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
# Check if the AVT role matches the expected role
|
||||
if self.inputs.role != command_output.get("role"):
|
||||
self.result.is_failure(f"Expected AVT role as `{self.inputs.role}`, but found `{command_output.get('role')}` instead.")
|
|
@ -1,203 +1,233 @@
|
|||
# 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.
|
||||
"""
|
||||
BFD test functions
|
||||
"""
|
||||
"""Module related to BFD tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from ipaddress import IPv4Address
|
||||
from typing import Any, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, 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.tools.get_value import get_value
|
||||
from anta.tools import get_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyBFDSpecificPeers(AntaTest):
|
||||
"""
|
||||
This class 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.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if IPv4 BFD peers are up and remote disc is non-zero in the specified VRF.
|
||||
* failure: The test will fail if IPv4 BFD peers are not found, the status is not UP or remote disc is zero in the specified VRF.
|
||||
This test performs the following checks for each specified peer:
|
||||
|
||||
1. Confirms that the specified VRF is configured.
|
||||
2. Verifies that the peer exists in the BFD configuration.
|
||||
3. For each specified BFD peer:
|
||||
- Validates that the state is `up`
|
||||
- Confirms that the remote discriminator identifier (disc) is non-zero.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: If all of the following conditions are met:
|
||||
- All specified peers are found in the BFD configuration within the specified VRF.
|
||||
- All BFD peers are `up` and remote disc is non-zero.
|
||||
* Failure: If any of the following occur:
|
||||
- A specified peer is not found in the BFD configuration within the specified VRF.
|
||||
- Any BFD peer session is not `up` or the remote discriminator identifier is zero.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.bfd:
|
||||
- VerifyBFDSpecificPeers:
|
||||
bfd_peers:
|
||||
- peer_address: 192.0.255.8
|
||||
vrf: default
|
||||
- peer_address: 192.0.255.7
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyBFDSpecificPeers"
|
||||
description = "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF."
|
||||
categories = ["bfd"]
|
||||
commands = [AntaCommand(command="show bfd peers")]
|
||||
categories: ClassVar[list[str]] = ["bfd"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""
|
||||
This class defines the input parameters of the test case.
|
||||
"""
|
||||
"""Input model for the VerifyBFDSpecificPeers test."""
|
||||
|
||||
bfd_peers: List[BFDPeers]
|
||||
"""List of IPv4 BFD peers"""
|
||||
|
||||
class BFDPeers(BaseModel):
|
||||
"""
|
||||
This class defines the details of an IPv4 BFD peer.
|
||||
"""
|
||||
|
||||
peer_address: IPv4Address
|
||||
"""IPv4 address of a BFD peer"""
|
||||
vrf: str = "default"
|
||||
"""Optional VRF for BGP peer. If not provided, it defaults to `default`."""
|
||||
bfd_peers: list[BFDPeer]
|
||||
"""List of IPv4 BFD"""
|
||||
BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer
|
||||
"""To maintain backward compatibility."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
failures: dict[Any, Any] = {}
|
||||
"""Main test function for VerifyBFDSpecificPeers."""
|
||||
self.result.is_success()
|
||||
|
||||
# Iterating over BFD peers
|
||||
for bfd_peer in self.inputs.bfd_peers:
|
||||
peer = str(bfd_peer.peer_address)
|
||||
vrf = bfd_peer.vrf
|
||||
bfd_output = get_value(self.instance_commands[0].json_output, f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..", separator="..")
|
||||
bfd_output = get_value(
|
||||
self.instance_commands[0].json_output,
|
||||
f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..",
|
||||
separator="..",
|
||||
)
|
||||
|
||||
# Check if BFD peer configured
|
||||
if not bfd_output:
|
||||
failures[peer] = {vrf: "Not Configured"}
|
||||
self.result.is_failure(f"{bfd_peer} - Not found")
|
||||
continue
|
||||
|
||||
# Check BFD peer status and remote disc
|
||||
if not (bfd_output.get("status") == "up" and bfd_output.get("remoteDisc") != 0):
|
||||
failures[peer] = {vrf: {"status": bfd_output.get("status"), "remote_disc": bfd_output.get("remoteDisc")}}
|
||||
|
||||
if not failures:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(f"Following BFD peers are not configured, status is not up or remote disc is zero:\n{failures}")
|
||||
state = bfd_output.get("status")
|
||||
remote_disc = bfd_output.get("remoteDisc")
|
||||
if not (state == "up" and remote_disc != 0):
|
||||
self.result.is_failure(f"{bfd_peer} - Session not properly established - State: {state} Remote Discriminator: {remote_disc}")
|
||||
|
||||
|
||||
class VerifyBFDPeersIntervals(AntaTest):
|
||||
"""
|
||||
This class verifies the timers of the IPv4 BFD peers in the specified VRF.
|
||||
"""Verifies the timers of IPv4 BFD peer sessions.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the timers of the IPv4 BFD peers are correct in the specified VRF.
|
||||
* failure: The test will fail if the IPv4 BFD peers are not found or their timers are incorrect in the specified VRF.
|
||||
This test performs the following checks for each specified peer:
|
||||
|
||||
1. Confirms that the specified VRF is configured.
|
||||
2. Verifies that the peer exists in the BFD configuration.
|
||||
3. Confirms that BFD peer is correctly configured with the `Transmit interval, Receive interval and Multiplier`.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: If all of the following conditions are met:
|
||||
- All specified peers are found in the BFD configuration within the specified VRF.
|
||||
- All BFD peers are correctly configured with the `Transmit interval, Receive interval and Multiplier`.
|
||||
* Failure: If any of the following occur:
|
||||
- A specified peer is not found in the BFD configuration within the specified VRF.
|
||||
- Any BFD peer not correctly configured with the `Transmit interval, Receive interval and Multiplier`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.bfd:
|
||||
- VerifyBFDPeersIntervals:
|
||||
bfd_peers:
|
||||
- peer_address: 192.0.255.8
|
||||
vrf: default
|
||||
tx_interval: 1200
|
||||
rx_interval: 1200
|
||||
multiplier: 3
|
||||
- peer_address: 192.0.255.7
|
||||
vrf: default
|
||||
tx_interval: 1200
|
||||
rx_interval: 1200
|
||||
multiplier: 3
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyBFDPeersIntervals"
|
||||
description = "Verifies the timers of the IPv4 BFD peers in the specified VRF."
|
||||
categories = ["bfd"]
|
||||
commands = [AntaCommand(command="show bfd peers detail")]
|
||||
categories: ClassVar[list[str]] = ["bfd"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""
|
||||
This class defines the input parameters of the test case.
|
||||
"""
|
||||
"""Input model for the VerifyBFDPeersIntervals test."""
|
||||
|
||||
bfd_peers: List[BFDPeers]
|
||||
"""List of BFD peers"""
|
||||
|
||||
class BFDPeers(BaseModel):
|
||||
"""
|
||||
This class defines the details of an IPv4 BFD peer.
|
||||
"""
|
||||
|
||||
peer_address: IPv4Address
|
||||
"""IPv4 address of a BFD peer"""
|
||||
vrf: str = "default"
|
||||
"""Optional VRF for BGP peer. If not provided, it defaults to `default`."""
|
||||
tx_interval: BfdInterval
|
||||
"""Tx interval of BFD peer in milliseconds"""
|
||||
rx_interval: BfdInterval
|
||||
"""Rx interval of BFD peer in milliseconds"""
|
||||
multiplier: BfdMultiplier
|
||||
"""Multiplier of BFD peer"""
|
||||
bfd_peers: list[BFDPeer]
|
||||
"""List of IPv4 BFD"""
|
||||
BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer
|
||||
"""To maintain backward compatibility"""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
failures: dict[Any, Any] = {}
|
||||
"""Main test function for VerifyBFDPeersIntervals."""
|
||||
self.result.is_success()
|
||||
|
||||
# Iterating over BFD peers
|
||||
for bfd_peers in self.inputs.bfd_peers:
|
||||
peer = str(bfd_peers.peer_address)
|
||||
vrf = bfd_peers.vrf
|
||||
|
||||
# Converting milliseconds intervals into actual value
|
||||
tx_interval = bfd_peers.tx_interval * 1000
|
||||
rx_interval = bfd_peers.rx_interval * 1000
|
||||
multiplier = bfd_peers.multiplier
|
||||
bfd_output = get_value(self.instance_commands[0].json_output, f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..", separator="..")
|
||||
for bfd_peer in self.inputs.bfd_peers:
|
||||
peer = str(bfd_peer.peer_address)
|
||||
vrf = bfd_peer.vrf
|
||||
tx_interval = bfd_peer.tx_interval
|
||||
rx_interval = bfd_peer.rx_interval
|
||||
multiplier = bfd_peer.multiplier
|
||||
|
||||
# Check if BFD peer configured
|
||||
bfd_output = get_value(
|
||||
self.instance_commands[0].json_output,
|
||||
f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..",
|
||||
separator="..",
|
||||
)
|
||||
if not bfd_output:
|
||||
failures[peer] = {vrf: "Not Configured"}
|
||||
self.result.is_failure(f"{bfd_peer} - Not found")
|
||||
continue
|
||||
|
||||
# Convert interval timer(s) into milliseconds to be consistent with the inputs.
|
||||
bfd_details = bfd_output.get("peerStatsDetail", {})
|
||||
intervals_ok = (
|
||||
bfd_details.get("operTxInterval") == tx_interval and bfd_details.get("operRxInterval") == rx_interval and bfd_details.get("detectMult") == multiplier
|
||||
)
|
||||
op_tx_interval = bfd_details.get("operTxInterval") // 1000
|
||||
op_rx_interval = bfd_details.get("operRxInterval") // 1000
|
||||
detect_multiplier = bfd_details.get("detectMult")
|
||||
|
||||
# Check timers of BFD peer
|
||||
if not intervals_ok:
|
||||
failures[peer] = {
|
||||
vrf: {
|
||||
"tx_interval": bfd_details.get("operTxInterval"),
|
||||
"rx_interval": bfd_details.get("operRxInterval"),
|
||||
"multiplier": bfd_details.get("detectMult"),
|
||||
}
|
||||
}
|
||||
if op_tx_interval != tx_interval:
|
||||
self.result.is_failure(f"{bfd_peer} - Incorrect Transmit interval - Expected: {tx_interval} Actual: {op_tx_interval}")
|
||||
|
||||
# Check if any failures
|
||||
if not failures:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(f"Following BFD peers are not configured or timers are not correct:\n{failures}")
|
||||
if op_rx_interval != rx_interval:
|
||||
self.result.is_failure(f"{bfd_peer} - Incorrect Receive interval - Expected: {rx_interval} Actual: {op_rx_interval}")
|
||||
|
||||
if detect_multiplier != multiplier:
|
||||
self.result.is_failure(f"{bfd_peer} - Incorrect Multiplier - Expected: {multiplier} Actual: {detect_multiplier}")
|
||||
|
||||
|
||||
class VerifyBFDPeersHealth(AntaTest):
|
||||
"""
|
||||
This class 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.
|
||||
Optionally, it can also verify that BFD peers have not been down before a specified threshold of hours.
|
||||
This test performs the following checks for BFD peers across all VRFs:
|
||||
|
||||
Expected results:
|
||||
* Success: The test will pass if all IPv4 BFD peers are up, the discriminator value of each remote system is non-zero,
|
||||
and the last downtime of each peer is above the defined threshold.
|
||||
* Failure: The test will fail if any IPv4 BFD peer is down, the discriminator value of any remote system is zero,
|
||||
or the last downtime of any peer is below the defined threshold.
|
||||
1. Validates that the state is `up`.
|
||||
2. Confirms that the remote discriminator identifier (disc) is non-zero.
|
||||
3. Optionally verifies that the peer have not been down before a specified threshold of hours.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: If all of the following conditions are met:
|
||||
- All BFD peers across the VRFs are up and remote disc is non-zero.
|
||||
- Last downtime of each peer is above the defined threshold, if specified.
|
||||
* Failure: If any of the following occur:
|
||||
- Any BFD peer session is not up or the remote discriminator identifier is zero.
|
||||
- Last downtime of any peer is below the defined threshold, if specified.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.bfd:
|
||||
- VerifyBFDPeersHealth:
|
||||
down_threshold: 2
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyBFDPeersHealth"
|
||||
description = "Verifies the health of all IPv4 BFD peers."
|
||||
categories = ["bfd"]
|
||||
categories: ClassVar[list[str]] = ["bfd"]
|
||||
# revision 1 as later revision introduces additional nesting for type
|
||||
commands = [AntaCommand(command="show bfd peers", revision=1), AntaCommand(command="show clock")]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaCommand(command="show bfd peers", revision=1),
|
||||
AntaCommand(command="show clock", revision=1),
|
||||
]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""
|
||||
This class defines the input parameters of the test case.
|
||||
"""
|
||||
"""Input model for the VerifyBFDPeersHealth test."""
|
||||
|
||||
down_threshold: Optional[int] = Field(default=None, gt=0)
|
||||
down_threshold: int | None = Field(default=None, gt=0)
|
||||
"""Optional down threshold in hours to check if a BFD peer was down before those hours or not."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
# Initialize failure strings
|
||||
down_failures = []
|
||||
up_failures = []
|
||||
"""Main test function for VerifyBFDPeersHealth."""
|
||||
self.result.is_success()
|
||||
|
||||
# Extract the current timestamp and command output
|
||||
clock_output = self.instance_commands[1].json_output
|
||||
current_timestamp = clock_output["utcTime"]
|
||||
bfd_output = self.instance_commands[0].json_output
|
||||
|
||||
# set the initial result
|
||||
self.result.is_success()
|
||||
|
||||
# Check if any IPv4 BFD peer is configured
|
||||
ipv4_neighbors_exist = any(vrf_data["ipv4Neighbors"] for vrf_data in bfd_output["vrfs"].values())
|
||||
if not ipv4_neighbors_exist:
|
||||
|
@ -210,26 +240,88 @@ class VerifyBFDPeersHealth(AntaTest):
|
|||
for peer_data in neighbor_data["peerStats"].values():
|
||||
peer_status = peer_data["status"]
|
||||
remote_disc = peer_data["remoteDisc"]
|
||||
remote_disc_info = f" with remote disc {remote_disc}" if remote_disc == 0 else ""
|
||||
last_down = peer_data["lastDown"]
|
||||
hours_difference = (datetime.fromtimestamp(current_timestamp) - datetime.fromtimestamp(last_down)).total_seconds() / 3600
|
||||
hours_difference = (
|
||||
datetime.fromtimestamp(current_timestamp, tz=timezone.utc) - datetime.fromtimestamp(last_down, tz=timezone.utc)
|
||||
).total_seconds() / 3600
|
||||
|
||||
# Check if peer status is not up
|
||||
if peer_status != "up":
|
||||
down_failures.append(f"{peer} is {peer_status} in {vrf} VRF{remote_disc_info}.")
|
||||
if not (peer_status == "up" and remote_disc != 0):
|
||||
self.result.is_failure(
|
||||
f"Peer: {peer} VRF: {vrf} - Session not properly established - State: {peer_status} Remote Discriminator: {remote_disc}"
|
||||
)
|
||||
|
||||
# Check if the last down is within the threshold
|
||||
elif self.inputs.down_threshold and hours_difference < self.inputs.down_threshold:
|
||||
up_failures.append(f"{peer} in {vrf} VRF was down {round(hours_difference)} hours ago{remote_disc_info}.")
|
||||
if self.inputs.down_threshold and hours_difference < self.inputs.down_threshold:
|
||||
self.result.is_failure(
|
||||
f"Peer: {peer} VRF: {vrf} - Session failure detected within the expected uptime threshold ({round(hours_difference)} hours ago)"
|
||||
)
|
||||
|
||||
# Check if remote disc is 0
|
||||
elif remote_disc == 0:
|
||||
up_failures.append(f"{peer} in {vrf} VRF has remote disc {remote_disc}.")
|
||||
|
||||
# Check if there are any failures
|
||||
if down_failures:
|
||||
down_failures_str = "\n".join(down_failures)
|
||||
self.result.is_failure(f"Following BFD peers are not up:\n{down_failures_str}")
|
||||
if up_failures:
|
||||
up_failures_str = "\n".join(up_failures)
|
||||
self.result.is_failure(f"\nFollowing BFD peers were down:\n{up_failures_str}")
|
||||
class VerifyBFDPeersRegProtocols(AntaTest):
|
||||
"""Verifies the registered routing protocol of IPv4 BFD peer sessions.
|
||||
|
||||
This test performs the following checks for each specified peer:
|
||||
|
||||
1. Confirms that the specified VRF is configured.
|
||||
2. Verifies that the peer exists in the BFD configuration.
|
||||
3. Confirms that BFD peer is correctly configured with the `routing protocol`.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: If all of the following conditions are met:
|
||||
- All specified peers are found in the BFD configuration within the specified VRF.
|
||||
- All BFD peers are correctly configured with the `routing protocol`.
|
||||
* Failure: If any of the following occur:
|
||||
- A specified peer is not found in the BFD configuration within the specified VRF.
|
||||
- Any BFD peer not correctly configured with the `routing protocol`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.bfd:
|
||||
- VerifyBFDPeersRegProtocols:
|
||||
bfd_peers:
|
||||
- peer_address: 192.0.255.7
|
||||
vrf: default
|
||||
protocols:
|
||||
- bgp
|
||||
```
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["bfd"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyBFDPeersRegProtocols test."""
|
||||
|
||||
bfd_peers: list[BFDPeer]
|
||||
"""List of IPv4 BFD"""
|
||||
BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer
|
||||
"""To maintain backward compatibility"""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyBFDPeersRegProtocols."""
|
||||
self.result.is_success()
|
||||
|
||||
# Iterating over BFD peers, extract the parameters and command output
|
||||
for bfd_peer in self.inputs.bfd_peers:
|
||||
peer = str(bfd_peer.peer_address)
|
||||
vrf = bfd_peer.vrf
|
||||
protocols = bfd_peer.protocols
|
||||
bfd_output = get_value(
|
||||
self.instance_commands[0].json_output,
|
||||
f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..",
|
||||
separator="..",
|
||||
)
|
||||
|
||||
# Check if BFD peer configured
|
||||
if not bfd_output:
|
||||
self.result.is_failure(f"{bfd_peer} - Not found")
|
||||
continue
|
||||
|
||||
# Check registered protocols
|
||||
difference = sorted(set(protocols) - set(get_value(bfd_output, "peerStatsDetail.apps")))
|
||||
if difference:
|
||||
failures = " ".join(f"`{item}`" for item in difference)
|
||||
self.result.is_failure(f"{bfd_peer} - {failures} routing protocol(s) not configured")
|
||||
|
|
|
@ -1,30 +1,45 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to the device configuration
|
||||
"""
|
||||
"""Module related to the device configuration tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.custom_types import RegexString
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyZeroTouch(AntaTest):
|
||||
"""
|
||||
Verifies ZeroTouch is disabled
|
||||
"""Verifies ZeroTouch is disabled.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if ZeroTouch is disabled.
|
||||
* Failure: The test will fail if ZeroTouch is enabled.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.configuration:
|
||||
- VerifyZeroTouch:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyZeroTouch"
|
||||
description = "Verifies ZeroTouch is disabled"
|
||||
categories = ["configuration"]
|
||||
commands = [AntaCommand(command="show zerotouch")]
|
||||
categories: ClassVar[list[str]] = ["configuration"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show zerotouch", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
command_output = self.instance_commands[0].output
|
||||
assert isinstance(command_output, dict)
|
||||
"""Main test function for VerifyZeroTouch."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["mode"] == "disabled":
|
||||
self.result.is_success()
|
||||
else:
|
||||
|
@ -32,20 +47,82 @@ class VerifyZeroTouch(AntaTest):
|
|||
|
||||
|
||||
class VerifyRunningConfigDiffs(AntaTest):
|
||||
"""
|
||||
Verifies there is no difference between the running-config and the startup-config
|
||||
"""Verifies there is no difference between the running-config and the startup-config.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if there is no difference between the running-config and the startup-config.
|
||||
* Failure: The test will fail if there is a difference between the running-config and the startup-config.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.configuration:
|
||||
- VerifyRunningConfigDiffs:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyRunningConfigDiffs"
|
||||
description = "Verifies there is no difference between the running-config and the startup-config"
|
||||
categories = ["configuration"]
|
||||
commands = [AntaCommand(command="show running-config diffs", ofmt="text")]
|
||||
categories: ClassVar[list[str]] = ["configuration"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config diffs", ofmt="text")]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
command_output = self.instance_commands[0].output
|
||||
if command_output is None or command_output == "":
|
||||
"""Main test function for VerifyRunningConfigDiffs."""
|
||||
command_output = self.instance_commands[0].text_output
|
||||
if command_output == "":
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure()
|
||||
self.result.is_failure(str(command_output))
|
||||
self.result.is_failure(command_output)
|
||||
|
||||
|
||||
class VerifyRunningConfigLines(AntaTest):
|
||||
"""Verifies the given regular expression patterns are present in the running-config.
|
||||
|
||||
!!! warning
|
||||
Since this uses regular expression searches on the whole running-config, it can
|
||||
drastically impact performance and should only be used if no other test is available.
|
||||
|
||||
If possible, try using another ANTA test that is more specific.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all the patterns are found in the running-config.
|
||||
* Failure: The test will fail if any of the patterns are NOT found in the running-config.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.configuration:
|
||||
- VerifyRunningConfigLines:
|
||||
regex_patterns:
|
||||
- "^enable password.*$"
|
||||
- "bla bla"
|
||||
```
|
||||
"""
|
||||
|
||||
description = "Search the Running-Config for the given RegEx patterns."
|
||||
categories: ClassVar[list[str]] = ["configuration"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config", ofmt="text")]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyRunningConfigLines test."""
|
||||
|
||||
regex_patterns: list[RegexString]
|
||||
"""List of regular expressions."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyRunningConfigLines."""
|
||||
failure_msgs = []
|
||||
command_output = self.instance_commands[0].text_output
|
||||
|
||||
for pattern in self.inputs.regex_patterns:
|
||||
re_search = re.compile(pattern, flags=re.MULTILINE)
|
||||
|
||||
if not re_search.search(command_output):
|
||||
failure_msgs.append(f"'{pattern}'")
|
||||
|
||||
if not failure_msgs:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure("Following patterns were not found: " + ",".join(failure_msgs))
|
||||
|
|
|
@ -1,125 +1,140 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to various connectivity checks
|
||||
"""
|
||||
"""Module related to various connectivity tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address
|
||||
from typing import ClassVar
|
||||
|
||||
# Need to keep List for pydantic in python 3.8
|
||||
from typing import List, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from anta.custom_types import Interface
|
||||
from anta.models import AntaCommand, AntaMissingParamException, AntaTemplate, AntaTest
|
||||
from anta.input_models.connectivity import Host, LLDPNeighbor, Neighbor
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
|
||||
|
||||
class VerifyReachability(AntaTest):
|
||||
"""
|
||||
Test network reachability to one or many destination IP(s).
|
||||
"""Test network reachability to one or many destination IP(s).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if all destination IP(s) are reachable.
|
||||
* failure: The test will fail if one or many destination IP(s) are unreachable.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all destination IP(s) are reachable.
|
||||
* Failure: The test will fail if one or many destination IP(s) are unreachable.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.connectivity:
|
||||
- VerifyReachability:
|
||||
hosts:
|
||||
- source: Management0
|
||||
destination: 1.1.1.1
|
||||
vrf: MGMT
|
||||
df_bit: True
|
||||
size: 100
|
||||
- source: Management0
|
||||
destination: 8.8.8.8
|
||||
vrf: MGMT
|
||||
df_bit: True
|
||||
size: 100
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyReachability"
|
||||
description = "Test the network reachability to one or many destination IP(s)."
|
||||
categories = ["connectivity"]
|
||||
commands = [AntaTemplate(template="ping vrf {vrf} {destination} source {source} repeat {repeat}")]
|
||||
categories: ClassVar[list[str]] = ["connectivity"]
|
||||
# Template uses '{size}{df_bit}' without space since df_bit includes leading space when enabled
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaTemplate(template="ping vrf {vrf} {destination} source {source} size {size}{df_bit} repeat {repeat}", revision=1)
|
||||
]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
hosts: List[Host]
|
||||
"""List of hosts to ping"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyReachability test."""
|
||||
|
||||
class Host(BaseModel):
|
||||
"""Remote host to ping"""
|
||||
|
||||
destination: IPv4Address
|
||||
"""IPv4 address to ping"""
|
||||
source: Union[IPv4Address, Interface]
|
||||
"""IPv4 address source IP or Egress interface to use"""
|
||||
vrf: str = "default"
|
||||
"""VRF context"""
|
||||
repeat: int = 2
|
||||
"""Number of ping repetition (default=2)"""
|
||||
hosts: list[Host]
|
||||
"""List of host to ping."""
|
||||
Host: ClassVar[type[Host]] = Host
|
||||
"""To maintain backward compatibility."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
return [template.render(destination=host.destination, source=host.source, vrf=host.vrf, repeat=host.repeat) for host in self.inputs.hosts]
|
||||
"""Render the template for each host in the input list."""
|
||||
return [
|
||||
template.render(
|
||||
destination=host.destination, source=host.source, vrf=host.vrf, repeat=host.repeat, size=host.size, df_bit=" df-bit" if host.df_bit else ""
|
||||
)
|
||||
for host in self.inputs.hosts
|
||||
]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
failures = []
|
||||
for command in self.instance_commands:
|
||||
src = command.params.get("source")
|
||||
dst = command.params.get("destination")
|
||||
repeat = command.params.get("repeat")
|
||||
"""Main test function for VerifyReachability."""
|
||||
self.result.is_success()
|
||||
|
||||
if any(elem is None for elem in (src, dst, repeat)):
|
||||
raise AntaMissingParamException(f"A parameter is missing to execute the test for command {command}")
|
||||
|
||||
if f"{repeat} received" not in command.json_output["messages"][0]:
|
||||
failures.append((str(src), str(dst)))
|
||||
|
||||
if not failures:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(f"Connectivity test failed for the following source-destination pairs: {failures}")
|
||||
for command, host in zip(self.instance_commands, self.inputs.hosts):
|
||||
if f"{host.repeat} received" not in command.json_output["messages"][0]:
|
||||
self.result.is_failure(f"{host} - Unreachable")
|
||||
|
||||
|
||||
class VerifyLLDPNeighbors(AntaTest):
|
||||
"""
|
||||
This test 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.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if each of the provided LLDP neighbors is present and connected to the specified port and device.
|
||||
* failure: The test will fail if any of the following conditions are met:
|
||||
- The provided LLDP neighbor is not found.
|
||||
- The system name or port of the LLDP neighbor does not match the provided information.
|
||||
This test performs the following checks for each specified LLDP neighbor:
|
||||
|
||||
1. Confirming matching ports on both local and neighboring devices.
|
||||
2. Ensuring compatibility of device names and interface identifiers.
|
||||
3. Verifying neighbor configurations match expected values per interface; extra neighbors are ignored.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all the provided LLDP neighbors are present and correctly connected to the specified port and device.
|
||||
* Failure: The test will fail if any of the following conditions are met:
|
||||
- The provided LLDP neighbor is not found in the LLDP table.
|
||||
- The system name or port of the LLDP neighbor does not match the expected information.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.connectivity:
|
||||
- VerifyLLDPNeighbors:
|
||||
neighbors:
|
||||
- port: Ethernet1
|
||||
neighbor_device: DC1-SPINE1
|
||||
neighbor_port: Ethernet1
|
||||
- port: Ethernet2
|
||||
neighbor_device: DC1-SPINE2
|
||||
neighbor_port: Ethernet1
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLLDPNeighbors"
|
||||
description = "Verifies that the provided LLDP neighbors are connected properly."
|
||||
categories = ["connectivity"]
|
||||
commands = [AntaCommand(command="show lldp neighbors detail")]
|
||||
categories: ClassVar[list[str]] = ["connectivity"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lldp neighbors detail", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
neighbors: List[Neighbor]
|
||||
"""List of LLDP neighbors"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyLLDPNeighbors test."""
|
||||
|
||||
class Neighbor(BaseModel):
|
||||
"""LLDP neighbor"""
|
||||
|
||||
port: Interface
|
||||
"""LLDP port"""
|
||||
neighbor_device: str
|
||||
"""LLDP neighbor device"""
|
||||
neighbor_port: Interface
|
||||
"""LLDP neighbor port"""
|
||||
neighbors: list[LLDPNeighbor]
|
||||
"""List of LLDP neighbors."""
|
||||
Neighbor: ClassVar[type[Neighbor]] = Neighbor
|
||||
"""To maintain backward compatibility."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
failures: dict[str, list[str]] = {}
|
||||
"""Main test function for VerifyLLDPNeighbors."""
|
||||
self.result.is_success()
|
||||
|
||||
output = self.instance_commands[0].json_output["lldpNeighbors"]
|
||||
for neighbor in self.inputs.neighbors:
|
||||
if neighbor.port not in command_output["lldpNeighbors"]:
|
||||
failures.setdefault("port_not_configured", []).append(neighbor.port)
|
||||
elif len(lldp_neighbor_info := command_output["lldpNeighbors"][neighbor.port]["lldpNeighborInfo"]) == 0:
|
||||
failures.setdefault("no_lldp_neighbor", []).append(neighbor.port)
|
||||
elif (
|
||||
lldp_neighbor_info[0]["systemName"] != neighbor.neighbor_device
|
||||
or lldp_neighbor_info[0]["neighborInterfaceInfo"]["interfaceId_v2"] != neighbor.neighbor_port
|
||||
):
|
||||
failures.setdefault("wrong_lldp_neighbor", []).append(neighbor.port)
|
||||
if neighbor.port not in output:
|
||||
self.result.is_failure(f"{neighbor} - Port not found")
|
||||
continue
|
||||
|
||||
if not failures:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(f"The following port(s) have issues: {failures}")
|
||||
if len(lldp_neighbor_info := output[neighbor.port]["lldpNeighborInfo"]) == 0:
|
||||
self.result.is_failure(f"{neighbor} - No LLDP neighbors")
|
||||
continue
|
||||
|
||||
# Check if the system name and neighbor port matches
|
||||
match_found = any(
|
||||
info["systemName"] == neighbor.neighbor_device and info["neighborInterfaceInfo"]["interfaceId_v2"] == neighbor.neighbor_port
|
||||
for info in lldp_neighbor_info
|
||||
)
|
||||
if not match_found:
|
||||
failure_msg = [f"{info['systemName']}/{info['neighborInterfaceInfo']['interfaceId_v2']}" for info in lldp_neighbor_info]
|
||||
self.result.is_failure(f"{neighbor} - Wrong LLDP neighbors: {', '.join(failure_msg)}")
|
||||
|
|
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}")
|
|
@ -1,33 +1,47 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions to flag field notices
|
||||
"""
|
||||
"""Module related to field notices tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.decorators import skip_on_platforms
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyFieldNotice44Resolution(AntaTest):
|
||||
"""
|
||||
Verifies the device is using an Aboot version that fix the bug discussed
|
||||
in the field notice 44 (Aboot manages system settings prior to EOS initialization).
|
||||
"""Verifies if the device is using an Aboot version that fixes the bug discussed in the Field Notice 44.
|
||||
|
||||
https://www.arista.com/en/support/advisories-notices/field-notice/8756-field-notice-44
|
||||
Aboot manages system settings prior to EOS initialization.
|
||||
|
||||
Reference: https://www.arista.com/en/support/advisories-notices/field-notice/8756-field-notice-44
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the device is using an Aboot version that fixes the bug discussed in the Field Notice 44.
|
||||
* Failure: The test will fail if the device is not using an Aboot version that fixes the bug discussed in the Field Notice 44.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.field_notices:
|
||||
- VerifyFieldNotice44Resolution:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyFieldNotice44Resolution"
|
||||
description = (
|
||||
"Verifies the device is using an Aboot version that fix the bug discussed in the field notice 44 (Aboot manages system settings prior to EOS initialization)"
|
||||
)
|
||||
categories = ["field notices", "software"]
|
||||
commands = [AntaCommand(command="show version detail")]
|
||||
description = "Verifies that the device is using the correct Aboot version per FN0044."
|
||||
categories: ClassVar[list[str]] = ["field notices"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)]
|
||||
|
||||
# TODO maybe implement ONLY ON PLATFORMS instead
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyFieldNotice44Resolution."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
devices = [
|
||||
|
@ -79,7 +93,6 @@ class VerifyFieldNotice44Resolution(AntaTest):
|
|||
variants = ["-SSD-F", "-SSD-R", "-M-F", "-M-R", "-F", "-R"]
|
||||
|
||||
model = command_output["modelName"]
|
||||
# TODO this list could be a regex
|
||||
for variant in variants:
|
||||
model = model.replace(variant, "")
|
||||
if model not in devices:
|
||||
|
@ -89,33 +102,50 @@ class VerifyFieldNotice44Resolution(AntaTest):
|
|||
for component in command_output["details"]["components"]:
|
||||
if component["name"] == "Aboot":
|
||||
aboot_version = component["version"].split("-")[2]
|
||||
break
|
||||
else:
|
||||
self.result.is_failure("Aboot component not found")
|
||||
return
|
||||
|
||||
self.result.is_success()
|
||||
if aboot_version.startswith("4.0.") and int(aboot_version.split(".")[2]) < 7:
|
||||
self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
|
||||
elif aboot_version.startswith("4.1.") and int(aboot_version.split(".")[2]) < 1:
|
||||
self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
|
||||
elif aboot_version.startswith("6.0.") and int(aboot_version.split(".")[2]) < 9:
|
||||
self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
|
||||
elif aboot_version.startswith("6.1.") and int(aboot_version.split(".")[2]) < 7:
|
||||
incorrect_aboot_version = (
|
||||
(aboot_version.startswith("4.0.") and int(aboot_version.split(".")[2]) < 7)
|
||||
or (aboot_version.startswith("4.1.") and int(aboot_version.split(".")[2]) < 1)
|
||||
or (
|
||||
(aboot_version.startswith("6.0.") and int(aboot_version.split(".")[2]) < 9)
|
||||
or (aboot_version.startswith("6.1.") and int(aboot_version.split(".")[2]) < 7)
|
||||
)
|
||||
)
|
||||
if incorrect_aboot_version:
|
||||
self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
|
||||
|
||||
|
||||
class VerifyFieldNotice72Resolution(AntaTest):
|
||||
"""
|
||||
Checks if the device is potentially exposed to Field Notice 72, and if the issue has been mitigated.
|
||||
"""Verifies if the device is potentially exposed to Field Notice 72, and if the issue has been mitigated.
|
||||
|
||||
https://www.arista.com/en/support/advisories-notices/field-notice/17410-field-notice-0072
|
||||
Reference: https://www.arista.com/en/support/advisories-notices/field-notice/17410-field-notice-0072
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the device is not exposed to FN72 and the issue has been mitigated.
|
||||
* Failure: The test will fail if the device is exposed to FN72 and the issue has not been mitigated.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.field_notices:
|
||||
- VerifyFieldNotice72Resolution:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyFieldNotice72Resolution"
|
||||
description = "Verifies if the device has exposeure to FN72, and if the issue has been mitigated"
|
||||
categories = ["field notices", "software"]
|
||||
commands = [AntaCommand(command="show version detail")]
|
||||
description = "Verifies if the device is exposed to FN0072, and if the issue has been mitigated."
|
||||
categories: ClassVar[list[str]] = ["field notices"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)]
|
||||
|
||||
# TODO maybe implement ONLY ON PLATFORMS instead
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyFieldNotice72Resolution."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
devices = ["DCS-7280SR3-48YC8", "DCS-7280SR3K-48YC8"]
|
||||
|
@ -151,8 +181,7 @@ class VerifyFieldNotice72Resolution(AntaTest):
|
|||
self.result.is_skipped("Device not exposed")
|
||||
return
|
||||
|
||||
# Because each of the if checks above will return if taken, we only run the long
|
||||
# check if we get this far
|
||||
# Because each of the if checks above will return if taken, we only run the long check if we get this far
|
||||
for entry in command_output["details"]["components"]:
|
||||
if entry["name"] == "FixedSystemvrm1":
|
||||
if int(entry["version"]) < 7:
|
||||
|
@ -161,5 +190,4 @@ class VerifyFieldNotice72Resolution(AntaTest):
|
|||
self.result.is_success("FN72 is mitigated")
|
||||
return
|
||||
# We should never hit this point
|
||||
self.result.is_error(message="Error in running test - FixedSystemvrm1 not found")
|
||||
return
|
||||
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")
|
|
@ -1,60 +1,79 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to GreenT (Postcard Telemetry) in EOS
|
||||
"""
|
||||
"""Module related to GreenT (Postcard Telemetry) tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyGreenTCounters(AntaTest):
|
||||
"""
|
||||
Verifies whether GRE packets are sent.
|
||||
"""Verifies if the GreenT (GRE Encapsulated Telemetry) counters are incremented.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the GreenT counters are incremented.
|
||||
* Failure: The test will fail if the GreenT counters are not incremented.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.greent:
|
||||
- VerifyGreenTCounters:
|
||||
```
|
||||
|
||||
Expected Results:
|
||||
* success: if >0 gre packets are sent
|
||||
* failure: if no gre packets are sent
|
||||
"""
|
||||
|
||||
name = "VerifyGreenTCounters"
|
||||
description = "Verifies if the greent counters are incremented."
|
||||
categories = ["greent"]
|
||||
commands = [AntaCommand(command="show monitor telemetry postcard counters")]
|
||||
description = "Verifies if the GreenT counters are incremented."
|
||||
categories: ClassVar[list[str]] = ["greent"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard counters", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyGreenTCounters."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
if command_output["grePktSent"] > 0:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure("GRE packets are not sent")
|
||||
self.result.is_failure("GreenT counters are not incremented")
|
||||
|
||||
|
||||
class VerifyGreenT(AntaTest):
|
||||
"""
|
||||
Verifies whether GreenT policy is created.
|
||||
"""Verifies if a GreenT (GRE Encapsulated Telemetry) policy other than the default is created.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if a GreenT policy is created other than the default one.
|
||||
* Failure: The test will fail if no other GreenT policy is created.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.greent:
|
||||
- VerifyGreenT:
|
||||
```
|
||||
|
||||
Expected Results:
|
||||
* success: if there exists any policy other than "default" policy.
|
||||
* failure: if no policy is created.
|
||||
"""
|
||||
|
||||
name = "VerifyGreenT"
|
||||
description = "Verifies whether greent policy is created."
|
||||
categories = ["greent"]
|
||||
commands = [AntaCommand(command="show monitor telemetry postcard policy profile")]
|
||||
description = "Verifies if a GreenT policy other than the default is created."
|
||||
categories: ClassVar[list[str]] = ["greent"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard policy profile", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyGreenT."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
out = [f"{i} policy is created" for i in command_output["profiles"].keys() if "default" not in i]
|
||||
profiles = [profile for profile in command_output["profiles"] if profile != "default"]
|
||||
|
||||
if len(out) > 0:
|
||||
for i in out:
|
||||
self.result.is_success(f"{i} policy is created")
|
||||
if profiles:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure("policy is not created")
|
||||
self.result.is_failure("No GreenT policy is created")
|
||||
|
|
|
@ -1,41 +1,54 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to the hardware or environment
|
||||
"""
|
||||
"""Module related to the hardware or environment tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
# Need to keep List for pydantic in python 3.8
|
||||
from typing import List
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.decorators import skip_on_platforms
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyTransceiversManufacturers(AntaTest):
|
||||
"""
|
||||
This test verifies if all the transceivers come from approved manufacturers.
|
||||
"""Verifies if all the transceivers come from approved manufacturers.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if all transceivers are from approved manufacturers.
|
||||
* failure: The test will fail if some transceivers are from unapproved manufacturers.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all transceivers are from approved manufacturers.
|
||||
* Failure: The test will fail if some transceivers are from unapproved manufacturers.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.hardware:
|
||||
- VerifyTransceiversManufacturers:
|
||||
manufacturers:
|
||||
- Not Present
|
||||
- Arista Networks
|
||||
- Arastra, Inc.
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTransceiversManufacturers"
|
||||
description = "Verifies if all transceivers come from approved manufacturers."
|
||||
categories = ["hardware"]
|
||||
commands = [AntaCommand(command="show inventory", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["hardware"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show inventory", revision=2)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
manufacturers: List[str]
|
||||
"""List of approved transceivers manufacturers"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyTransceiversManufacturers test."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
manufacturers: list[str]
|
||||
"""List of approved transceivers manufacturers."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTransceiversManufacturers."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
wrong_manufacturers = {
|
||||
interface: value["mfgName"] for interface, value in command_output["xcvrSlots"].items() if value["mfgName"] not in self.inputs.manufacturers
|
||||
|
@ -47,24 +60,30 @@ class VerifyTransceiversManufacturers(AntaTest):
|
|||
|
||||
|
||||
class VerifyTemperature(AntaTest):
|
||||
"""
|
||||
This test verifies if the device temperature is within acceptable limits.
|
||||
"""Verifies if the device temperature is within acceptable limits.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the device temperature is currently OK: 'temperatureOk'.
|
||||
* failure: The test will fail if the device temperature is NOT OK.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the device temperature is currently OK: 'temperatureOk'.
|
||||
* Failure: The test will fail if the device temperature is NOT OK.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.hardware:
|
||||
- VerifyTemperature:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTemperature"
|
||||
description = "Verifies the device temperature."
|
||||
categories = ["hardware"]
|
||||
commands = [AntaCommand(command="show system environment temperature", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["hardware"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature", revision=1)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTemperature."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
temperature_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else ""
|
||||
temperature_status = command_output.get("systemStatus", "")
|
||||
if temperature_status == "temperatureOk":
|
||||
self.result.is_success()
|
||||
else:
|
||||
|
@ -72,24 +91,30 @@ class VerifyTemperature(AntaTest):
|
|||
|
||||
|
||||
class VerifyTransceiversTemperature(AntaTest):
|
||||
"""
|
||||
This test verifies if all the transceivers are operating at an acceptable temperature.
|
||||
"""Verifies if all the transceivers are operating at an acceptable temperature.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if all transceivers status are OK: 'ok'.
|
||||
* failure: The test will fail if some transceivers are NOT OK.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all transceivers status are OK: 'ok'.
|
||||
* Failure: The test will fail if some transceivers are NOT OK.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.hardware:
|
||||
- VerifyTransceiversTemperature:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTransceiversTemperature"
|
||||
description = "Verifies the transceivers temperature."
|
||||
categories = ["hardware"]
|
||||
commands = [AntaCommand(command="show system environment temperature transceiver", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["hardware"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature transceiver", revision=1)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTransceiversTemperature."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
sensors = command_output["tempSensors"] if "tempSensors" in command_output.keys() else ""
|
||||
sensors = command_output.get("tempSensors", "")
|
||||
wrong_sensors = {
|
||||
sensor["name"]: {
|
||||
"hwStatus": sensor["hwStatus"],
|
||||
|
@ -105,50 +130,68 @@ class VerifyTransceiversTemperature(AntaTest):
|
|||
|
||||
|
||||
class VerifyEnvironmentSystemCooling(AntaTest):
|
||||
"""
|
||||
This test verifies the device's system cooling.
|
||||
"""Verifies the device's system cooling status.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the system cooling status is OK: 'coolingOk'.
|
||||
* failure: The test will fail if the system cooling status is NOT OK.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the system cooling status is OK: 'coolingOk'.
|
||||
* Failure: The test will fail if the system cooling status is NOT OK.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.hardware:
|
||||
- VerifyEnvironmentSystemCooling:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyEnvironmentSystemCooling"
|
||||
description = "Verifies the system cooling status."
|
||||
categories = ["hardware"]
|
||||
commands = [AntaCommand(command="show system environment cooling", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["hardware"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", revision=1)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyEnvironmentSystemCooling."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
sys_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else ""
|
||||
sys_status = command_output.get("systemStatus", "")
|
||||
self.result.is_success()
|
||||
if sys_status != "coolingOk":
|
||||
self.result.is_failure(f"Device system cooling is not OK: '{sys_status}'")
|
||||
|
||||
|
||||
class VerifyEnvironmentCooling(AntaTest):
|
||||
"""
|
||||
This test verifies the fans status.
|
||||
"""Verifies the status of power supply fans and all fan trays.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the fans status are within the accepted states list.
|
||||
* failure: The test will fail if some fans status is not within the accepted states list.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the fans status are within the accepted states list.
|
||||
* Failure: The test will fail if some fans status is not within the accepted states list.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.hardware:
|
||||
- VerifyEnvironmentCooling:
|
||||
states:
|
||||
- ok
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyEnvironmentCooling"
|
||||
description = "Verifies the status of power supply fans and all fan trays."
|
||||
categories = ["hardware"]
|
||||
commands = [AntaCommand(command="show system environment cooling", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["hardware"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
states: List[str]
|
||||
"""Accepted states list for fan status"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyEnvironmentCooling test."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
states: list[str]
|
||||
"""List of accepted states of fan status."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyEnvironmentCooling."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
self.result.is_success()
|
||||
# First go through power supplies fans
|
||||
|
@ -164,28 +207,38 @@ class VerifyEnvironmentCooling(AntaTest):
|
|||
|
||||
|
||||
class VerifyEnvironmentPower(AntaTest):
|
||||
"""
|
||||
This test verifies the power supplies status.
|
||||
"""Verifies the power supplies status.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the power supplies status are within the accepted states list.
|
||||
* failure: The test will fail if some power supplies status is not within the accepted states list.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the power supplies status are within the accepted states list.
|
||||
* Failure: The test will fail if some power supplies status is not within the accepted states list.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.hardware:
|
||||
- VerifyEnvironmentPower:
|
||||
states:
|
||||
- ok
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyEnvironmentPower"
|
||||
description = "Verifies the power supplies status."
|
||||
categories = ["hardware"]
|
||||
commands = [AntaCommand(command="show system environment power", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["hardware"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment power", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
states: List[str]
|
||||
"""Accepted states list for power supplies status"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyEnvironmentPower test."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
states: list[str]
|
||||
"""List of accepted states list of power supplies status."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyEnvironmentPower."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
power_supplies = command_output["powerSupplies"] if "powerSupplies" in command_output.keys() else "{}"
|
||||
power_supplies = command_output.get("powerSupplies", "{}")
|
||||
wrong_power_supplies = {
|
||||
powersupply: {"state": value["state"]} for powersupply, value in dict(power_supplies).items() if value["state"] not in self.inputs.states
|
||||
}
|
||||
|
@ -196,24 +249,31 @@ class VerifyEnvironmentPower(AntaTest):
|
|||
|
||||
|
||||
class VerifyAdverseDrops(AntaTest):
|
||||
"""
|
||||
This test verifies if there are no adverse drops on DCS7280E and DCS7500E.
|
||||
"""Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches (Arad/Jericho chips).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if there are no adverse drops.
|
||||
* failure: The test will fail if there are adverse drops.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if there are no adverse drops.
|
||||
* Failure: The test will fail if there are adverse drops.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.hardware:
|
||||
- VerifyAdverseDrops:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAdverseDrops"
|
||||
description = "Verifies there are no adverse drops on DCS7280E and DCS7500E"
|
||||
categories = ["hardware"]
|
||||
commands = [AntaCommand(command="show hardware counter drop", ofmt="json")]
|
||||
description = "Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches."
|
||||
categories: ClassVar[list[str]] = ["hardware"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware counter drop", revision=1)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAdverseDrops."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
total_adverse_drop = command_output["totalAdverseDrops"] if "totalAdverseDrops" in command_output.keys() else ""
|
||||
total_adverse_drop = command_output.get("totalAdverseDrops", "")
|
||||
if total_adverse_drop == 0:
|
||||
self.result.is_success()
|
||||
else:
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,34 +1,46 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to LANZ
|
||||
"""
|
||||
"""Module related to LANZ tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.decorators import skip_on_platforms
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyLANZ(AntaTest):
|
||||
"""
|
||||
Verifies if LANZ is enabled
|
||||
"""Verifies if LANZ (Latency Analyzer) is enabled.
|
||||
|
||||
Expected results:
|
||||
* success: the test will pass if lanz is enabled
|
||||
* failure: the test will fail if lanz is disabled
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if LANZ is enabled.
|
||||
* Failure: The test will fail if LANZ is disabled.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.lanz:
|
||||
- VerifyLANZ:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLANZ"
|
||||
description = "Verifies if LANZ is enabled."
|
||||
categories = ["lanz"]
|
||||
commands = [AntaCommand(command="show queue-monitor length status")]
|
||||
categories: ClassVar[list[str]] = ["lanz"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show queue-monitor length status", revision=1)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLANZ."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
if command_output["lanzEnabled"] is not True:
|
||||
self.result.is_failure("LANZ is not enabled")
|
||||
else:
|
||||
self.result.is_success("LANZ is enabled")
|
||||
self.result.is_success()
|
||||
|
|
|
@ -1,57 +1,73 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to the EOS various logging settings
|
||||
"""Module related to the EOS various logging tests.
|
||||
|
||||
NOTE: 'show logging' does not support json output yet
|
||||
NOTE: The EOS command `show logging` does not support JSON output format.
|
||||
"""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
# Need to keep List for pydantic in python 3.8
|
||||
from typing import List
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import logging
|
||||
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
def _get_logging_states(logger: logging.Logger, command_output: str) -> str:
|
||||
"""
|
||||
Parse "show logging" output and gets operational logging states used
|
||||
in the tests in this module.
|
||||
"""Parse `show logging` output and gets operational logging states used in the tests in this module.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
logger
|
||||
The logger object.
|
||||
command_output
|
||||
The `show logging` output.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The operational logging states.
|
||||
|
||||
Args:
|
||||
command_output: The 'show logging' output
|
||||
"""
|
||||
log_states = command_output.partition("\n\nExternal configuration:")[0]
|
||||
logger.debug(f"Device logging states:\n{log_states}")
|
||||
logger.debug("Device logging states:\n%s", log_states)
|
||||
return log_states
|
||||
|
||||
|
||||
class VerifyLoggingPersistent(AntaTest):
|
||||
"""
|
||||
Verifies if logging persistent is enabled and logs are saved in flash.
|
||||
"""Verifies if logging persistent is enabled and logs are saved in flash.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if logging persistent is enabled and logs are in flash.
|
||||
* failure: The test will fail if logging persistent is disabled or no logs are saved in flash.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if logging persistent is enabled and logs are in flash.
|
||||
* Failure: The test will fail if logging persistent is disabled or no logs are saved in flash.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.logging:
|
||||
- VerifyLoggingPersistent:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLoggingPersistent"
|
||||
description = "Verifies if logging persistent is enabled and logs are saved in flash."
|
||||
categories = ["logging"]
|
||||
commands = [
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaCommand(command="show logging", ofmt="text"),
|
||||
AntaCommand(command="dir flash:/persist/messages", ofmt="text"),
|
||||
]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoggingPersistent."""
|
||||
self.result.is_success()
|
||||
log_output = self.instance_commands[0].text_output
|
||||
dir_flash_output = self.instance_commands[1].text_output
|
||||
|
@ -65,27 +81,37 @@ class VerifyLoggingPersistent(AntaTest):
|
|||
|
||||
|
||||
class VerifyLoggingSourceIntf(AntaTest):
|
||||
"""
|
||||
Verifies logging source-interface for a specified VRF.
|
||||
"""Verifies logging source-interface for a specified VRF.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided logging source-interface is configured in the specified VRF.
|
||||
* failure: The test will fail if the provided logging source-interface is NOT configured in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided logging source-interface is configured in the specified VRF.
|
||||
* Failure: The test will fail if the provided logging source-interface is NOT configured in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.logging:
|
||||
- VerifyLoggingSourceIntf:
|
||||
interface: Management0
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLoggingSourceInt"
|
||||
description = "Verifies logging source-interface for a specified VRF."
|
||||
categories = ["logging"]
|
||||
commands = [AntaCommand(command="show logging", ofmt="text")]
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyLoggingSourceIntf test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
interface: str
|
||||
"""Source-interface to use as source IP of log messages"""
|
||||
"""Source-interface to use as source IP of log messages."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF to transport log messages"""
|
||||
"""The name of the VRF to transport log messages. Defaults to `default`."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoggingSourceIntf."""
|
||||
output = self.instance_commands[0].text_output
|
||||
pattern = rf"Logging source-interface '{self.inputs.interface}'.*VRF {self.inputs.vrf}"
|
||||
if re.search(pattern, _get_logging_states(self.logger, output)):
|
||||
|
@ -95,31 +121,43 @@ class VerifyLoggingSourceIntf(AntaTest):
|
|||
|
||||
|
||||
class VerifyLoggingHosts(AntaTest):
|
||||
"""
|
||||
Verifies logging hosts (syslog servers) for a specified VRF.
|
||||
"""Verifies logging hosts (syslog servers) for a specified VRF.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided syslog servers are configured in the specified VRF.
|
||||
* failure: The test will fail if the provided syslog servers are NOT configured in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided syslog servers are configured in the specified VRF.
|
||||
* Failure: The test will fail if the provided syslog servers are NOT configured in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.logging:
|
||||
- VerifyLoggingHosts:
|
||||
hosts:
|
||||
- 1.1.1.1
|
||||
- 2.2.2.2
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLoggingHosts"
|
||||
description = "Verifies logging hosts (syslog servers) for a specified VRF."
|
||||
categories = ["logging"]
|
||||
commands = [AntaCommand(command="show logging", ofmt="text")]
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
hosts: List[IPv4Address]
|
||||
"""List of hosts (syslog servers) IP addresses"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyLoggingHosts test."""
|
||||
|
||||
hosts: list[IPv4Address]
|
||||
"""List of hosts (syslog servers) IP addresses."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF to transport log messages"""
|
||||
"""The name of the VRF to transport log messages. Defaults to `default`."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoggingHosts."""
|
||||
output = self.instance_commands[0].text_output
|
||||
not_configured = []
|
||||
for host in self.inputs.hosts:
|
||||
pattern = rf"Logging to '{str(host)}'.*VRF {self.inputs.vrf}"
|
||||
pattern = rf"Logging to '{host!s}'.*VRF {self.inputs.vrf}"
|
||||
if not re.search(pattern, _get_logging_states(self.logger, output)):
|
||||
not_configured.append(str(host))
|
||||
|
||||
|
@ -130,24 +168,42 @@ class VerifyLoggingHosts(AntaTest):
|
|||
|
||||
|
||||
class VerifyLoggingLogsGeneration(AntaTest):
|
||||
"""
|
||||
Verifies if logs are generated.
|
||||
"""Verifies if logs are generated.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if logs are generated.
|
||||
* failure: The test will fail if logs are NOT generated.
|
||||
This test performs the following checks:
|
||||
|
||||
1. Sends a test log message at the **informational** level
|
||||
2. Retrieves the most recent logs (last 30 seconds)
|
||||
3. Verifies that the test message was successfully logged
|
||||
|
||||
!!! warning
|
||||
EOS logging buffer should be set to severity level `informational` or higher for this test to work.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: If logs are being generated and the test message is found in recent logs.
|
||||
* Failure: If any of the following occur:
|
||||
- The test message is not found in recent logs
|
||||
- The logging system is not capturing new messages
|
||||
- No logs are being generated
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.logging:
|
||||
- VerifyLoggingLogsGeneration:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLoggingLogsGeneration"
|
||||
description = "Verifies if logs are generated."
|
||||
categories = ["logging"]
|
||||
commands = [
|
||||
AntaCommand(command="send log level informational message ANTA VerifyLoggingLogsGeneration validation"),
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaCommand(command="send log level informational message ANTA VerifyLoggingLogsGeneration validation", ofmt="text"),
|
||||
AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
|
||||
]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoggingLogsGeneration."""
|
||||
log_pattern = r"ANTA VerifyLoggingLogsGeneration validation"
|
||||
output = self.instance_commands[1].text_output
|
||||
lines = output.strip().split("\n")[::-1]
|
||||
|
@ -159,25 +215,44 @@ class VerifyLoggingLogsGeneration(AntaTest):
|
|||
|
||||
|
||||
class VerifyLoggingHostname(AntaTest):
|
||||
"""
|
||||
Verifies if logs are generated with the device FQDN.
|
||||
"""Verifies if logs are generated with the device FQDN.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if logs are generated with the device FQDN.
|
||||
* failure: The test will fail if logs are NOT generated with the device FQDN.
|
||||
This test performs the following checks:
|
||||
|
||||
1. Retrieves the device's configured FQDN
|
||||
2. Sends a test log message at the **informational** level
|
||||
3. Retrieves the most recent logs (last 30 seconds)
|
||||
4. Verifies that the test message includes the complete FQDN of the device
|
||||
|
||||
!!! warning
|
||||
EOS logging buffer should be set to severity level `informational` or higher for this test to work.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: If logs are generated with the device's complete FQDN.
|
||||
* Failure: If any of the following occur:
|
||||
- The test message is not found in recent logs
|
||||
- The log message does not include the device's FQDN
|
||||
- The FQDN in the log message doesn't match the configured FQDN
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.logging:
|
||||
- VerifyLoggingHostname:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLoggingHostname"
|
||||
description = "Verifies if logs are generated with the device FQDN."
|
||||
categories = ["logging"]
|
||||
commands = [
|
||||
AntaCommand(command="show hostname"),
|
||||
AntaCommand(command="send log level informational message ANTA VerifyLoggingHostname validation"),
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaCommand(command="show hostname", revision=1),
|
||||
AntaCommand(command="send log level informational message ANTA VerifyLoggingHostname validation", ofmt="text"),
|
||||
AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
|
||||
]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoggingHostname."""
|
||||
output_hostname = self.instance_commands[0].json_output
|
||||
output_logging = self.instance_commands[2].text_output
|
||||
fqdn = output_hostname["fqdn"]
|
||||
|
@ -195,26 +270,46 @@ class VerifyLoggingHostname(AntaTest):
|
|||
|
||||
|
||||
class VerifyLoggingTimestamp(AntaTest):
|
||||
"""
|
||||
Verifies if logs are generated with the approprate timestamp.
|
||||
"""Verifies if logs are generated with the appropriate timestamp.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if logs are generated with the appropriated timestamp.
|
||||
* failure: The test will fail if logs are NOT generated with the appropriated timestamp.
|
||||
This test performs the following checks:
|
||||
|
||||
1. Sends a test log message at the **informational** level
|
||||
2. Retrieves the most recent logs (last 30 seconds)
|
||||
3. Verifies that the test message is present with a high-resolution RFC3339 timestamp format
|
||||
- Example format: `2024-01-25T15:30:45.123456+00:00`
|
||||
- Includes microsecond precision
|
||||
- Contains timezone offset
|
||||
|
||||
!!! warning
|
||||
EOS logging buffer should be set to severity level `informational` or higher for this test to work.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: If logs are generated with the correct high-resolution RFC3339 timestamp format.
|
||||
* Failure: If any of the following occur:
|
||||
- The test message is not found in recent logs
|
||||
- The timestamp format does not match the expected RFC3339 format
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.logging:
|
||||
- VerifyLoggingTimestamp:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLoggingTimestamp"
|
||||
description = "Verifies if logs are generated with the appropriate timestamp."
|
||||
categories = ["logging"]
|
||||
commands = [
|
||||
AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation"),
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation", ofmt="text"),
|
||||
AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
|
||||
]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoggingTimestamp."""
|
||||
log_pattern = r"ANTA VerifyLoggingTimestamp validation"
|
||||
timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}-\d{2}:\d{2}"
|
||||
timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}[+-]\d{2}:\d{2}"
|
||||
output = self.instance_commands[1].text_output
|
||||
lines = output.strip().split("\n")[::-1]
|
||||
last_line_with_pattern = ""
|
||||
|
@ -229,21 +324,27 @@ class VerifyLoggingTimestamp(AntaTest):
|
|||
|
||||
|
||||
class VerifyLoggingAccounting(AntaTest):
|
||||
"""
|
||||
Verifies if AAA accounting logs are generated.
|
||||
"""Verifies if AAA accounting logs are generated.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if AAA accounting logs are generated.
|
||||
* failure: The test will fail if AAA accounting logs are NOT generated.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if AAA accounting logs are generated.
|
||||
* Failure: The test will fail if AAA accounting logs are NOT generated.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.logging:
|
||||
- VerifyLoggingAccounting:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLoggingAccounting"
|
||||
description = "Verifies if AAA accounting logs are generated."
|
||||
categories = ["logging"]
|
||||
commands = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")]
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoggingAccounting."""
|
||||
pattern = r"cmd=show aaa accounting logs"
|
||||
output = self.instance_commands[0].text_output
|
||||
if re.search(pattern, output):
|
||||
|
@ -253,24 +354,27 @@ class VerifyLoggingAccounting(AntaTest):
|
|||
|
||||
|
||||
class VerifyLoggingErrors(AntaTest):
|
||||
"""
|
||||
This test verifies there are no syslog messages with a severity of ERRORS or higher.
|
||||
"""Verifies there are no syslog messages with a severity of ERRORS or higher.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if there are NO syslog messages with a severity of ERRORS or higher.
|
||||
* failure: The test will fail if ERRORS or higher syslog messages are present.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if there are NO syslog messages with a severity of ERRORS or higher.
|
||||
* Failure: The test will fail if ERRORS or higher syslog messages are present.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.logging:
|
||||
- VerifyLoggingErrors:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLoggingWarning"
|
||||
description = "This test verifies there are no syslog messages with a severity of ERRORS or higher."
|
||||
categories = ["logging"]
|
||||
commands = [AntaCommand(command="show logging threshold errors", ofmt="text")]
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging threshold errors", ofmt="text")]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""
|
||||
Run VerifyLoggingWarning validation
|
||||
"""
|
||||
"""Main test function for VerifyLoggingErrors."""
|
||||
command_output = self.instance_commands[0].text_output
|
||||
|
||||
if len(command_output) == 0:
|
||||
|
|
|
@ -1,39 +1,47 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to Multi-chassis Link Aggregation (MLAG)
|
||||
"""
|
||||
"""Module related to Multi-chassis Link Aggregation (MLAG) tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import conint
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.custom_types import MlagPriority
|
||||
from anta.custom_types import MlagPriority, PositiveInteger
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
from anta.tools.get_value import get_value
|
||||
from anta.tools import get_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyMlagStatus(AntaTest):
|
||||
"""
|
||||
This test verifies the health status of the MLAG configuration.
|
||||
"""Verifies the health status of the MLAG configuration.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the MLAG state is 'active', negotiation status is 'connected',
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the MLAG state is 'active', negotiation status is 'connected',
|
||||
peer-link status and local interface status are 'up'.
|
||||
* failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected',
|
||||
* Failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected',
|
||||
peer-link status or local interface status are not 'up'.
|
||||
* skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
* Skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.mlag:
|
||||
- VerifyMlagStatus:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyMlagStatus"
|
||||
description = "Verifies the health status of the MLAG configuration."
|
||||
categories = ["mlag"]
|
||||
commands = [AntaCommand(command="show mlag", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["mlag"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyMlagStatus."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["state"] == "disabled":
|
||||
self.result.is_skipped("MLAG is disabled")
|
||||
|
@ -52,22 +60,28 @@ class VerifyMlagStatus(AntaTest):
|
|||
|
||||
|
||||
class VerifyMlagInterfaces(AntaTest):
|
||||
"""
|
||||
This test verifies there are no inactive or active-partial MLAG ports.
|
||||
"""Verifies there are no inactive or active-partial MLAG ports.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if there are NO inactive or active-partial MLAG ports.
|
||||
* failure: The test will fail if there are inactive or active-partial MLAG ports.
|
||||
* skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if there are NO inactive or active-partial MLAG ports.
|
||||
* Failure: The test will fail if there are inactive or active-partial MLAG ports.
|
||||
* Skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.mlag:
|
||||
- VerifyMlagInterfaces:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyMlagInterfaces"
|
||||
description = "Verifies there are no inactive or active-partial MLAG ports."
|
||||
categories = ["mlag"]
|
||||
commands = [AntaCommand(command="show mlag", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["mlag"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyMlagInterfaces."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["state"] == "disabled":
|
||||
self.result.is_skipped("MLAG is disabled")
|
||||
|
@ -79,28 +93,31 @@ class VerifyMlagInterfaces(AntaTest):
|
|||
|
||||
|
||||
class VerifyMlagConfigSanity(AntaTest):
|
||||
"""
|
||||
This test verifies there are no MLAG config-sanity inconsistencies.
|
||||
"""Verifies there are no MLAG config-sanity inconsistencies.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if there are NO MLAG config-sanity inconsistencies.
|
||||
* failure: The test will fail if there are MLAG config-sanity inconsistencies.
|
||||
* skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
* error: The test will give an error if 'mlagActive' is not found in the JSON response.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if there are NO MLAG config-sanity inconsistencies.
|
||||
* Failure: The test will fail if there are MLAG config-sanity inconsistencies.
|
||||
* Skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
* Error: The test will give an error if 'mlagActive' is not found in the JSON response.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.mlag:
|
||||
- VerifyMlagConfigSanity:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyMlagConfigSanity"
|
||||
description = "Verifies there are no MLAG config-sanity inconsistencies."
|
||||
categories = ["mlag"]
|
||||
commands = [AntaCommand(command="show mlag config-sanity", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["mlag"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag config-sanity", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyMlagConfigSanity."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if (mlag_status := get_value(command_output, "mlagActive")) is None:
|
||||
self.result.is_error(message="Incorrect JSON response - 'mlagActive' state was not found")
|
||||
return
|
||||
if mlag_status is False:
|
||||
if command_output["mlagActive"] is False:
|
||||
self.result.is_skipped("MLAG is disabled")
|
||||
return
|
||||
keys_to_verify = ["globalConfiguration", "interfaceConfiguration"]
|
||||
|
@ -112,28 +129,38 @@ class VerifyMlagConfigSanity(AntaTest):
|
|||
|
||||
|
||||
class VerifyMlagReloadDelay(AntaTest):
|
||||
"""
|
||||
This test verifies the reload-delay parameters of the MLAG configuration.
|
||||
"""Verifies the reload-delay parameters of the MLAG configuration.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the reload-delay parameters are configured properly.
|
||||
* failure: The test will fail if the reload-delay parameters are NOT configured properly.
|
||||
* skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the reload-delay parameters are configured properly.
|
||||
* Failure: The test will fail if the reload-delay parameters are NOT configured properly.
|
||||
* Skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.mlag:
|
||||
- VerifyMlagReloadDelay:
|
||||
reload_delay: 300
|
||||
reload_delay_non_mlag: 330
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyMlagReloadDelay"
|
||||
description = "Verifies the MLAG reload-delay parameters."
|
||||
categories = ["mlag"]
|
||||
commands = [AntaCommand(command="show mlag", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["mlag"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
reload_delay: conint(ge=0) # type: ignore
|
||||
"""Delay (seconds) after reboot until non peer-link ports that are part of an MLAG are enabled"""
|
||||
reload_delay_non_mlag: conint(ge=0) # type: ignore
|
||||
"""Delay (seconds) after reboot until ports that are not part of an MLAG are enabled"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyMlagReloadDelay test."""
|
||||
|
||||
reload_delay: PositiveInteger
|
||||
"""Delay (seconds) after reboot until non peer-link ports that are part of an MLAG are enabled."""
|
||||
reload_delay_non_mlag: PositiveInteger
|
||||
"""Delay (seconds) after reboot until ports that are not part of an MLAG are enabled."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyMlagReloadDelay."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["state"] == "disabled":
|
||||
self.result.is_skipped("MLAG is disabled")
|
||||
|
@ -148,32 +175,45 @@ class VerifyMlagReloadDelay(AntaTest):
|
|||
|
||||
|
||||
class VerifyMlagDualPrimary(AntaTest):
|
||||
"""
|
||||
This test verifies the dual-primary detection and its parameters of the MLAG configuration.
|
||||
"""Verifies the dual-primary detection and its parameters of the MLAG configuration.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the dual-primary detection is enabled and its parameters are configured properly.
|
||||
* failure: The test will fail if the dual-primary detection is NOT enabled or its parameters are NOT configured properly.
|
||||
* skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the dual-primary detection is enabled and its parameters are configured properly.
|
||||
* Failure: The test will fail if the dual-primary detection is NOT enabled or its parameters are NOT configured properly.
|
||||
* Skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.mlag:
|
||||
- VerifyMlagDualPrimary:
|
||||
detection_delay: 200
|
||||
errdisabled: True
|
||||
recovery_delay: 60
|
||||
recovery_delay_non_mlag: 0
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyMlagDualPrimary"
|
||||
description = "Verifies the MLAG dual-primary detection parameters."
|
||||
categories = ["mlag"]
|
||||
commands = [AntaCommand(command="show mlag detail", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["mlag"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
detection_delay: conint(ge=0) # type: ignore
|
||||
"""Delay detection (seconds)"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyMlagDualPrimary test."""
|
||||
|
||||
detection_delay: PositiveInteger
|
||||
"""Delay detection (seconds)."""
|
||||
errdisabled: bool = False
|
||||
"""Errdisabled all interfaces when dual-primary is detected"""
|
||||
recovery_delay: conint(ge=0) # type: ignore
|
||||
"""Delay (seconds) after dual-primary detection resolves until non peer-link ports that are part of an MLAG are enabled"""
|
||||
recovery_delay_non_mlag: conint(ge=0) # type: ignore
|
||||
"""Delay (seconds) after dual-primary detection resolves until ports that are not part of an MLAG are enabled"""
|
||||
"""Errdisabled all interfaces when dual-primary is detected."""
|
||||
recovery_delay: PositiveInteger
|
||||
"""Delay (seconds) after dual-primary detection resolves until non peer-link ports that are part of an MLAG are enabled."""
|
||||
recovery_delay_non_mlag: PositiveInteger
|
||||
"""Delay (seconds) after dual-primary detection resolves until ports that are not part of an MLAG are enabled."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyMlagDualPrimary."""
|
||||
errdisabled_action = "errdisableAllInterfaces" if self.inputs.errdisabled else "none"
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["state"] == "disabled":
|
||||
|
@ -196,28 +236,36 @@ class VerifyMlagDualPrimary(AntaTest):
|
|||
|
||||
|
||||
class VerifyMlagPrimaryPriority(AntaTest):
|
||||
"""
|
||||
Test class to verify the MLAG (Multi-Chassis Link Aggregation) primary priority.
|
||||
"""Verify the MLAG (Multi-Chassis Link Aggregation) primary priority.
|
||||
|
||||
Expected Results:
|
||||
* Success: The test will pass if the MLAG state is set as 'primary' and the priority matches the input.
|
||||
* Failure: The test will fail if the MLAG state is not 'primary' or the priority doesn't match the input.
|
||||
* Skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the MLAG state is set as 'primary' and the priority matches the input.
|
||||
* Failure: The test will fail if the MLAG state is not 'primary' or the priority doesn't match the input.
|
||||
* Skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.mlag:
|
||||
- VerifyMlagPrimaryPriority:
|
||||
primary_priority: 3276
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyMlagPrimaryPriority"
|
||||
description = "Verifies the configuration of the MLAG primary priority."
|
||||
categories = ["mlag"]
|
||||
commands = [AntaCommand(command="show mlag detail")]
|
||||
categories: ClassVar[list[str]] = ["mlag"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Inputs for the VerifyMlagPrimaryPriority test."""
|
||||
"""Input model for the VerifyMlagPrimaryPriority test."""
|
||||
|
||||
primary_priority: MlagPriority
|
||||
"""The expected MLAG primary priority."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyMlagPrimaryPriority."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
self.result.is_success()
|
||||
# Skip the test if MLAG is disabled
|
||||
|
@ -235,5 +283,5 @@ class VerifyMlagPrimaryPriority(AntaTest):
|
|||
# Check primary priority
|
||||
if primary_priority != self.inputs.primary_priority:
|
||||
self.result.is_failure(
|
||||
f"The primary priority does not match expected. Expected `{self.inputs.primary_priority}`, but found `{primary_priority}` instead."
|
||||
f"The primary priority does not match expected. Expected `{self.inputs.primary_priority}`, but found `{primary_priority}` instead.",
|
||||
)
|
||||
|
|
|
@ -1,36 +1,52 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to multicast
|
||||
"""
|
||||
"""Module related to multicast and IGMP tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
# Need to keep Dict for pydantic in python 3.8
|
||||
from typing import Dict
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.custom_types import Vlan
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyIGMPSnoopingVlans(AntaTest):
|
||||
"""
|
||||
Verifies the IGMP snooping configuration for some VLANs.
|
||||
"""Verifies the IGMP snooping status for the provided VLANs.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the IGMP snooping status matches the expected status for the provided VLANs.
|
||||
* Failure: The test will fail if the IGMP snooping status does not match the expected status for the provided VLANs.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.multicast:
|
||||
- VerifyIGMPSnoopingVlans:
|
||||
vlans:
|
||||
10: False
|
||||
12: False
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyIGMPSnoopingVlans"
|
||||
description = "Verifies the IGMP snooping configuration for some VLANs."
|
||||
categories = ["multicast", "igmp"]
|
||||
commands = [AntaCommand(command="show ip igmp snooping")]
|
||||
categories: ClassVar[list[str]] = ["multicast"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
vlans: Dict[Vlan, bool]
|
||||
"""Dictionary of VLANs with associated IGMP configuration status (True=enabled, False=disabled)"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyIGMPSnoopingVlans test."""
|
||||
|
||||
vlans: dict[Vlan, bool]
|
||||
"""Dictionary with VLAN ID and whether IGMP snooping must be enabled (True) or disabled (False)."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyIGMPSnoopingVlans."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
self.result.is_success()
|
||||
for vlan, enabled in self.inputs.vlans.items():
|
||||
|
@ -44,21 +60,34 @@ class VerifyIGMPSnoopingVlans(AntaTest):
|
|||
|
||||
|
||||
class VerifyIGMPSnoopingGlobal(AntaTest):
|
||||
"""
|
||||
Verifies the IGMP snooping global configuration.
|
||||
"""Verifies the IGMP snooping global status.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the IGMP snooping global status matches the expected status.
|
||||
* Failure: The test will fail if the IGMP snooping global status does not match the expected status.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.multicast:
|
||||
- VerifyIGMPSnoopingGlobal:
|
||||
enabled: True
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyIGMPSnoopingGlobal"
|
||||
description = "Verifies the IGMP snooping global configuration."
|
||||
categories = ["multicast", "igmp"]
|
||||
commands = [AntaCommand(command="show ip igmp snooping")]
|
||||
categories: ClassVar[list[str]] = ["multicast"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyIGMPSnoopingGlobal test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
enabled: bool
|
||||
"""Expected global IGMP snooping configuration (True=enabled, False=disabled)"""
|
||||
"""Whether global IGMP snopping must be enabled (True) or disabled (False)."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyIGMPSnoopingGlobal."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
self.result.is_success()
|
||||
igmp_state = command_output["igmpSnoopingState"]
|
||||
|
|
159
anta/tests/path_selection.py
Normal file
159
anta/tests/path_selection.py
Normal file
|
@ -0,0 +1,159 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""Test functions related to various router path-selection settings."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from anta.decorators import skip_on_platforms
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
from anta.tools import get_value
|
||||
|
||||
|
||||
class VerifyPathsHealth(AntaTest):
|
||||
"""Verifies the path and telemetry state of all paths under router path-selection.
|
||||
|
||||
The expected states are 'IPsec established', 'Resolved' for path and 'active' for telemetry.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all path states under router path-selection are either 'IPsec established' or 'Resolved'
|
||||
and their telemetry state as 'active'.
|
||||
* Failure: The test will fail if router path-selection is not configured or if any path state is not 'IPsec established' or 'Resolved',
|
||||
or the telemetry state is 'inactive'.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.path_selection:
|
||||
- VerifyPathsHealth:
|
||||
```
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["path-selection"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show path-selection paths", revision=1)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyPathsHealth."""
|
||||
self.result.is_success()
|
||||
|
||||
command_output = self.instance_commands[0].json_output["dpsPeers"]
|
||||
|
||||
# If no paths are configured for router path-selection, the test fails
|
||||
if not command_output:
|
||||
self.result.is_failure("No path configured for router path-selection.")
|
||||
return
|
||||
|
||||
# Check the state of each path
|
||||
for peer, peer_data in command_output.items():
|
||||
for group, group_data in peer_data["dpsGroups"].items():
|
||||
for path_data in group_data["dpsPaths"].values():
|
||||
path_state = path_data["state"]
|
||||
session = path_data["dpsSessions"]["0"]["active"]
|
||||
|
||||
# If the path state of any path is not 'ipsecEstablished' or 'routeResolved', the test fails
|
||||
if path_state not in ["ipsecEstablished", "routeResolved"]:
|
||||
self.result.is_failure(f"Path state for peer {peer} in path-group {group} is `{path_state}`.")
|
||||
|
||||
# If the telemetry state of any path is inactive, the test fails
|
||||
elif not session:
|
||||
self.result.is_failure(f"Telemetry state for peer {peer} in path-group {group} is `inactive`.")
|
||||
|
||||
|
||||
class VerifySpecificPath(AntaTest):
|
||||
"""Verifies the path and telemetry state of a specific path for an IPv4 peer under router path-selection.
|
||||
|
||||
The expected states are 'IPsec established', 'Resolved' for path and 'active' for telemetry.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the path state under router path-selection is either 'IPsec established' or 'Resolved'
|
||||
and telemetry state as 'active'.
|
||||
* Failure: The test will fail if router path-selection is not configured or if the path state is not 'IPsec established' or 'Resolved',
|
||||
or if the telemetry state is 'inactive'.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.path_selection:
|
||||
- VerifySpecificPath:
|
||||
paths:
|
||||
- peer: 10.255.0.1
|
||||
path_group: internet
|
||||
source_address: 100.64.3.2
|
||||
destination_address: 100.64.1.2
|
||||
```
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["path-selection"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaTemplate(template="show path-selection paths peer {peer} path-group {group} source {source} destination {destination}", revision=1)
|
||||
]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySpecificPath test."""
|
||||
|
||||
paths: list[RouterPath]
|
||||
"""List of router paths to verify."""
|
||||
|
||||
class RouterPath(BaseModel):
|
||||
"""Detail of a router path."""
|
||||
|
||||
peer: IPv4Address
|
||||
"""Static peer IPv4 address."""
|
||||
|
||||
path_group: str
|
||||
"""Router path group name."""
|
||||
|
||||
source_address: IPv4Address
|
||||
"""Source IPv4 address of path."""
|
||||
|
||||
destination_address: IPv4Address
|
||||
"""Destination IPv4 address of path."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render the template for each router path."""
|
||||
return [
|
||||
template.render(peer=path.peer, group=path.path_group, source=path.source_address, destination=path.destination_address) for path in self.inputs.paths
|
||||
]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySpecificPath."""
|
||||
self.result.is_success()
|
||||
|
||||
# Check the state of each path
|
||||
for command in self.instance_commands:
|
||||
peer = command.params.peer
|
||||
path_group = command.params.group
|
||||
source = command.params.source
|
||||
destination = command.params.destination
|
||||
command_output = command.json_output.get("dpsPeers", [])
|
||||
|
||||
# If the peer is not configured for the path group, the test fails
|
||||
if not command_output:
|
||||
self.result.is_failure(f"Path `peer: {peer} source: {source} destination: {destination}` is not configured for path-group `{path_group}`.")
|
||||
continue
|
||||
|
||||
# Extract the state of the path
|
||||
path_output = get_value(command_output, f"{peer}..dpsGroups..{path_group}..dpsPaths", separator="..")
|
||||
path_state = next(iter(path_output.values())).get("state")
|
||||
session = get_value(next(iter(path_output.values())), "dpsSessions.0.active")
|
||||
|
||||
# If the state of the path is not 'ipsecEstablished' or 'routeResolved', or the telemetry state is 'inactive', the test fails
|
||||
if path_state not in ["ipsecEstablished", "routeResolved"]:
|
||||
self.result.is_failure(f"Path state for `peer: {peer} source: {source} destination: {destination}` in path-group {path_group} is `{path_state}`.")
|
||||
elif not session:
|
||||
self.result.is_failure(
|
||||
f"Telemetry state for path `peer: {peer} source: {source} destination: {destination}` in path-group {path_group} is `inactive`."
|
||||
)
|
|
@ -1,36 +1,52 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to ASIC profiles
|
||||
"""
|
||||
"""Module related to ASIC profile tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from typing import TYPE_CHECKING, ClassVar, Literal
|
||||
|
||||
from anta.decorators import skip_on_platforms
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyUnifiedForwardingTableMode(AntaTest):
|
||||
"""
|
||||
Verifies the device is using the expected Unified Forwarding Table mode.
|
||||
"""Verifies the device is using the expected UFT (Unified Forwarding Table) mode.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the device is using the expected UFT mode.
|
||||
* Failure: The test will fail if the device is not using the expected UFT mode.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.profiles:
|
||||
- VerifyUnifiedForwardingTableMode:
|
||||
mode: 3
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyUnifiedForwardingTableMode"
|
||||
description = ""
|
||||
categories = ["profiles"]
|
||||
commands = [AntaCommand(command="show platform trident forwarding-table partition", ofmt="json")]
|
||||
description = "Verifies the device is using the expected UFT mode."
|
||||
categories: ClassVar[list[str]] = ["profiles"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show platform trident forwarding-table partition", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyUnifiedForwardingTableMode test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
mode: Literal[0, 1, 2, 3, 4, "flexible"]
|
||||
"""Expected UFT mode"""
|
||||
"""Expected UFT mode. Valid values are 0, 1, 2, 3, 4, or "flexible"."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyUnifiedForwardingTableMode."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["uftMode"] == str(self.inputs.mode):
|
||||
self.result.is_success()
|
||||
|
@ -39,22 +55,36 @@ class VerifyUnifiedForwardingTableMode(AntaTest):
|
|||
|
||||
|
||||
class VerifyTcamProfile(AntaTest):
|
||||
"""
|
||||
Verifies the device is using the configured TCAM profile.
|
||||
"""Verifies that the device is using the provided Ternary Content-Addressable Memory (TCAM) profile.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided TCAM profile is actually running on the device.
|
||||
* Failure: The test will fail if the provided TCAM profile is not running on the device.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.profiles:
|
||||
- VerifyTcamProfile:
|
||||
profile: vxlan-routing
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTcamProfile"
|
||||
description = "Verify that the assigned TCAM profile is actually running on the device"
|
||||
categories = ["profiles"]
|
||||
commands = [AntaCommand(command="show hardware tcam profile", ofmt="json")]
|
||||
description = "Verifies the device TCAM profile."
|
||||
categories: ClassVar[list[str]] = ["profiles"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware tcam profile", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyTcamProfile test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
profile: str
|
||||
"""Expected TCAM profile"""
|
||||
"""Expected TCAM profile."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTcamProfile."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["pmfProfiles"]["FixedSystem"]["status"] == command_output["pmfProfiles"]["FixedSystem"]["config"] == self.inputs.profile:
|
||||
self.result.is_success()
|
||||
|
|
|
@ -1,33 +1,230 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Copyright (c) 2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to PTP (Precision Time Protocol) in EOS
|
||||
"""
|
||||
"""Module related to PTP tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.decorators import skip_on_platforms
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
class VerifyPtpStatus(AntaTest):
|
||||
"""
|
||||
Verifies whether the PTP agent is enabled globally.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the PTP agent is enabled globally.
|
||||
* failure: The test will fail if the PTP agent is enabled globally.
|
||||
class VerifyPtpModeStatus(AntaTest):
|
||||
"""Verifies that the device is configured as a Precision Time Protocol (PTP) Boundary Clock (BC).
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the device is a BC.
|
||||
* Failure: The test will fail if the device is not a BC.
|
||||
* Skipped: The test will be skipped if PTP is not configured on the device.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.ptp:
|
||||
- VerifyPtpModeStatus:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyPtpStatus"
|
||||
description = "Verifies if the PTP agent is enabled."
|
||||
categories = ["ptp"]
|
||||
commands = [AntaCommand(command="show ptp")]
|
||||
description = "Verifies that the device is configured as a PTP Boundary Clock."
|
||||
categories: ClassVar[list[str]] = ["ptp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyPtpModeStatus."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
if "ptpMode" in command_output.keys():
|
||||
if (ptp_mode := command_output.get("ptpMode")) is None:
|
||||
self.result.is_skipped("PTP is not configured")
|
||||
return
|
||||
|
||||
if ptp_mode != "ptpBoundaryClock":
|
||||
self.result.is_failure(f"The device is not configured as a PTP Boundary Clock: '{ptp_mode}'")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifyPtpGMStatus(AntaTest):
|
||||
"""Verifies that the device is locked to a valid Precision Time Protocol (PTP) Grandmaster (GM).
|
||||
|
||||
To test PTP failover, re-run the test with a secondary GMID configured.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the device is locked to the provided Grandmaster.
|
||||
* Failure: The test will fail if the device is not locked to the provided Grandmaster.
|
||||
* Skipped: The test will be skipped if PTP is not configured on the device.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.ptp:
|
||||
- VerifyPtpGMStatus:
|
||||
gmid: 0xec:46:70:ff:fe:00:ff:a9
|
||||
```
|
||||
"""
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyPtpGMStatus test."""
|
||||
|
||||
gmid: str
|
||||
"""Identifier of the Grandmaster to which the device should be locked."""
|
||||
|
||||
description = "Verifies that the device is locked to a valid PTP Grandmaster."
|
||||
categories: ClassVar[list[str]] = ["ptp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyPtpGMStatus."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
if (ptp_clock_summary := command_output.get("ptpClockSummary")) is None:
|
||||
self.result.is_skipped("PTP is not configured")
|
||||
return
|
||||
|
||||
if ptp_clock_summary["gmClockIdentity"] != self.inputs.gmid:
|
||||
self.result.is_failure(
|
||||
f"The device is locked to the following Grandmaster: '{ptp_clock_summary['gmClockIdentity']}', which differ from the expected one.",
|
||||
)
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifyPtpLockStatus(AntaTest):
|
||||
"""Verifies that the device was locked to the upstream Precision Time Protocol (PTP) Grandmaster (GM) in the last minute.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the device was locked to the upstream GM in the last minute.
|
||||
* Failure: The test will fail if the device was not locked to the upstream GM in the last minute.
|
||||
* Skipped: The test will be skipped if PTP is not configured on the device.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.ptp:
|
||||
- VerifyPtpLockStatus:
|
||||
```
|
||||
"""
|
||||
|
||||
description = "Verifies that the device was locked to the upstream PTP GM in the last minute."
|
||||
categories: ClassVar[list[str]] = ["ptp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyPtpLockStatus."""
|
||||
threshold = 60
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
if (ptp_clock_summary := command_output.get("ptpClockSummary")) is None:
|
||||
self.result.is_skipped("PTP is not configured")
|
||||
return
|
||||
|
||||
time_difference = ptp_clock_summary["currentPtpSystemTime"] - ptp_clock_summary["lastSyncTime"]
|
||||
|
||||
if time_difference >= threshold:
|
||||
self.result.is_failure(f"The device lock is more than {threshold}s old: {time_difference}s")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifyPtpOffset(AntaTest):
|
||||
"""Verifies that the Precision Time Protocol (PTP) timing offset is within +/- 1000ns from the master clock.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the PTP timing offset is within +/- 1000ns from the master clock.
|
||||
* Failure: The test will fail if the PTP timing offset is greater than +/- 1000ns from the master clock.
|
||||
* Skipped: The test will be skipped if PTP is not configured on the device.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.ptp:
|
||||
- VerifyPtpOffset:
|
||||
```
|
||||
"""
|
||||
|
||||
description = "Verifies that the PTP timing offset is within +/- 1000ns from the master clock."
|
||||
categories: ClassVar[list[str]] = ["ptp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp monitor", revision=1)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyPtpOffset."""
|
||||
threshold = 1000
|
||||
offset_interfaces: dict[str, list[int]] = {}
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
if not command_output["ptpMonitorData"]:
|
||||
self.result.is_skipped("PTP is not configured")
|
||||
return
|
||||
|
||||
for interface in command_output["ptpMonitorData"]:
|
||||
if abs(interface["offsetFromMaster"]) > threshold:
|
||||
offset_interfaces.setdefault(interface["intf"], []).append(interface["offsetFromMaster"])
|
||||
|
||||
if offset_interfaces:
|
||||
self.result.is_failure(f"The device timing offset from master is greater than +/- {threshold}ns: {offset_interfaces}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifyPtpPortModeStatus(AntaTest):
|
||||
"""Verifies that all interfaces are in a valid Precision Time Protocol (PTP) state.
|
||||
|
||||
The interfaces can be in one of the following state: Master, Slave, Passive, or Disabled.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all PTP enabled interfaces are in a valid state.
|
||||
* Failure: The test will fail if there are no PTP enabled interfaces or if some interfaces are not in a valid state.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.ptp:
|
||||
- VerifyPtpPortModeStatus:
|
||||
```
|
||||
"""
|
||||
|
||||
description = "Verifies the PTP interfaces state."
|
||||
categories: ClassVar[list[str]] = ["ptp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyPtpPortModeStatus."""
|
||||
valid_state = ("psMaster", "psSlave", "psPassive", "psDisabled")
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
if not command_output["ptpIntfSummaries"]:
|
||||
self.result.is_failure("No interfaces are PTP enabled")
|
||||
return
|
||||
|
||||
invalid_interfaces = [
|
||||
interface
|
||||
for interface in command_output["ptpIntfSummaries"]
|
||||
for vlan in command_output["ptpIntfSummaries"][interface]["ptpIntfVlanSummaries"]
|
||||
if vlan["portState"] not in valid_state
|
||||
]
|
||||
|
||||
if not invalid_interfaces:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure("PTP agent disabled")
|
||||
self.result.is_failure(f"The following interface(s) are not in a valid PTP state: '{invalid_interfaces}'")
|
||||
|
|
|
@ -1,3 +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."""
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,41 +1,62 @@
|
|||
# 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.
|
||||
"""
|
||||
Generic routing test functions
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address, ip_interface
|
||||
|
||||
# Need to keep List for pydantic in python 3.8
|
||||
from typing import List, Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
"""Module related to generic routing tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cache
|
||||
from ipaddress import IPv4Address, IPv4Interface
|
||||
from typing import TYPE_CHECKING, ClassVar, Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
|
||||
from anta.custom_types import PositiveInteger
|
||||
from anta.input_models.routing.generic import IPv4Routes
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
from anta.tools import get_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sys
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self
|
||||
else:
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
class VerifyRoutingProtocolModel(AntaTest):
|
||||
"""
|
||||
Verifies the configured routing protocol model is the one we expect.
|
||||
And if there is no mismatch between the configured and operating routing protocol model.
|
||||
"""Verifies the configured routing protocol model.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the configured routing protocol model is the one we expect.
|
||||
* Failure: The test will fail if the configured routing protocol model is not the one we expect.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
generic:
|
||||
- VerifyRoutingProtocolModel:
|
||||
model: multi-agent
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyRoutingProtocolModel"
|
||||
description = "Verifies the configured routing protocol model."
|
||||
categories = ["routing"]
|
||||
commands = [AntaCommand(command="show ip route summary", revision=3)]
|
||||
categories: ClassVar[list[str]] = ["routing"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyRoutingProtocolModel test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
model: Literal["multi-agent", "ribd"] = "multi-agent"
|
||||
"""Expected routing protocol model"""
|
||||
"""Expected routing protocol model. Defaults to `multi-agent`."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyRoutingProtocolModel."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
configured_model = command_output["protoModelStatus"]["configuredProtoModel"]
|
||||
operating_model = command_output["protoModelStatus"]["operatingProtoModel"]
|
||||
|
@ -46,31 +67,46 @@ class VerifyRoutingProtocolModel(AntaTest):
|
|||
|
||||
|
||||
class VerifyRoutingTableSize(AntaTest):
|
||||
"""
|
||||
Verifies the size of the IP routing table (default VRF).
|
||||
Should be between the two provided thresholds.
|
||||
"""Verifies the size of the IP routing table of the default VRF.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the routing table size is between the provided minimum and maximum values.
|
||||
* Failure: The test will fail if the routing table size is not between the provided minimum and maximum values.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
generic:
|
||||
- VerifyRoutingTableSize:
|
||||
minimum: 2
|
||||
maximum: 20
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyRoutingTableSize"
|
||||
description = "Verifies the size of the IP routing table (default VRF). Should be between the two provided thresholds."
|
||||
categories = ["routing"]
|
||||
commands = [AntaCommand(command="show ip route summary", revision=3)]
|
||||
categories: ClassVar[list[str]] = ["routing"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
minimum: int
|
||||
"""Expected minimum routing table (default VRF) size"""
|
||||
maximum: int
|
||||
"""Expected maximum routing table (default VRF) size"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyRoutingTableSize test."""
|
||||
|
||||
@model_validator(mode="after") # type: ignore
|
||||
def check_min_max(self) -> AntaTest.Input:
|
||||
"""Validate that maximum is greater than minimum"""
|
||||
minimum: PositiveInteger
|
||||
"""Expected minimum routing table size."""
|
||||
maximum: PositiveInteger
|
||||
"""Expected maximum routing table size."""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_min_max(self) -> Self:
|
||||
"""Validate that maximum is greater than minimum."""
|
||||
if self.minimum > self.maximum:
|
||||
raise ValueError(f"Minimum {self.minimum} is greater than maximum {self.maximum}")
|
||||
msg = f"Minimum {self.minimum} is greater than maximum {self.maximum}"
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyRoutingTableSize."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
total_routes = int(command_output["vrfs"]["default"]["totalRoutes"])
|
||||
if self.inputs.minimum <= total_routes <= self.inputs.maximum:
|
||||
|
@ -80,39 +116,143 @@ class VerifyRoutingTableSize(AntaTest):
|
|||
|
||||
|
||||
class VerifyRoutingTableEntry(AntaTest):
|
||||
"""
|
||||
This test verifies that the provided routes are present in the routing table of a specified VRF.
|
||||
"""Verifies that the provided routes are present in the routing table of a specified VRF.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided routes are present in the routing table.
|
||||
* failure: The test will fail if one or many provided routes are missing from the routing table.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided routes are present in the routing table.
|
||||
* Failure: The test will fail if one or many provided routes are missing from the routing table.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
generic:
|
||||
- VerifyRoutingTableEntry:
|
||||
vrf: default
|
||||
routes:
|
||||
- 10.1.0.1
|
||||
- 10.1.0.2
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyRoutingTableEntry"
|
||||
description = "Verifies that the provided routes are present in the routing table of a specified VRF."
|
||||
categories = ["routing"]
|
||||
commands = [AntaTemplate(template="show ip route vrf {vrf} {route}")]
|
||||
categories: ClassVar[list[str]] = ["routing"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaTemplate(template="show ip route vrf {vrf} {route}", revision=4),
|
||||
AntaTemplate(template="show ip route vrf {vrf}", revision=4),
|
||||
]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyRoutingTableEntry test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
vrf: str = "default"
|
||||
"""VRF context"""
|
||||
routes: List[IPv4Address]
|
||||
"""Routes to verify"""
|
||||
"""VRF context. Defaults to `default` VRF."""
|
||||
routes: list[IPv4Address]
|
||||
"""List of routes to verify."""
|
||||
collect: Literal["one", "all"] = "one"
|
||||
"""Route collect behavior: one=one route per command, all=all routes in vrf per command. Defaults to `one`"""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes]
|
||||
"""Render the template for the input vrf."""
|
||||
if template == VerifyRoutingTableEntry.commands[0] and self.inputs.collect == "one":
|
||||
return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes]
|
||||
|
||||
if template == VerifyRoutingTableEntry.commands[1] and self.inputs.collect == "all":
|
||||
return [template.render(vrf=self.inputs.vrf)]
|
||||
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
@cache
|
||||
def ip_interface_ip(route: str) -> IPv4Address:
|
||||
"""Return the IP address of the provided ip route with mask."""
|
||||
return IPv4Interface(route).ip
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
missing_routes = []
|
||||
"""Main test function for VerifyRoutingTableEntry."""
|
||||
commands_output_route_ips = set()
|
||||
|
||||
for command in self.instance_commands:
|
||||
if "vrf" in command.params and "route" in command.params:
|
||||
vrf, route = command.params["vrf"], command.params["route"]
|
||||
if len(routes := command.json_output["vrfs"][vrf]["routes"]) == 0 or route != ip_interface(list(routes)[0]).ip:
|
||||
missing_routes.append(str(route))
|
||||
command_output_vrf = command.json_output["vrfs"][self.inputs.vrf]
|
||||
commands_output_route_ips |= {self.ip_interface_ip(route) for route in command_output_vrf["routes"]}
|
||||
|
||||
missing_routes = [str(route) for route in self.inputs.routes if route not in commands_output_route_ips]
|
||||
|
||||
if not missing_routes:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(f"The following route(s) are missing from the routing table of VRF {self.inputs.vrf}: {missing_routes}")
|
||||
|
||||
|
||||
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}")
|
||||
|
|
730
anta/tests/routing/isis.py
Normal file
730
anta/tests/routing/isis.py
Normal file
|
@ -0,0 +1,730 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""Module related to IS-IS tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
from typing import Any, ClassVar, Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from anta.custom_types import Interface
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
from anta.tools import get_value
|
||||
|
||||
|
||||
def _count_isis_neighbor(isis_neighbor_json: dict[str, Any]) -> int:
|
||||
"""Count the number of isis neighbors.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
isis_neighbor_json
|
||||
The JSON output of the `show isis neighbors` command.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of isis neighbors.
|
||||
|
||||
"""
|
||||
count = 0
|
||||
for vrf_data in isis_neighbor_json["vrfs"].values():
|
||||
for instance_data in vrf_data["isisInstances"].values():
|
||||
count += len(instance_data.get("neighbors", {}))
|
||||
return count
|
||||
|
||||
|
||||
def _get_not_full_isis_neighbors(isis_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return the isis neighbors whose adjacency state is not `up`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
isis_neighbor_json
|
||||
The JSON output of the `show isis neighbors` command.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict[str, Any]]
|
||||
A list of isis neighbors whose adjacency state is not `UP`.
|
||||
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"vrf": vrf,
|
||||
"instance": instance,
|
||||
"neighbor": adjacency["hostname"],
|
||||
"state": state,
|
||||
}
|
||||
for vrf, vrf_data in isis_neighbor_json["vrfs"].items()
|
||||
for instance, instance_data in vrf_data.get("isisInstances").items()
|
||||
for neighbor, neighbor_data in instance_data.get("neighbors").items()
|
||||
for adjacency in neighbor_data.get("adjacencies")
|
||||
if (state := adjacency["state"]) != "up"
|
||||
]
|
||||
|
||||
|
||||
def _get_full_isis_neighbors(isis_neighbor_json: dict[str, Any], neighbor_state: Literal["up", "down"] = "up") -> list[dict[str, Any]]:
|
||||
"""Return the isis neighbors whose adjacency state is `up`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
isis_neighbor_json
|
||||
The JSON output of the `show isis neighbors` command.
|
||||
neighbor_state
|
||||
Value of the neihbor state we are looking for. Defaults to `up`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict[str, Any]]
|
||||
A list of isis neighbors whose adjacency state is not `UP`.
|
||||
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"vrf": vrf,
|
||||
"instance": instance,
|
||||
"neighbor": adjacency["hostname"],
|
||||
"neighbor_address": adjacency["routerIdV4"],
|
||||
"interface": adjacency["interfaceName"],
|
||||
"state": state,
|
||||
}
|
||||
for vrf, vrf_data in isis_neighbor_json["vrfs"].items()
|
||||
for instance, instance_data in vrf_data.get("isisInstances").items()
|
||||
for neighbor, neighbor_data in instance_data.get("neighbors").items()
|
||||
for adjacency in neighbor_data.get("adjacencies")
|
||||
if (state := adjacency["state"]) == neighbor_state
|
||||
]
|
||||
|
||||
|
||||
def _get_isis_neighbors_count(isis_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Count number of IS-IS neighbor of the device."""
|
||||
return [
|
||||
{"vrf": vrf, "interface": interface, "mode": mode, "count": int(level_data["numAdjacencies"]), "level": int(level)}
|
||||
for vrf, vrf_data in isis_neighbor_json["vrfs"].items()
|
||||
for instance, instance_data in vrf_data.get("isisInstances").items()
|
||||
for interface, interface_data in instance_data.get("interfaces").items()
|
||||
for level, level_data in interface_data.get("intfLevels").items()
|
||||
if (mode := level_data["passive"]) is not True
|
||||
]
|
||||
|
||||
|
||||
def _get_interface_data(interface: str, vrf: str, command_output: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Extract data related to an IS-IS interface for testing."""
|
||||
if (vrf_data := get_value(command_output, f"vrfs.{vrf}")) is None:
|
||||
return None
|
||||
|
||||
for instance_data in vrf_data.get("isisInstances").values():
|
||||
if (intf_dict := get_value(dictionary=instance_data, key="interfaces")) is not None:
|
||||
try:
|
||||
return next(ifl_data for ifl, ifl_data in intf_dict.items() if ifl == interface)
|
||||
except StopIteration:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _get_adjacency_segment_data_by_neighbor(neighbor: str, instance: str, vrf: str, command_output: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Extract data related to an IS-IS interface for testing."""
|
||||
search_path = f"vrfs.{vrf}.isisInstances.{instance}.adjacencySegments"
|
||||
if get_value(dictionary=command_output, key=search_path, default=None) is None:
|
||||
return None
|
||||
|
||||
isis_instance = get_value(dictionary=command_output, key=search_path, default=None)
|
||||
|
||||
return next(
|
||||
(segment_data for segment_data in isis_instance if neighbor == segment_data["ipAddress"]),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
class VerifyISISNeighborState(AntaTest):
|
||||
"""Verifies all IS-IS neighbors are in UP state.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all IS-IS neighbors are in UP state.
|
||||
* Failure: The test will fail if some IS-IS neighbors are not in UP state.
|
||||
* Skipped: The test will be skipped if no IS-IS neighbor is found.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
isis:
|
||||
- VerifyISISNeighborState:
|
||||
```
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["isis"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis neighbors", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyISISNeighborState."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if _count_isis_neighbor(command_output) == 0:
|
||||
self.result.is_skipped("No IS-IS neighbor detected")
|
||||
return
|
||||
self.result.is_success()
|
||||
not_full_neighbors = _get_not_full_isis_neighbors(command_output)
|
||||
if not_full_neighbors:
|
||||
self.result.is_failure(f"Some neighbors are not in the correct state (UP): {not_full_neighbors}.")
|
||||
|
||||
|
||||
class VerifyISISNeighborCount(AntaTest):
|
||||
"""Verifies number of IS-IS neighbors per level and per interface.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the number of neighbors is correct.
|
||||
* Failure: The test will fail if the number of neighbors is incorrect.
|
||||
* Skipped: The test will be skipped if no IS-IS neighbor is found.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
isis:
|
||||
- VerifyISISNeighborCount:
|
||||
interfaces:
|
||||
- name: Ethernet1
|
||||
level: 1
|
||||
count: 2
|
||||
- name: Ethernet2
|
||||
level: 2
|
||||
count: 1
|
||||
- name: Ethernet3
|
||||
count: 2
|
||||
# level is set to 2 by default
|
||||
```
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["isis"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyISISNeighborCount test."""
|
||||
|
||||
interfaces: list[InterfaceCount]
|
||||
"""list of interfaces with their information."""
|
||||
|
||||
class InterfaceCount(BaseModel):
|
||||
"""Input model for the VerifyISISNeighborCount test."""
|
||||
|
||||
name: Interface
|
||||
"""Interface name to check."""
|
||||
level: int = 2
|
||||
"""IS-IS level to check."""
|
||||
count: int
|
||||
"""Number of IS-IS neighbors."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyISISNeighborCount."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
self.result.is_success()
|
||||
isis_neighbor_count = _get_isis_neighbors_count(command_output)
|
||||
if len(isis_neighbor_count) == 0:
|
||||
self.result.is_skipped("No IS-IS neighbor detected")
|
||||
return
|
||||
for interface in self.inputs.interfaces:
|
||||
eos_data = [ifl_data for ifl_data in isis_neighbor_count if ifl_data["interface"] == interface.name and ifl_data["level"] == interface.level]
|
||||
if not eos_data:
|
||||
self.result.is_failure(f"No neighbor detected for interface {interface.name}")
|
||||
continue
|
||||
if eos_data[0]["count"] != interface.count:
|
||||
self.result.is_failure(
|
||||
f"Interface {interface.name}: "
|
||||
f"expected Level {interface.level}: count {interface.count}, "
|
||||
f"got Level {eos_data[0]['level']}: count {eos_data[0]['count']}"
|
||||
)
|
||||
|
||||
|
||||
class VerifyISISInterfaceMode(AntaTest):
|
||||
"""Verifies ISIS Interfaces are running in correct mode.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all listed interfaces are running in correct mode.
|
||||
* Failure: The test will fail if any of the listed interfaces is not running in correct mode.
|
||||
* Skipped: The test will be skipped if no ISIS neighbor is found.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
isis:
|
||||
- VerifyISISInterfaceMode:
|
||||
interfaces:
|
||||
- name: Loopback0
|
||||
mode: passive
|
||||
# vrf is set to default by default
|
||||
- name: Ethernet2
|
||||
mode: passive
|
||||
level: 2
|
||||
# vrf is set to default by default
|
||||
- name: Ethernet1
|
||||
mode: point-to-point
|
||||
vrf: default
|
||||
# level is set to 2 by default
|
||||
```
|
||||
"""
|
||||
|
||||
description = "Verifies interface mode for IS-IS"
|
||||
categories: ClassVar[list[str]] = ["isis"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyISISNeighborCount test."""
|
||||
|
||||
interfaces: list[InterfaceState]
|
||||
"""list of interfaces with their information."""
|
||||
|
||||
class InterfaceState(BaseModel):
|
||||
"""Input model for the VerifyISISNeighborCount test."""
|
||||
|
||||
name: Interface
|
||||
"""Interface name to check."""
|
||||
level: Literal[1, 2] = 2
|
||||
"""ISIS level configured for interface. Default is 2."""
|
||||
mode: Literal["point-to-point", "broadcast", "passive"]
|
||||
"""Number of IS-IS neighbors."""
|
||||
vrf: str = "default"
|
||||
"""VRF where the interface should be configured"""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyISISInterfaceMode."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
self.result.is_success()
|
||||
|
||||
if len(command_output["vrfs"]) == 0:
|
||||
self.result.is_skipped("IS-IS is not configured on device")
|
||||
return
|
||||
|
||||
# Check for p2p interfaces
|
||||
for interface in self.inputs.interfaces:
|
||||
interface_data = _get_interface_data(
|
||||
interface=interface.name,
|
||||
vrf=interface.vrf,
|
||||
command_output=command_output,
|
||||
)
|
||||
# Check for correct VRF
|
||||
if interface_data is not None:
|
||||
interface_type = get_value(dictionary=interface_data, key="interfaceType", default="unset")
|
||||
# Check for interfaceType
|
||||
if interface.mode == "point-to-point" and interface.mode != interface_type:
|
||||
self.result.is_failure(f"Interface {interface.name} in VRF {interface.vrf} is not running in {interface.mode} reporting {interface_type}")
|
||||
# Check for passive
|
||||
elif interface.mode == "passive":
|
||||
json_path = f"intfLevels.{interface.level}.passive"
|
||||
if interface_data is None or get_value(dictionary=interface_data, key=json_path, default=False) is False:
|
||||
self.result.is_failure(f"Interface {interface.name} in VRF {interface.vrf} is not running in passive mode")
|
||||
else:
|
||||
self.result.is_failure(f"Interface {interface.name} not found in VRF {interface.vrf}")
|
||||
|
||||
|
||||
class VerifyISISSegmentRoutingAdjacencySegments(AntaTest):
|
||||
"""Verify that all expected Adjacency segments are correctly visible for each interface.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all listed interfaces have correct adjacencies.
|
||||
* Failure: The test will fail if any of the listed interfaces has not expected list of adjacencies.
|
||||
* Skipped: The test will be skipped if no ISIS SR Adjacency is found.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
isis:
|
||||
- VerifyISISSegmentRoutingAdjacencySegments:
|
||||
instances:
|
||||
- name: CORE-ISIS
|
||||
vrf: default
|
||||
segments:
|
||||
- interface: Ethernet2
|
||||
address: 10.0.1.3
|
||||
sid_origin: dynamic
|
||||
```
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["isis", "segment-routing"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis segment-routing adjacency-segments", ofmt="json")]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyISISSegmentRoutingAdjacencySegments test."""
|
||||
|
||||
instances: list[IsisInstance]
|
||||
|
||||
class IsisInstance(BaseModel):
|
||||
"""ISIS Instance model definition."""
|
||||
|
||||
name: str
|
||||
"""ISIS instance name."""
|
||||
vrf: str = "default"
|
||||
"""VRF name where ISIS instance is configured."""
|
||||
segments: list[Segment]
|
||||
"""List of Adjacency segments configured in this instance."""
|
||||
|
||||
class Segment(BaseModel):
|
||||
"""Segment model definition."""
|
||||
|
||||
interface: Interface
|
||||
"""Interface name to check."""
|
||||
level: Literal[1, 2] = 2
|
||||
"""ISIS level configured for interface. Default is 2."""
|
||||
sid_origin: Literal["dynamic"] = "dynamic"
|
||||
"""Adjacency type"""
|
||||
address: IPv4Address
|
||||
"""IP address of remote end of segment."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyISISSegmentRoutingAdjacencySegments."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
self.result.is_success()
|
||||
|
||||
if len(command_output["vrfs"]) == 0:
|
||||
self.result.is_skipped("IS-IS is not configured on device")
|
||||
return
|
||||
|
||||
# initiate defaults
|
||||
failure_message = []
|
||||
skip_vrfs = []
|
||||
skip_instances = []
|
||||
|
||||
# Check if VRFs and instances are present in output.
|
||||
for instance in self.inputs.instances:
|
||||
vrf_data = get_value(
|
||||
dictionary=command_output,
|
||||
key=f"vrfs.{instance.vrf}",
|
||||
default=None,
|
||||
)
|
||||
if vrf_data is None:
|
||||
skip_vrfs.append(instance.vrf)
|
||||
failure_message.append(f"VRF {instance.vrf} is not configured to run segment routging.")
|
||||
|
||||
elif get_value(dictionary=vrf_data, key=f"isisInstances.{instance.name}", default=None) is None:
|
||||
skip_instances.append(instance.name)
|
||||
failure_message.append(f"Instance {instance.name} is not found in vrf {instance.vrf}.")
|
||||
|
||||
# Check Adjacency segments
|
||||
for instance in self.inputs.instances:
|
||||
if instance.vrf not in skip_vrfs and instance.name not in skip_instances:
|
||||
for input_segment in instance.segments:
|
||||
eos_segment = _get_adjacency_segment_data_by_neighbor(
|
||||
neighbor=str(input_segment.address),
|
||||
instance=instance.name,
|
||||
vrf=instance.vrf,
|
||||
command_output=command_output,
|
||||
)
|
||||
if eos_segment is None:
|
||||
failure_message.append(f"Your segment has not been found: {input_segment}.")
|
||||
|
||||
elif (
|
||||
eos_segment["localIntf"] != input_segment.interface
|
||||
or eos_segment["level"] != input_segment.level
|
||||
or eos_segment["sidOrigin"] != input_segment.sid_origin
|
||||
):
|
||||
failure_message.append(f"Your segment is not correct: Expected: {input_segment} - Found: {eos_segment}.")
|
||||
if failure_message:
|
||||
self.result.is_failure("\n".join(failure_message))
|
||||
|
||||
|
||||
class VerifyISISSegmentRoutingDataplane(AntaTest):
|
||||
"""Verify dataplane of a list of ISIS-SR instances.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all instances have correct dataplane configured
|
||||
* Failure: The test will fail if one of the instances has incorrect dataplane configured
|
||||
* Skipped: The test will be skipped if ISIS is not running
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
isis:
|
||||
- VerifyISISSegmentRoutingDataplane:
|
||||
instances:
|
||||
- name: CORE-ISIS
|
||||
vrf: default
|
||||
dataplane: MPLS
|
||||
```
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["isis", "segment-routing"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis segment-routing", ofmt="json")]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyISISSegmentRoutingDataplane test."""
|
||||
|
||||
instances: list[IsisInstance]
|
||||
|
||||
class IsisInstance(BaseModel):
|
||||
"""ISIS Instance model definition."""
|
||||
|
||||
name: str
|
||||
"""ISIS instance name."""
|
||||
vrf: str = "default"
|
||||
"""VRF name where ISIS instance is configured."""
|
||||
dataplane: Literal["MPLS", "mpls", "unset"] = "MPLS"
|
||||
"""Configured dataplane for the instance."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyISISSegmentRoutingDataplane."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
self.result.is_success()
|
||||
|
||||
if len(command_output["vrfs"]) == 0:
|
||||
self.result.is_skipped("IS-IS-SR is not running on device.")
|
||||
return
|
||||
|
||||
# initiate defaults
|
||||
failure_message = []
|
||||
skip_vrfs = []
|
||||
skip_instances = []
|
||||
|
||||
# Check if VRFs and instances are present in output.
|
||||
for instance in self.inputs.instances:
|
||||
vrf_data = get_value(
|
||||
dictionary=command_output,
|
||||
key=f"vrfs.{instance.vrf}",
|
||||
default=None,
|
||||
)
|
||||
if vrf_data is None:
|
||||
skip_vrfs.append(instance.vrf)
|
||||
failure_message.append(f"VRF {instance.vrf} is not configured to run segment routing.")
|
||||
|
||||
elif get_value(dictionary=vrf_data, key=f"isisInstances.{instance.name}", default=None) is None:
|
||||
skip_instances.append(instance.name)
|
||||
failure_message.append(f"Instance {instance.name} is not found in vrf {instance.vrf}.")
|
||||
|
||||
# Check Adjacency segments
|
||||
for instance in self.inputs.instances:
|
||||
if instance.vrf not in skip_vrfs and instance.name not in skip_instances:
|
||||
eos_dataplane = get_value(dictionary=command_output, key=f"vrfs.{instance.vrf}.isisInstances.{instance.name}.dataPlane", default=None)
|
||||
if instance.dataplane.upper() != eos_dataplane:
|
||||
failure_message.append(f"ISIS instance {instance.name} is not running dataplane {instance.dataplane} ({eos_dataplane})")
|
||||
|
||||
if failure_message:
|
||||
self.result.is_failure("\n".join(failure_message))
|
||||
|
||||
|
||||
class VerifyISISSegmentRoutingTunnels(AntaTest):
|
||||
"""Verify ISIS-SR tunnels computed by device.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all listed tunnels are computed on device.
|
||||
* Failure: The test will fail if one of the listed tunnels is missing.
|
||||
* Skipped: The test will be skipped if ISIS-SR is not configured.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
isis:
|
||||
- VerifyISISSegmentRoutingTunnels:
|
||||
entries:
|
||||
# Check only endpoint
|
||||
- endpoint: 1.0.0.122/32
|
||||
# Check endpoint and via TI-LFA
|
||||
- endpoint: 1.0.0.13/32
|
||||
vias:
|
||||
- type: tunnel
|
||||
tunnel_id: ti-lfa
|
||||
# Check endpoint and via IP routers
|
||||
- endpoint: 1.0.0.14/32
|
||||
vias:
|
||||
- type: ip
|
||||
nexthop: 1.1.1.1
|
||||
```
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["isis", "segment-routing"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis segment-routing tunnel", ofmt="json")]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyISISSegmentRoutingTunnels test."""
|
||||
|
||||
entries: list[Entry]
|
||||
"""List of tunnels to check on device."""
|
||||
|
||||
class Entry(BaseModel):
|
||||
"""Definition of a tunnel entry."""
|
||||
|
||||
endpoint: IPv4Network
|
||||
"""Endpoint IP of the tunnel."""
|
||||
vias: list[Vias] | None = None
|
||||
"""Optional list of path to reach endpoint."""
|
||||
|
||||
class Vias(BaseModel):
|
||||
"""Definition of a tunnel path."""
|
||||
|
||||
nexthop: IPv4Address | None = None
|
||||
"""Nexthop of the tunnel. If None, then it is not tested. Default: None"""
|
||||
type: Literal["ip", "tunnel"] | None = None
|
||||
"""Type of the tunnel. If None, then it is not tested. Default: None"""
|
||||
interface: Interface | None = None
|
||||
"""Interface of the tunnel. If None, then it is not tested. Default: None"""
|
||||
tunnel_id: Literal["TI-LFA", "ti-lfa", "unset"] | None = None
|
||||
"""Computation method of the tunnel. If None, then it is not tested. Default: None"""
|
||||
|
||||
def _eos_entry_lookup(self, search_value: IPv4Network, entries: dict[str, Any], search_key: str = "endpoint") -> dict[str, Any] | None:
|
||||
return next(
|
||||
(entry_value for entry_id, entry_value in entries.items() if str(entry_value[search_key]) == str(search_value)),
|
||||
None,
|
||||
)
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyISISSegmentRoutingTunnels.
|
||||
|
||||
This method performs the main test logic for verifying ISIS Segment Routing tunnels.
|
||||
It checks the command output, initiates defaults, and performs various checks on the tunnels.
|
||||
"""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
self.result.is_success()
|
||||
|
||||
# initiate defaults
|
||||
failure_message = []
|
||||
|
||||
if len(command_output["entries"]) == 0:
|
||||
self.result.is_skipped("IS-IS-SR is not running on device.")
|
||||
return
|
||||
|
||||
for input_entry in self.inputs.entries:
|
||||
eos_entry = self._eos_entry_lookup(search_value=input_entry.endpoint, entries=command_output["entries"])
|
||||
if eos_entry is None:
|
||||
failure_message.append(f"Tunnel to {input_entry} is not found.")
|
||||
elif input_entry.vias is not None:
|
||||
failure_src = []
|
||||
for via_input in input_entry.vias:
|
||||
if not self._check_tunnel_type(via_input, eos_entry):
|
||||
failure_src.append("incorrect tunnel type")
|
||||
if not self._check_tunnel_nexthop(via_input, eos_entry):
|
||||
failure_src.append("incorrect nexthop")
|
||||
if not self._check_tunnel_interface(via_input, eos_entry):
|
||||
failure_src.append("incorrect interface")
|
||||
if not self._check_tunnel_id(via_input, eos_entry):
|
||||
failure_src.append("incorrect tunnel ID")
|
||||
|
||||
if failure_src:
|
||||
failure_message.append(f"Tunnel to {input_entry.endpoint!s} is incorrect: {', '.join(failure_src)}")
|
||||
|
||||
if failure_message:
|
||||
self.result.is_failure("\n".join(failure_message))
|
||||
|
||||
def _check_tunnel_type(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool:
|
||||
"""Check if the tunnel type specified in `via_input` matches any of the tunnel types in `eos_entry`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
|
||||
The input tunnel type to check.
|
||||
eos_entry : dict[str, Any]
|
||||
The EOS entry containing the tunnel types.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if the tunnel type matches any of the tunnel types in `eos_entry`, False otherwise.
|
||||
"""
|
||||
if via_input.type is not None:
|
||||
return any(
|
||||
via_input.type
|
||||
== get_value(
|
||||
dictionary=eos_via,
|
||||
key="type",
|
||||
default="undefined",
|
||||
)
|
||||
for eos_via in eos_entry["vias"]
|
||||
)
|
||||
return True
|
||||
|
||||
def _check_tunnel_nexthop(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool:
|
||||
"""Check if the tunnel nexthop matches the given input.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
|
||||
The input via object.
|
||||
eos_entry : dict[str, Any]
|
||||
The EOS entry dictionary.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if the tunnel nexthop matches, False otherwise.
|
||||
"""
|
||||
if via_input.nexthop is not None:
|
||||
return any(
|
||||
str(via_input.nexthop)
|
||||
== get_value(
|
||||
dictionary=eos_via,
|
||||
key="nexthop",
|
||||
default="undefined",
|
||||
)
|
||||
for eos_via in eos_entry["vias"]
|
||||
)
|
||||
return True
|
||||
|
||||
def _check_tunnel_interface(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool:
|
||||
"""Check if the tunnel interface exists in the given EOS entry.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
|
||||
The input via object.
|
||||
eos_entry : dict[str, Any]
|
||||
The EOS entry dictionary.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if the tunnel interface exists, False otherwise.
|
||||
"""
|
||||
if via_input.interface is not None:
|
||||
return any(
|
||||
via_input.interface
|
||||
== get_value(
|
||||
dictionary=eos_via,
|
||||
key="interface",
|
||||
default="undefined",
|
||||
)
|
||||
for eos_via in eos_entry["vias"]
|
||||
)
|
||||
return True
|
||||
|
||||
def _check_tunnel_id(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool:
|
||||
"""Check if the tunnel ID matches any of the tunnel IDs in the EOS entry's vias.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
|
||||
The input vias to check.
|
||||
eos_entry : dict[str, Any])
|
||||
The EOS entry to compare against.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if the tunnel ID matches any of the tunnel IDs in the EOS entry's vias, False otherwise.
|
||||
"""
|
||||
if via_input.tunnel_id is not None:
|
||||
return any(
|
||||
via_input.tunnel_id.upper()
|
||||
== get_value(
|
||||
dictionary=eos_via,
|
||||
key="tunnelId.type",
|
||||
default="undefined",
|
||||
).upper()
|
||||
for eos_via in eos_entry["vias"]
|
||||
)
|
||||
return True
|
|
@ -1,61 +1,120 @@
|
|||
# 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.
|
||||
"""
|
||||
OSPF test functions
|
||||
"""
|
||||
"""Module related to OSPF tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int:
|
||||
"""
|
||||
Count the number of OSPF neighbors
|
||||
"""Count the number of OSPF neighbors.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ospf_neighbor_json
|
||||
The JSON output of the `show ip ospf neighbor` command.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of OSPF neighbors.
|
||||
|
||||
"""
|
||||
count = 0
|
||||
for _, vrf_data in ospf_neighbor_json["vrfs"].items():
|
||||
for _, instance_data in vrf_data["instList"].items():
|
||||
for vrf_data in ospf_neighbor_json["vrfs"].values():
|
||||
for instance_data in vrf_data["instList"].values():
|
||||
count += len(instance_data.get("ospfNeighborEntries", []))
|
||||
return count
|
||||
|
||||
|
||||
def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return the OSPF neighbors whose adjacency state is not `full`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ospf_neighbor_json
|
||||
The JSON output of the `show ip ospf neighbor` command.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict[str, Any]]
|
||||
A list of OSPF neighbors whose adjacency state is not `full`.
|
||||
|
||||
"""
|
||||
Return the OSPF neighbors whose adjacency state is not "full"
|
||||
return [
|
||||
{
|
||||
"vrf": vrf,
|
||||
"instance": instance,
|
||||
"neighbor": neighbor_data["routerId"],
|
||||
"state": state,
|
||||
}
|
||||
for vrf, vrf_data in ospf_neighbor_json["vrfs"].items()
|
||||
for instance, instance_data in vrf_data["instList"].items()
|
||||
for neighbor_data in instance_data.get("ospfNeighborEntries", [])
|
||||
if (state := neighbor_data["adjacencyState"]) != "full"
|
||||
]
|
||||
|
||||
|
||||
def _get_ospf_max_lsa_info(ospf_process_json: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return information about OSPF instances and their LSAs.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ospf_process_json
|
||||
OSPF process information in JSON format.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict[str, Any]]
|
||||
A list of dictionaries containing OSPF LSAs information.
|
||||
|
||||
"""
|
||||
not_full_neighbors = []
|
||||
for vrf, vrf_data in ospf_neighbor_json["vrfs"].items():
|
||||
for instance, instance_data in vrf_data["instList"].items():
|
||||
for neighbor_data in instance_data.get("ospfNeighborEntries", []):
|
||||
if (state := neighbor_data["adjacencyState"]) != "full":
|
||||
not_full_neighbors.append(
|
||||
{
|
||||
"vrf": vrf,
|
||||
"instance": instance,
|
||||
"neighbor": neighbor_data["routerId"],
|
||||
"state": state,
|
||||
}
|
||||
)
|
||||
return not_full_neighbors
|
||||
return [
|
||||
{
|
||||
"vrf": vrf,
|
||||
"instance": instance,
|
||||
"maxLsa": instance_data.get("maxLsaInformation", {}).get("maxLsa"),
|
||||
"maxLsaThreshold": instance_data.get("maxLsaInformation", {}).get("maxLsaThreshold"),
|
||||
"numLsa": instance_data.get("lsaInformation", {}).get("numLsa"),
|
||||
}
|
||||
for vrf, vrf_data in ospf_process_json.get("vrfs", {}).items()
|
||||
for instance, instance_data in vrf_data.get("instList", {}).items()
|
||||
]
|
||||
|
||||
|
||||
class VerifyOSPFNeighborState(AntaTest):
|
||||
"""
|
||||
Verifies all OSPF neighbors are in FULL state.
|
||||
"""Verifies all OSPF neighbors are in FULL state.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all OSPF neighbors are in FULL state.
|
||||
* Failure: The test will fail if some OSPF neighbors are not in FULL state.
|
||||
* Skipped: The test will be skipped if no OSPF neighbor is found.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
ospf:
|
||||
- VerifyOSPFNeighborState:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyOSPFNeighborState"
|
||||
description = "Verifies all OSPF neighbors are in FULL state."
|
||||
categories = ["ospf"]
|
||||
commands = [AntaCommand(command="show ip ospf neighbor")]
|
||||
categories: ClassVar[list[str]] = ["ospf"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyOSPFNeighborState."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if _count_ospf_neighbor(command_output) == 0:
|
||||
self.result.is_skipped("no OSPF neighbor found")
|
||||
|
@ -67,21 +126,36 @@ class VerifyOSPFNeighborState(AntaTest):
|
|||
|
||||
|
||||
class VerifyOSPFNeighborCount(AntaTest):
|
||||
"""
|
||||
Verifies the number of OSPF neighbors in FULL state is the one we expect.
|
||||
"""Verifies the number of OSPF neighbors in FULL state is the one we expect.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the number of OSPF neighbors in FULL state is the one we expect.
|
||||
* Failure: The test will fail if the number of OSPF neighbors in FULL state is not the one we expect.
|
||||
* Skipped: The test will be skipped if no OSPF neighbor is found.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
ospf:
|
||||
- VerifyOSPFNeighborCount:
|
||||
number: 3
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyOSPFNeighborCount"
|
||||
description = "Verifies the number of OSPF neighbors in FULL state is the one we expect."
|
||||
categories = ["ospf"]
|
||||
commands = [AntaCommand(command="show ip ospf neighbor")]
|
||||
categories: ClassVar[list[str]] = ["ospf"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyOSPFNeighborCount test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
number: int
|
||||
"""The expected number of OSPF neighbors in FULL state"""
|
||||
"""The expected number of OSPF neighbors in FULL state."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyOSPFNeighborCount."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if (neighbor_count := _count_ospf_neighbor(command_output)) == 0:
|
||||
self.result.is_skipped("no OSPF neighbor found")
|
||||
|
@ -90,6 +164,45 @@ class VerifyOSPFNeighborCount(AntaTest):
|
|||
if neighbor_count != self.inputs.number:
|
||||
self.result.is_failure(f"device has {neighbor_count} neighbors (expected {self.inputs.number})")
|
||||
not_full_neighbors = _get_not_full_ospf_neighbors(command_output)
|
||||
print(not_full_neighbors)
|
||||
if not_full_neighbors:
|
||||
self.result.is_failure(f"Some neighbors are not correctly configured: {not_full_neighbors}.")
|
||||
|
||||
|
||||
class VerifyOSPFMaxLSA(AntaTest):
|
||||
"""Verifies LSAs present in the OSPF link state database did not cross the maximum LSA Threshold.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all OSPF instances did not cross the maximum LSA Threshold.
|
||||
* Failure: The test will fail if some OSPF instances crossed the maximum LSA Threshold.
|
||||
* Skipped: The test will be skipped if no OSPF instance is found.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
ospf:
|
||||
- VerifyOSPFMaxLSA:
|
||||
```
|
||||
"""
|
||||
|
||||
description = "Verifies all OSPF instances did not cross the maximum LSA threshold."
|
||||
categories: ClassVar[list[str]] = ["ospf"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyOSPFMaxLSA."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
ospf_instance_info = _get_ospf_max_lsa_info(command_output)
|
||||
if not ospf_instance_info:
|
||||
self.result.is_skipped("No OSPF instance found.")
|
||||
return
|
||||
all_instances_within_threshold = all(instance["numLsa"] <= instance["maxLsa"] * (instance["maxLsaThreshold"] / 100) for instance in ospf_instance_info)
|
||||
if all_instances_within_threshold:
|
||||
self.result.is_success()
|
||||
else:
|
||||
exceeded_instances = [
|
||||
instance["instance"] for instance in ospf_instance_info if instance["numLsa"] > instance["maxLsa"] * (instance["maxLsaThreshold"] / 100)
|
||||
]
|
||||
self.result.is_failure(f"OSPF Instances {exceeded_instances} crossed the maximum LSA threshold.")
|
||||
|
|
|
@ -1,45 +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.
|
||||
"""
|
||||
Test functions related to the EOS various security settings
|
||||
"""
|
||||
"""Module related to the EOS various security tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from datetime import datetime
|
||||
from typing import List, Union
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, ClassVar, get_args
|
||||
|
||||
from pydantic import BaseModel, Field, conint, model_validator
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from anta.custom_types import EcdsaKeySize, EncryptionAlgorithm, 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.tools.get_item import get_item
|
||||
from anta.tools.get_value import get_value
|
||||
from anta.tools.utils import get_failed_logs
|
||||
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):
|
||||
"""
|
||||
Verifies if the SSHD agent is disabled in the default VRF.
|
||||
"""Verifies if the SSHD agent is disabled in the default VRF.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the SSHD agent is disabled in the default VRF.
|
||||
* failure: The test will fail if the SSHD agent is NOT disabled in the default VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SSHD agent is disabled in the default VRF.
|
||||
* Failure: The test will fail if the SSHD agent is NOT disabled in the default VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifySSHStatus:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySSHStatus"
|
||||
description = "Verifies if the SSHD agent is disabled in the default VRF."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management ssh", ofmt="text")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh", ofmt="text")]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySSHStatus."""
|
||||
command_output = self.instance_commands[0].text_output
|
||||
|
||||
line = [line for line in command_output.split("\n") if line.startswith("SSHD status")][0]
|
||||
status = line.split("is ")[1]
|
||||
try:
|
||||
line = next(line for line in command_output.split("\n") if line.startswith("SSHD status"))
|
||||
except StopIteration:
|
||||
self.result.is_failure("Could not find SSH status in returned output.")
|
||||
return
|
||||
status = line.split()[-1]
|
||||
|
||||
if status == "disabled":
|
||||
self.result.is_success()
|
||||
|
@ -48,97 +64,123 @@ class VerifySSHStatus(AntaTest):
|
|||
|
||||
|
||||
class VerifySSHIPv4Acl(AntaTest):
|
||||
"""
|
||||
Verifies if the SSHD agent has the right number IPv4 ACL(s) configured for a specified VRF.
|
||||
"""Verifies if the SSHD agent has the right number IPv4 ACL(s) configured for a specified VRF.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the SSHD agent has the provided number of IPv4 ACL(s) in the specified VRF.
|
||||
* failure: The test will fail if the SSHD agent has not the right number of IPv4 ACL(s) in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SSHD agent has the provided number of IPv4 ACL(s) in the specified VRF.
|
||||
* Failure: The test will fail if the SSHD agent has not the right number of IPv4 ACL(s) in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifySSHIPv4Acl:
|
||||
number: 3
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySSHIPv4Acl"
|
||||
description = "Verifies if the SSHD agent has IPv4 ACL(s) configured."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management ssh ip access-list summary")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ip access-list summary", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
number: conint(ge=0) # type:ignore
|
||||
"""The number of expected IPv4 ACL(s)"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySSHIPv4Acl test."""
|
||||
|
||||
number: PositiveInteger
|
||||
"""The number of expected IPv4 ACL(s)."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF in which to check for the SSHD agent"""
|
||||
"""The name of the VRF in which to check for the SSHD agent. Defaults to `default` VRF."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySSHIPv4Acl."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
ipv4_acl_list = command_output["ipAclList"]["aclList"]
|
||||
ipv4_acl_number = len(ipv4_acl_list)
|
||||
not_configured_acl_list = []
|
||||
if ipv4_acl_number != self.inputs.number:
|
||||
self.result.is_failure(f"Expected {self.inputs.number} SSH IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
|
||||
return
|
||||
for ipv4_acl in ipv4_acl_list:
|
||||
if self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]:
|
||||
not_configured_acl_list.append(ipv4_acl["name"])
|
||||
if not_configured_acl_list:
|
||||
self.result.is_failure(f"SSH IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
|
||||
|
||||
not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
|
||||
|
||||
if not_configured_acl:
|
||||
self.result.is_failure(f"SSH IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifySSHIPv6Acl(AntaTest):
|
||||
"""
|
||||
Verifies if the SSHD agent has the right number IPv6 ACL(s) configured for a specified VRF.
|
||||
"""Verifies if the SSHD agent has the right number IPv6 ACL(s) configured for a specified VRF.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the SSHD agent has the provided number of IPv6 ACL(s) in the specified VRF.
|
||||
* failure: The test will fail if the SSHD agent has not the right number of IPv6 ACL(s) in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SSHD agent has the provided number of IPv6 ACL(s) in the specified VRF.
|
||||
* Failure: The test will fail if the SSHD agent has not the right number of IPv6 ACL(s) in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifySSHIPv6Acl:
|
||||
number: 3
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySSHIPv6Acl"
|
||||
description = "Verifies if the SSHD agent has IPv6 ACL(s) configured."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management ssh ipv6 access-list summary")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ipv6 access-list summary", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
number: conint(ge=0) # type:ignore
|
||||
"""The number of expected IPv6 ACL(s)"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySSHIPv6Acl test."""
|
||||
|
||||
number: PositiveInteger
|
||||
"""The number of expected IPv6 ACL(s)."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF in which to check for the SSHD agent"""
|
||||
"""The name of the VRF in which to check for the SSHD agent. Defaults to `default` VRF."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySSHIPv6Acl."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
|
||||
ipv6_acl_number = len(ipv6_acl_list)
|
||||
not_configured_acl_list = []
|
||||
if ipv6_acl_number != self.inputs.number:
|
||||
self.result.is_failure(f"Expected {self.inputs.number} SSH IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
|
||||
return
|
||||
for ipv6_acl in ipv6_acl_list:
|
||||
if self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]:
|
||||
not_configured_acl_list.append(ipv6_acl["name"])
|
||||
if not_configured_acl_list:
|
||||
self.result.is_failure(f"SSH IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
|
||||
|
||||
not_configured_acl = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
|
||||
|
||||
if not_configured_acl:
|
||||
self.result.is_failure(f"SSH IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifyTelnetStatus(AntaTest):
|
||||
"""
|
||||
Verifies if Telnet is disabled in the default VRF.
|
||||
"""Verifies if Telnet is disabled in the default VRF.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if Telnet is disabled in the default VRF.
|
||||
* failure: The test will fail if Telnet is NOT disabled in the default VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if Telnet is disabled in the default VRF.
|
||||
* Failure: The test will fail if Telnet is NOT disabled in the default VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyTelnetStatus:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTelnetStatus"
|
||||
description = "Verifies if Telnet is disabled in the default VRF."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management telnet")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management telnet", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTelnetStatus."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["serverState"] == "disabled":
|
||||
self.result.is_success()
|
||||
|
@ -147,21 +189,27 @@ class VerifyTelnetStatus(AntaTest):
|
|||
|
||||
|
||||
class VerifyAPIHttpStatus(AntaTest):
|
||||
"""
|
||||
Verifies if eAPI HTTP server is disabled globally.
|
||||
"""Verifies if eAPI HTTP server is disabled globally.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if eAPI HTTP server is disabled globally.
|
||||
* failure: The test will fail if eAPI HTTP server is NOT disabled globally.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if eAPI HTTP server is disabled globally.
|
||||
* Failure: The test will fail if eAPI HTTP server is NOT disabled globally.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyAPIHttpStatus:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAPIHttpStatus"
|
||||
description = "Verifies if eAPI HTTP server is disabled globally."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management api http-commands")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAPIHttpStatus."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["enabled"] and not command_output["httpServer"]["running"]:
|
||||
self.result.is_success()
|
||||
|
@ -170,25 +218,35 @@ class VerifyAPIHttpStatus(AntaTest):
|
|||
|
||||
|
||||
class VerifyAPIHttpsSSL(AntaTest):
|
||||
"""
|
||||
Verifies if eAPI HTTPS server SSL profile is configured and valid.
|
||||
"""Verifies if eAPI HTTPS server SSL profile is configured and valid.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the eAPI HTTPS server SSL profile is configured and valid.
|
||||
* failure: The test will fail if the eAPI HTTPS server SSL profile is NOT configured, misconfigured or invalid.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the eAPI HTTPS server SSL profile is configured and valid.
|
||||
* Failure: The test will fail if the eAPI HTTPS server SSL profile is NOT configured, misconfigured or invalid.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyAPIHttpsSSL:
|
||||
profile: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAPIHttpsSSL"
|
||||
description = "Verifies if the eAPI has a valid SSL profile."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management api http-commands")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyAPIHttpsSSL test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
profile: str
|
||||
"""SSL profile to verify"""
|
||||
"""SSL profile to verify."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAPIHttpsSSL."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
try:
|
||||
if command_output["sslProfile"]["name"] == self.inputs.profile and command_output["sslProfile"]["state"] == "valid":
|
||||
|
@ -201,110 +259,143 @@ class VerifyAPIHttpsSSL(AntaTest):
|
|||
|
||||
|
||||
class VerifyAPIIPv4Acl(AntaTest):
|
||||
"""
|
||||
Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF.
|
||||
"""Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if eAPI has the provided number of IPv4 ACL(s) in the specified VRF.
|
||||
* failure: The test will fail if eAPI has not the right number of IPv4 ACL(s) in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if eAPI has the provided number of IPv4 ACL(s) in the specified VRF.
|
||||
* Failure: The test will fail if eAPI has not the right number of IPv4 ACL(s) in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyAPIIPv4Acl:
|
||||
number: 3
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAPIIPv4Acl"
|
||||
description = "Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management api http-commands ip access-list summary")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ip access-list summary", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
number: conint(ge=0) # type:ignore
|
||||
"""The number of expected IPv4 ACL(s)"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input parameters for the VerifyAPIIPv4Acl test."""
|
||||
|
||||
number: PositiveInteger
|
||||
"""The number of expected IPv4 ACL(s)."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF in which to check for eAPI"""
|
||||
"""The name of the VRF in which to check for eAPI. Defaults to `default` VRF."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAPIIPv4Acl."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
ipv4_acl_list = command_output["ipAclList"]["aclList"]
|
||||
ipv4_acl_number = len(ipv4_acl_list)
|
||||
not_configured_acl_list = []
|
||||
if ipv4_acl_number != self.inputs.number:
|
||||
self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
|
||||
return
|
||||
for ipv4_acl in ipv4_acl_list:
|
||||
if self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]:
|
||||
not_configured_acl_list.append(ipv4_acl["name"])
|
||||
if not_configured_acl_list:
|
||||
self.result.is_failure(f"eAPI IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
|
||||
|
||||
not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
|
||||
|
||||
if not_configured_acl:
|
||||
self.result.is_failure(f"eAPI IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifyAPIIPv6Acl(AntaTest):
|
||||
"""
|
||||
Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF.
|
||||
"""Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if eAPI has the provided number of IPv6 ACL(s) in the specified VRF.
|
||||
* failure: The test will fail if eAPI has not the right number of IPv6 ACL(s) in the specified VRF.
|
||||
* skipped: The test will be skipped if the number of IPv6 ACL(s) or VRF parameter is not provided.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if eAPI has the provided number of IPv6 ACL(s) in the specified VRF.
|
||||
* Failure: The test will fail if eAPI has not the right number of IPv6 ACL(s) in the specified VRF.
|
||||
* Skipped: The test will be skipped if the number of IPv6 ACL(s) or VRF parameter is not provided.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyAPIIPv6Acl:
|
||||
number: 3
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAPIIPv6Acl"
|
||||
description = "Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management api http-commands ipv6 access-list summary")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ipv6 access-list summary", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
number: conint(ge=0) # type:ignore
|
||||
"""The number of expected IPv6 ACL(s)"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input parameters for the VerifyAPIIPv6Acl test."""
|
||||
|
||||
number: PositiveInteger
|
||||
"""The number of expected IPv6 ACL(s)."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF in which to check for eAPI"""
|
||||
"""The name of the VRF in which to check for eAPI. Defaults to `default` VRF."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAPIIPv6Acl."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
|
||||
ipv6_acl_number = len(ipv6_acl_list)
|
||||
not_configured_acl_list = []
|
||||
if ipv6_acl_number != self.inputs.number:
|
||||
self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
|
||||
return
|
||||
for ipv6_acl in ipv6_acl_list:
|
||||
if self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]:
|
||||
not_configured_acl_list.append(ipv6_acl["name"])
|
||||
if not_configured_acl_list:
|
||||
self.result.is_failure(f"eAPI IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
|
||||
|
||||
not_configured_acl = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
|
||||
|
||||
if not_configured_acl:
|
||||
self.result.is_failure(f"eAPI IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifyAPISSLCertificate(AntaTest):
|
||||
"""
|
||||
Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size.
|
||||
"""Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the certificate's expiry date is greater than the threshold,
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the certificate's expiry date is greater than the threshold,
|
||||
and the certificate has the correct name, encryption algorithm, and key size.
|
||||
* failure: The test will fail if the certificate is expired or is going to expire,
|
||||
* Failure: The test will fail if the certificate is expired or is going to expire,
|
||||
or if the certificate has an incorrect name, encryption algorithm, or key size.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyAPISSLCertificate:
|
||||
certificates:
|
||||
- certificate_name: ARISTA_SIGNING_CA.crt
|
||||
expiry_threshold: 30
|
||||
common_name: AristaIT-ICA ECDSA Issuing Cert Authority
|
||||
encryption_algorithm: ECDSA
|
||||
key_size: 256
|
||||
- certificate_name: ARISTA_ROOT_CA.crt
|
||||
expiry_threshold: 30
|
||||
common_name: Arista Networks Internal IT Root Cert Authority
|
||||
encryption_algorithm: RSA
|
||||
key_size: 4096
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAPISSLCertificate"
|
||||
description = "Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management security ssl certificate"), AntaCommand(command="show clock")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaCommand(command="show management security ssl certificate", revision=1),
|
||||
AntaCommand(command="show clock", revision=1),
|
||||
]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""
|
||||
Input parameters for the VerifyAPISSLCertificate test.
|
||||
"""
|
||||
"""Input parameters for the VerifyAPISSLCertificate test."""
|
||||
|
||||
certificates: List[APISSLCertificates]
|
||||
"""List of API SSL certificates"""
|
||||
certificates: list[APISSLCertificate]
|
||||
"""List of API SSL certificates."""
|
||||
|
||||
class APISSLCertificates(BaseModel):
|
||||
"""
|
||||
This class defines the details of an API SSL certificate.
|
||||
"""
|
||||
class APISSLCertificate(BaseModel):
|
||||
"""Model for an API SSL certificate."""
|
||||
|
||||
certificate_name: str
|
||||
"""The name of the certificate to be verified."""
|
||||
|
@ -314,31 +405,30 @@ class VerifyAPISSLCertificate(AntaTest):
|
|||
"""The common subject name of the certificate."""
|
||||
encryption_algorithm: EncryptionAlgorithm
|
||||
"""The encryption algorithm of the certificate."""
|
||||
key_size: Union[RsaKeySize, EcdsaKeySize]
|
||||
key_size: RsaKeySize | EcdsaKeySize
|
||||
"""The encryption algorithm key size of the certificate."""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_inputs(self: BaseModel) -> BaseModel:
|
||||
"""
|
||||
Validate the key size provided to the APISSLCertificates class.
|
||||
def validate_inputs(self) -> Self:
|
||||
"""Validate the key size provided to the APISSLCertificates class.
|
||||
|
||||
If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}.
|
||||
|
||||
If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}.
|
||||
"""
|
||||
if self.encryption_algorithm == "RSA" and self.key_size not in get_args(RsaKeySize):
|
||||
msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {get_args(RsaKeySize)}."
|
||||
raise ValueError(msg)
|
||||
|
||||
if self.encryption_algorithm == "RSA" and self.key_size not in RsaKeySize.__args__:
|
||||
raise ValueError(f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {RsaKeySize.__args__}.")
|
||||
|
||||
if self.encryption_algorithm == "ECDSA" and self.key_size not in EcdsaKeySize.__args__:
|
||||
raise ValueError(
|
||||
f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {EcdsaKeySize.__args__}."
|
||||
)
|
||||
if self.encryption_algorithm == "ECDSA" and self.key_size not in get_args(EcdsaKeySize):
|
||||
msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {get_args(EcdsaKeySize)}."
|
||||
raise ValueError(msg)
|
||||
|
||||
return self
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAPISSLCertificate."""
|
||||
# Mark the result as success by default
|
||||
self.result.is_success()
|
||||
|
||||
|
@ -356,7 +446,7 @@ class VerifyAPISSLCertificate(AntaTest):
|
|||
continue
|
||||
|
||||
expiry_time = certificate_data["notAfter"]
|
||||
day_difference = (datetime.fromtimestamp(expiry_time) - datetime.fromtimestamp(current_timestamp)).days
|
||||
day_difference = (datetime.fromtimestamp(expiry_time, tz=timezone.utc) - datetime.fromtimestamp(current_timestamp, tz=timezone.utc)).days
|
||||
|
||||
# Verify certificate expiry
|
||||
if 0 < day_difference < certificate.expiry_threshold:
|
||||
|
@ -381,27 +471,37 @@ class VerifyAPISSLCertificate(AntaTest):
|
|||
|
||||
|
||||
class VerifyBannerLogin(AntaTest):
|
||||
"""
|
||||
Verifies the login banner of a device.
|
||||
"""Verifies the login banner of a device.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the login banner matches the provided input.
|
||||
* failure: The test will fail if the login banner does not match the provided input.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the login banner matches the provided input.
|
||||
* Failure: The test will fail if the login banner does not match the provided input.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyBannerLogin:
|
||||
login_banner: |
|
||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyBannerLogin"
|
||||
description = "Verifies the login banner of a device."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show banner login")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner login", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Defines the input parameters for this test case."""
|
||||
"""Input model for the VerifyBannerLogin test."""
|
||||
|
||||
login_banner: str
|
||||
"""Expected login banner of the device."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyBannerLogin."""
|
||||
login_banner = self.instance_commands[0].json_output["loginBanner"]
|
||||
|
||||
# Remove leading and trailing whitespaces from each line
|
||||
|
@ -413,27 +513,37 @@ class VerifyBannerLogin(AntaTest):
|
|||
|
||||
|
||||
class VerifyBannerMotd(AntaTest):
|
||||
"""
|
||||
Verifies the motd banner of a device.
|
||||
"""Verifies the motd banner of a device.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the motd banner matches the provided input.
|
||||
* failure: The test will fail if the motd banner does not match the provided input.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the motd banner matches the provided input.
|
||||
* Failure: The test will fail if the motd banner does not match the provided input.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyBannerMotd:
|
||||
motd_banner: |
|
||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyBannerMotd"
|
||||
description = "Verifies the motd banner of a device."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show banner motd")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner motd", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Defines the input parameters for this test case."""
|
||||
"""Input model for the VerifyBannerMotd test."""
|
||||
|
||||
motd_banner: str
|
||||
"""Expected motd banner of the device."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyBannerMotd."""
|
||||
motd_banner = self.instance_commands[0].json_output["motd"]
|
||||
|
||||
# Remove leading and trailing whitespaces from each line
|
||||
|
@ -445,52 +555,75 @@ class VerifyBannerMotd(AntaTest):
|
|||
|
||||
|
||||
class VerifyIPv4ACL(AntaTest):
|
||||
"""
|
||||
Verifies the configuration of IPv4 ACLs.
|
||||
"""Verifies the configuration of IPv4 ACLs.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if an IPv4 ACL is configured with the correct sequence entries.
|
||||
* failure: The test will fail if an IPv4 ACL is not configured or entries are not in sequence.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if an IPv4 ACL is configured with the correct sequence entries.
|
||||
* Failure: The test will fail if an IPv4 ACL is not configured or entries are not in sequence.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyIPv4ACL:
|
||||
ipv4_access_lists:
|
||||
- name: default-control-plane-acl
|
||||
entries:
|
||||
- sequence: 10
|
||||
action: permit icmp any any
|
||||
- sequence: 20
|
||||
action: permit ip any any tracked
|
||||
- sequence: 30
|
||||
action: permit udp any any eq bfd ttl eq 255
|
||||
- name: LabTest
|
||||
entries:
|
||||
- sequence: 10
|
||||
action: permit icmp any any
|
||||
- sequence: 20
|
||||
action: permit tcp any any range 5900 5910
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyIPv4ACL"
|
||||
description = "Verifies the configuration of IPv4 ACLs."
|
||||
categories = ["security"]
|
||||
commands = [AntaTemplate(template="show ip access-lists {acl}")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip access-lists {acl}", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Inputs for the VerifyIPv4ACL test."""
|
||||
"""Input model for the VerifyIPv4ACL test."""
|
||||
|
||||
ipv4_access_lists: List[IPv4ACL]
|
||||
"""List of IPv4 ACLs to verify"""
|
||||
ipv4_access_lists: list[IPv4ACL]
|
||||
"""List of IPv4 ACLs to verify."""
|
||||
|
||||
class IPv4ACL(BaseModel):
|
||||
"""Detail of IPv4 ACL"""
|
||||
"""Model for an IPv4 ACL."""
|
||||
|
||||
name: str
|
||||
"""Name of IPv4 ACL"""
|
||||
"""Name of IPv4 ACL."""
|
||||
|
||||
entries: List[IPv4ACLEntries]
|
||||
"""List of IPv4 ACL entries"""
|
||||
entries: list[IPv4ACLEntry]
|
||||
"""List of IPv4 ACL entries."""
|
||||
|
||||
class IPv4ACLEntries(BaseModel):
|
||||
"""IPv4 ACL entries details"""
|
||||
class IPv4ACLEntry(BaseModel):
|
||||
"""Model for an IPv4 ACL entry."""
|
||||
|
||||
sequence: int = Field(ge=1, le=4294967295)
|
||||
"""Sequence number of an ACL entry"""
|
||||
"""Sequence number of an ACL entry."""
|
||||
action: str
|
||||
"""Action of an ACL entry"""
|
||||
"""Action of an ACL entry."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
return [template.render(acl=acl.name, entries=acl.entries) for acl in self.inputs.ipv4_access_lists]
|
||||
"""Render the template for each input ACL."""
|
||||
return [template.render(acl=acl.name) for acl in self.inputs.ipv4_access_lists]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyIPv4ACL."""
|
||||
self.result.is_success()
|
||||
for command_output in self.instance_commands:
|
||||
for command_output, acl in zip(self.instance_commands, self.inputs.ipv4_access_lists):
|
||||
# Collecting input ACL details
|
||||
acl_name = command_output.params["acl"]
|
||||
acl_entries = command_output.params["entries"]
|
||||
acl_name = command_output.params.acl
|
||||
# Retrieve the expected entries from the inputs
|
||||
acl_entries = acl.entries
|
||||
|
||||
# Check if ACL is configured
|
||||
ipv4_acl_list = command_output.json_output["aclList"]
|
||||
|
@ -512,3 +645,173 @@ class VerifyIPv4ACL(AntaTest):
|
|||
|
||||
if failed_log != f"{acl_name}:\n":
|
||||
self.result.is_failure(f"{failed_log}")
|
||||
|
||||
|
||||
class VerifyIPSecConnHealth(AntaTest):
|
||||
"""Verifies all IPv4 security connections.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all the IPv4 security connections are established in all vrf.
|
||||
* Failure: The test will fail if IPv4 security is not configured or any of IPv4 security connections are not established in any vrf.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyIPSecConnHealth:
|
||||
```
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip security connection vrf all")]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyIPSecConnHealth."""
|
||||
self.result.is_success()
|
||||
failure_conn = []
|
||||
command_output = self.instance_commands[0].json_output["connections"]
|
||||
|
||||
# Check if IP security connection is configured
|
||||
if not command_output:
|
||||
self.result.is_failure("No IPv4 security connection configured.")
|
||||
return
|
||||
|
||||
# Iterate over all ipsec connections
|
||||
for conn_data in command_output.values():
|
||||
state = next(iter(conn_data["pathDict"].values()))
|
||||
if state != "Established":
|
||||
source = conn_data.get("saddr")
|
||||
destination = conn_data.get("daddr")
|
||||
vrf = conn_data.get("tunnelNs")
|
||||
failure_conn.append(f"source:{source} destination:{destination} vrf:{vrf}")
|
||||
if failure_conn:
|
||||
failure_msg = "\n".join(failure_conn)
|
||||
self.result.is_failure(f"The following IPv4 security connections are not established:\n{failure_msg}.")
|
||||
|
||||
|
||||
class VerifySpecificIPSecConn(AntaTest):
|
||||
"""Verifies the IPv4 security connections.
|
||||
|
||||
This test performs the following checks for each peer:
|
||||
|
||||
1. Validates that the VRF is configured.
|
||||
2. Checks for the presence of IPv4 security connections for the specified peer.
|
||||
3. For each relevant peer:
|
||||
- If source and destination addresses are provided, verifies the security connection for the specific path exists and is `Established`.
|
||||
- If no addresses are provided, verifies that all security connections associated with the peer are `Established`.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: If all checks pass for all specified IPv4 security connections.
|
||||
* Failure: If any of the following occur:
|
||||
- No IPv4 security connections are found for the peer
|
||||
- The security connection is not established for the specified path or any of the peer connections is not established when no path is specified.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifySpecificIPSecConn:
|
||||
ip_security_connections:
|
||||
- peer: 10.255.0.1
|
||||
- peer: 10.255.0.2
|
||||
vrf: default
|
||||
connections:
|
||||
- source_address: 100.64.3.2
|
||||
destination_address: 100.64.2.2
|
||||
- source_address: 172.18.3.2
|
||||
destination_address: 172.18.2.2
|
||||
```
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip security connection vrf {vrf} path peer {peer}", revision=2)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySpecificIPSecConn test."""
|
||||
|
||||
ip_security_connections: list[IPSecPeer]
|
||||
"""List of IP4v security peers."""
|
||||
IPSecPeers: ClassVar[type[IPSecPeers]] = IPSecPeers
|
||||
"""To maintain backward compatibility."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render the template for each input IP Sec connection."""
|
||||
return [template.render(peer=conn.peer, vrf=conn.vrf) for conn in self.inputs.ip_security_connections]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySpecificIPSecConn."""
|
||||
self.result.is_success()
|
||||
|
||||
for command_output, input_peer in zip(self.instance_commands, self.inputs.ip_security_connections):
|
||||
conn_output = command_output.json_output["connections"]
|
||||
conn_input = input_peer.connections
|
||||
vrf = input_peer.vrf
|
||||
|
||||
# Check if IPv4 security connection is configured
|
||||
if not conn_output:
|
||||
self.result.is_failure(f"{input_peer} - Not configured")
|
||||
continue
|
||||
|
||||
# If connection details are not provided then check all connections of a peer
|
||||
if conn_input is None:
|
||||
for conn_data in conn_output.values():
|
||||
state = next(iter(conn_data["pathDict"].values()))
|
||||
if state != "Established":
|
||||
source = conn_data.get("saddr")
|
||||
destination = conn_data.get("daddr")
|
||||
self.result.is_failure(
|
||||
f"{input_peer} Source: {source} Destination: {destination} - Connection down - Expected: Established, Actual: {state}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Create a dictionary of existing connections for faster lookup
|
||||
existing_connections = {
|
||||
(conn_data.get("saddr"), conn_data.get("daddr"), conn_data.get("tunnelNs")): next(iter(conn_data["pathDict"].values()))
|
||||
for conn_data in conn_output.values()
|
||||
}
|
||||
for connection in conn_input:
|
||||
source_input = str(connection.source_address)
|
||||
destination_input = str(connection.destination_address)
|
||||
|
||||
if (source_input, destination_input, vrf) in existing_connections:
|
||||
existing_state = existing_connections[(source_input, destination_input, vrf)]
|
||||
if existing_state != "Established":
|
||||
failure = f"Expected: Established, Actual: {existing_state}"
|
||||
self.result.is_failure(f"{input_peer} Source: {source_input} Destination: {destination_input} - Connection down - {failure}")
|
||||
else:
|
||||
self.result.is_failure(f"{input_peer} Source: {source_input} Destination: {destination_input} - Connection not found.")
|
||||
|
||||
|
||||
class VerifyHardwareEntropy(AntaTest):
|
||||
"""Verifies hardware entropy generation is enabled on device.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if hardware entropy generation is enabled.
|
||||
* Failure: The test will fail if hardware entropy generation is not enabled.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyHardwareEntropy:
|
||||
```
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management security")]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyHardwareEntropy."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
# Check if hardware entropy generation is enabled.
|
||||
if not command_output.get("hardwareEntropyEnabled"):
|
||||
self.result.is_failure("Hardware entropy generation is disabled.")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
|
|
@ -1,48 +1,51 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to the EOS various services settings
|
||||
"""
|
||||
"""Module related to the EOS various services tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
from typing import List, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from anta.custom_types import ErrDisableInterval, ErrDisableReasons
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
from anta.tools.get_dict_superset import get_dict_superset
|
||||
from anta.tools.get_item import get_item
|
||||
from anta.tools.utils import get_failed_logs
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from anta.custom_types import ErrDisableInterval, ErrDisableReasons
|
||||
from anta.input_models.services import DnsServer
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
from anta.tools import get_dict_superset, get_failed_logs
|
||||
|
||||
|
||||
class VerifyHostname(AntaTest):
|
||||
"""
|
||||
Verifies the hostname of a device.
|
||||
"""Verifies the hostname of a device.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the hostname matches the provided input.
|
||||
* failure: The test will fail if the hostname does not match the provided input.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the hostname matches the provided input.
|
||||
* Failure: The test will fail if the hostname does not match the provided input.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.services:
|
||||
- VerifyHostname:
|
||||
hostname: s1-spine1
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyHostname"
|
||||
description = "Verifies the hostname of a device."
|
||||
categories = ["services"]
|
||||
commands = [AntaCommand(command="show hostname")]
|
||||
categories: ClassVar[list[str]] = ["services"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hostname", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Defines the input parameters for this test case."""
|
||||
"""Input model for the VerifyHostname test."""
|
||||
|
||||
hostname: str
|
||||
"""Expected hostname of the device."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyHostname."""
|
||||
hostname = self.instance_commands[0].json_output["hostname"]
|
||||
|
||||
if hostname != self.inputs.hostname:
|
||||
|
@ -52,35 +55,47 @@ class VerifyHostname(AntaTest):
|
|||
|
||||
|
||||
class VerifyDNSLookup(AntaTest):
|
||||
"""
|
||||
This class verifies the DNS (Domain name service) name to IP address resolution.
|
||||
"""Verifies the DNS (Domain Name Service) name to IP address resolution.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if a domain name is resolved to an IP address.
|
||||
* failure: The test will fail if a domain name does not resolve to an IP address.
|
||||
* error: This test will error out if a domain name is invalid.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if a domain name is resolved to an IP address.
|
||||
* Failure: The test will fail if a domain name does not resolve to an IP address.
|
||||
* Error: This test will error out if a domain name is invalid.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.services:
|
||||
- VerifyDNSLookup:
|
||||
domain_names:
|
||||
- arista.com
|
||||
- www.google.com
|
||||
- arista.ca
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyDNSLookup"
|
||||
description = "Verifies the DNS name to IP address resolution."
|
||||
categories = ["services"]
|
||||
commands = [AntaTemplate(template="bash timeout 10 nslookup {domain}")]
|
||||
categories: ClassVar[list[str]] = ["services"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="bash timeout 10 nslookup {domain}", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Inputs for the VerifyDNSLookup test."""
|
||||
"""Input model for the VerifyDNSLookup test."""
|
||||
|
||||
domain_names: List[str]
|
||||
"""List of domain names"""
|
||||
domain_names: list[str]
|
||||
"""List of domain names."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render the template for each domain name in the input list."""
|
||||
return [template.render(domain=domain_name) for domain_name in self.inputs.domain_names]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyDNSLookup."""
|
||||
self.result.is_success()
|
||||
failed_domains = []
|
||||
for command in self.instance_commands:
|
||||
domain = command.params["domain"]
|
||||
domain = command.params.domain
|
||||
output = command.json_output["messages"][0]
|
||||
if f"Can't find {domain}: No answer" in output:
|
||||
failed_domains.append(domain)
|
||||
|
@ -89,87 +104,109 @@ class VerifyDNSLookup(AntaTest):
|
|||
|
||||
|
||||
class VerifyDNSServers(AntaTest):
|
||||
"""
|
||||
Verifies if the DNS (Domain Name Service) servers are correctly configured.
|
||||
"""Verifies if the DNS (Domain Name Service) servers are correctly configured.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the DNS server specified in the input is configured with the correct VRF and priority.
|
||||
* failure: The test will fail if the DNS server is not configured or if the VRF and priority of the DNS server do not match the input.
|
||||
This test performs the following checks for each specified DNS Server:
|
||||
|
||||
1. Confirming correctly registered with a valid IPv4 or IPv6 address with the designated VRF.
|
||||
2. Ensuring an appropriate priority level.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the DNS server specified in the input is configured with the correct VRF and priority.
|
||||
* Failure: The test will fail if any of the following conditions are met:
|
||||
- The provided DNS server is not configured.
|
||||
- The provided DNS server with designated VRF and priority does not match the expected information.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.services:
|
||||
- VerifyDNSServers:
|
||||
dns_servers:
|
||||
- server_address: 10.14.0.1
|
||||
vrf: default
|
||||
priority: 1
|
||||
- server_address: 10.14.0.11
|
||||
vrf: MGMT
|
||||
priority: 0
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyDNSServers"
|
||||
description = "Verifies if the DNS servers are correctly configured."
|
||||
categories = ["services"]
|
||||
commands = [AntaCommand(command="show ip name-server")]
|
||||
categories: ClassVar[list[str]] = ["services"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip name-server", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Inputs for the VerifyDNSServers test."""
|
||||
"""Input model for the VerifyDNSServers test."""
|
||||
|
||||
dns_servers: List[DnsServers]
|
||||
dns_servers: list[DnsServer]
|
||||
"""List of DNS servers to verify."""
|
||||
|
||||
class DnsServers(BaseModel):
|
||||
"""DNS server details"""
|
||||
|
||||
server_address: Union[IPv4Address, IPv6Address]
|
||||
"""The IPv4/IPv6 address of the DNS server."""
|
||||
vrf: str = "default"
|
||||
"""The VRF for the DNS server. Defaults to 'default' if not provided."""
|
||||
priority: int = Field(ge=0, le=4)
|
||||
"""The priority of the DNS server from 0 to 4, lower is first."""
|
||||
DnsServer: ClassVar[type[DnsServer]] = DnsServer
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
command_output = self.instance_commands[0].json_output["nameServerConfigs"]
|
||||
"""Main test function for VerifyDNSServers."""
|
||||
self.result.is_success()
|
||||
|
||||
command_output = self.instance_commands[0].json_output["nameServerConfigs"]
|
||||
for server in self.inputs.dns_servers:
|
||||
address = str(server.server_address)
|
||||
vrf = server.vrf
|
||||
priority = server.priority
|
||||
input_dict = {"ipAddr": address, "vrf": vrf}
|
||||
|
||||
if get_item(command_output, "ipAddr", address) is None:
|
||||
self.result.is_failure(f"DNS server `{address}` is not configured with any VRF.")
|
||||
continue
|
||||
|
||||
# Check if the DNS server is configured with specified VRF.
|
||||
if (output := get_dict_superset(command_output, input_dict)) is None:
|
||||
self.result.is_failure(f"DNS server `{address}` is not configured with VRF `{vrf}`.")
|
||||
self.result.is_failure(f"{server} - Not configured")
|
||||
continue
|
||||
|
||||
# Check if the DNS server priority matches with expected.
|
||||
if output["priority"] != priority:
|
||||
self.result.is_failure(f"For DNS server `{address}`, the expected priority is `{priority}`, but `{output['priority']}` was found instead.")
|
||||
self.result.is_failure(f"{server} - Incorrect priority - Priority: {output['priority']}")
|
||||
|
||||
|
||||
class VerifyErrdisableRecovery(AntaTest):
|
||||
"""
|
||||
Verifies the errdisable recovery reason, status, and interval.
|
||||
"""Verifies the errdisable recovery reason, status, and interval.
|
||||
|
||||
Expected Results:
|
||||
* Success: The test will pass if the errdisable recovery reason status is enabled and the interval matches the input.
|
||||
* Failure: The test will fail if the errdisable recovery reason is not found, the status is not enabled, or the interval does not match the input.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the errdisable recovery reason status is enabled and the interval matches the input.
|
||||
* Failure: The test will fail if the errdisable recovery reason is not found, the status is not enabled, or the interval does not match the input.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.services:
|
||||
- VerifyErrdisableRecovery:
|
||||
reasons:
|
||||
- reason: acl
|
||||
interval: 30
|
||||
- reason: bpduguard
|
||||
interval: 30
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyErrdisableRecovery"
|
||||
description = "Verifies the errdisable recovery reason, status, and interval."
|
||||
categories = ["services"]
|
||||
commands = [AntaCommand(command="show errdisable recovery", ofmt="text")] # Command does not support JSON output hence using text output
|
||||
categories: ClassVar[list[str]] = ["services"]
|
||||
# NOTE: Only `text` output format is supported for this command
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show errdisable recovery", ofmt="text")]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Inputs for the VerifyErrdisableRecovery test."""
|
||||
"""Input model for the VerifyErrdisableRecovery test."""
|
||||
|
||||
reasons: List[ErrDisableReason]
|
||||
"""List of errdisable reasons"""
|
||||
reasons: list[ErrDisableReason]
|
||||
"""List of errdisable reasons."""
|
||||
|
||||
class ErrDisableReason(BaseModel):
|
||||
"""Details of an errdisable reason"""
|
||||
"""Model for an errdisable reason."""
|
||||
|
||||
reason: ErrDisableReasons
|
||||
"""Type or name of the errdisable reason"""
|
||||
"""Type or name of the errdisable reason."""
|
||||
interval: ErrDisableInterval
|
||||
"""Interval of the reason in seconds"""
|
||||
"""Interval of the reason in seconds."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyErrdisableRecovery."""
|
||||
command_output = self.instance_commands[0].text_output
|
||||
self.result.is_success()
|
||||
for error_reason in self.inputs.reasons:
|
||||
|
|
|
@ -1,38 +1,52 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to the EOS various SNMP settings
|
||||
"""
|
||||
"""Module related to the EOS various SNMP tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import conint
|
||||
from typing import TYPE_CHECKING, ClassVar, get_args
|
||||
|
||||
from anta.custom_types import PositiveInteger, SnmpErrorCounter, SnmpPdu
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
from anta.tools import get_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifySnmpStatus(AntaTest):
|
||||
"""
|
||||
Verifies whether the SNMP agent is enabled in a specified VRF.
|
||||
"""Verifies whether the SNMP agent is enabled in a specified VRF.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the SNMP agent is enabled in the specified VRF.
|
||||
* failure: The test will fail if the SNMP agent is disabled in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SNMP agent is enabled in the specified VRF.
|
||||
* Failure: The test will fail if the SNMP agent is disabled in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.snmp:
|
||||
- VerifySnmpStatus:
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySnmpStatus"
|
||||
description = "Verifies if the SNMP agent is enabled."
|
||||
categories = ["snmp"]
|
||||
commands = [AntaCommand(command="show snmp")]
|
||||
categories: ClassVar[list[str]] = ["snmp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySnmpStatus test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF in which to check for the SNMP agent"""
|
||||
"""The name of the VRF in which to check for the SNMP agent. Defaults to `default` VRF."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySnmpStatus."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["enabled"] and self.inputs.vrf in command_output["vrfs"]["snmpVrfs"]:
|
||||
self.result.is_success()
|
||||
|
@ -41,105 +55,136 @@ class VerifySnmpStatus(AntaTest):
|
|||
|
||||
|
||||
class VerifySnmpIPv4Acl(AntaTest):
|
||||
"""
|
||||
Verifies if the SNMP agent has the right number IPv4 ACL(s) configured for a specified VRF.
|
||||
"""Verifies if the SNMP agent has the right number IPv4 ACL(s) configured for a specified VRF.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the SNMP agent has the provided number of IPv4 ACL(s) in the specified VRF.
|
||||
* failure: The test will fail if the SNMP agent has not the right number of IPv4 ACL(s) in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SNMP agent has the provided number of IPv4 ACL(s) in the specified VRF.
|
||||
* Failure: The test will fail if the SNMP agent has not the right number of IPv4 ACL(s) in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.snmp:
|
||||
- VerifySnmpIPv4Acl:
|
||||
number: 3
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySnmpIPv4Acl"
|
||||
description = "Verifies if the SNMP agent has IPv4 ACL(s) configured."
|
||||
categories = ["snmp"]
|
||||
commands = [AntaCommand(command="show snmp ipv4 access-list summary")]
|
||||
categories: ClassVar[list[str]] = ["snmp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv4 access-list summary", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
number: conint(ge=0) # type:ignore
|
||||
"""The number of expected IPv4 ACL(s)"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySnmpIPv4Acl test."""
|
||||
|
||||
number: PositiveInteger
|
||||
"""The number of expected IPv4 ACL(s)."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF in which to check for the SNMP agent"""
|
||||
"""The name of the VRF in which to check for the SNMP agent. Defaults to `default` VRF."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySnmpIPv4Acl."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
ipv4_acl_list = command_output["ipAclList"]["aclList"]
|
||||
ipv4_acl_number = len(ipv4_acl_list)
|
||||
not_configured_acl_list = []
|
||||
if ipv4_acl_number != self.inputs.number:
|
||||
self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
|
||||
return
|
||||
for ipv4_acl in ipv4_acl_list:
|
||||
if self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]:
|
||||
not_configured_acl_list.append(ipv4_acl["name"])
|
||||
if not_configured_acl_list:
|
||||
self.result.is_failure(f"SNMP IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
|
||||
|
||||
not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
|
||||
|
||||
if not_configured_acl:
|
||||
self.result.is_failure(f"SNMP IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifySnmpIPv6Acl(AntaTest):
|
||||
"""
|
||||
Verifies if the SNMP agent has the right number IPv6 ACL(s) configured for a specified VRF.
|
||||
"""Verifies if the SNMP agent has the right number IPv6 ACL(s) configured for a specified VRF.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the SNMP agent has the provided number of IPv6 ACL(s) in the specified VRF.
|
||||
* failure: The test will fail if the SNMP agent has not the right number of IPv6 ACL(s) in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SNMP agent has the provided number of IPv6 ACL(s) in the specified VRF.
|
||||
* Failure: The test will fail if the SNMP agent has not the right number of IPv6 ACL(s) in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.snmp:
|
||||
- VerifySnmpIPv6Acl:
|
||||
number: 3
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySnmpIPv6Acl"
|
||||
description = "Verifies if the SNMP agent has IPv6 ACL(s) configured."
|
||||
categories = ["snmp"]
|
||||
commands = [AntaCommand(command="show snmp ipv6 access-list summary")]
|
||||
categories: ClassVar[list[str]] = ["snmp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv6 access-list summary", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
number: conint(ge=0) # type:ignore
|
||||
"""The number of expected IPv6 ACL(s)"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySnmpIPv6Acl test."""
|
||||
|
||||
number: PositiveInteger
|
||||
"""The number of expected IPv6 ACL(s)."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF in which to check for the SNMP agent"""
|
||||
"""The name of the VRF in which to check for the SNMP agent. Defaults to `default` VRF."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySnmpIPv6Acl."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
|
||||
ipv6_acl_number = len(ipv6_acl_list)
|
||||
not_configured_acl_list = []
|
||||
if ipv6_acl_number != self.inputs.number:
|
||||
self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
|
||||
return
|
||||
for ipv6_acl in ipv6_acl_list:
|
||||
if self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]:
|
||||
not_configured_acl_list.append(ipv6_acl["name"])
|
||||
if not_configured_acl_list:
|
||||
self.result.is_failure(f"SNMP IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
|
||||
|
||||
acl_not_configured = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
|
||||
|
||||
if acl_not_configured:
|
||||
self.result.is_failure(f"SNMP IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {acl_not_configured}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifySnmpLocation(AntaTest):
|
||||
"""
|
||||
This class verifies the SNMP location of a device.
|
||||
"""Verifies the SNMP location of a device.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the SNMP location matches the provided input.
|
||||
* failure: The test will fail if the SNMP location does not match the provided input.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SNMP location matches the provided input.
|
||||
* Failure: The test will fail if the SNMP location does not match the provided input.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.snmp:
|
||||
- VerifySnmpLocation:
|
||||
location: New York
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySnmpLocation"
|
||||
description = "Verifies the SNMP location of a device."
|
||||
categories = ["snmp"]
|
||||
commands = [AntaCommand(command="show snmp")]
|
||||
categories: ClassVar[list[str]] = ["snmp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Defines the input parameters for this test case."""
|
||||
"""Input model for the VerifySnmpLocation test."""
|
||||
|
||||
location: str
|
||||
"""Expected SNMP location of the device."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
location = self.instance_commands[0].json_output["location"]["location"]
|
||||
"""Main test function for VerifySnmpLocation."""
|
||||
# Verifies the SNMP location is configured.
|
||||
if not (location := get_value(self.instance_commands[0].json_output, "location.location")):
|
||||
self.result.is_failure("SNMP location is not configured.")
|
||||
return
|
||||
|
||||
# Verifies the expected SNMP location.
|
||||
if location != self.inputs.location:
|
||||
self.result.is_failure(f"Expected `{self.inputs.location}` as the location, but found `{location}` instead.")
|
||||
else:
|
||||
|
@ -147,30 +192,150 @@ class VerifySnmpLocation(AntaTest):
|
|||
|
||||
|
||||
class VerifySnmpContact(AntaTest):
|
||||
"""
|
||||
This class verifies the SNMP contact of a device.
|
||||
"""Verifies the SNMP contact of a device.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the SNMP contact matches the provided input.
|
||||
* failure: The test will fail if the SNMP contact does not match the provided input.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SNMP contact matches the provided input.
|
||||
* Failure: The test will fail if the SNMP contact does not match the provided input.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.snmp:
|
||||
- VerifySnmpContact:
|
||||
contact: Jon@example.com
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySnmpContact"
|
||||
description = "Verifies the SNMP contact of a device."
|
||||
categories = ["snmp"]
|
||||
commands = [AntaCommand(command="show snmp")]
|
||||
categories: ClassVar[list[str]] = ["snmp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Defines the input parameters for this test case."""
|
||||
"""Input model for the VerifySnmpContact test."""
|
||||
|
||||
contact: str
|
||||
"""Expected SNMP contact details of the device."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
contact = self.instance_commands[0].json_output["contact"]["contact"]
|
||||
"""Main test function for VerifySnmpContact."""
|
||||
# Verifies the SNMP contact is configured.
|
||||
if not (contact := get_value(self.instance_commands[0].json_output, "contact.contact")):
|
||||
self.result.is_failure("SNMP contact is not configured.")
|
||||
return
|
||||
|
||||
# Verifies the expected SNMP contact.
|
||||
if contact != self.inputs.contact:
|
||||
self.result.is_failure(f"Expected `{self.inputs.contact}` as the contact, but found `{contact}` instead.")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifySnmpPDUCounters(AntaTest):
|
||||
"""Verifies the SNMP PDU counters.
|
||||
|
||||
By default, all SNMP PDU counters will be checked for any non-zero values.
|
||||
An optional list of specific SNMP PDU(s) can be provided for granular testing.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SNMP PDU counter(s) are non-zero/greater than zero.
|
||||
* Failure: The test will fail if the SNMP PDU counter(s) are zero/None/Not Found.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.snmp:
|
||||
- VerifySnmpPDUCounters:
|
||||
pdus:
|
||||
- outTrapPdus
|
||||
- inGetNextPdus
|
||||
```
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["snmp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySnmpPDUCounters test."""
|
||||
|
||||
pdus: list[SnmpPdu] | None = None
|
||||
"""Optional list of SNMP PDU counters to be verified. If not provided, test will verifies all PDU counters."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySnmpPDUCounters."""
|
||||
snmp_pdus = self.inputs.pdus
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
# Verify SNMP PDU counters.
|
||||
if not (pdu_counters := get_value(command_output, "counters")):
|
||||
self.result.is_failure("SNMP counters not found.")
|
||||
return
|
||||
|
||||
# In case SNMP PDUs not provided, It will check all the update error counters.
|
||||
if not snmp_pdus:
|
||||
snmp_pdus = list(get_args(SnmpPdu))
|
||||
|
||||
failures = {pdu: value for pdu in snmp_pdus if (value := pdu_counters.get(pdu, "Not Found")) == "Not Found" or value == 0}
|
||||
|
||||
# Check if any failures
|
||||
if not failures:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(f"The following SNMP PDU counters are not found or have zero PDU counters:\n{failures}")
|
||||
|
||||
|
||||
class VerifySnmpErrorCounters(AntaTest):
|
||||
"""Verifies the SNMP error counters.
|
||||
|
||||
By default, all error counters will be checked for any non-zero values.
|
||||
An optional list of specific error counters can be provided for granular testing.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SNMP error counter(s) are zero/None.
|
||||
* Failure: The test will fail if the SNMP error counter(s) are non-zero/not None/Not Found or is not configured.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.snmp:
|
||||
- VerifySnmpErrorCounters:
|
||||
error_counters:
|
||||
- inVersionErrs
|
||||
- inBadCommunityNames
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["snmp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySnmpErrorCounters test."""
|
||||
|
||||
error_counters: list[SnmpErrorCounter] | None = None
|
||||
"""Optional list of SNMP error counters to be verified. If not provided, test will verifies all error counters."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySnmpErrorCounters."""
|
||||
error_counters = self.inputs.error_counters
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
# Verify SNMP PDU counters.
|
||||
if not (snmp_counters := get_value(command_output, "counters")):
|
||||
self.result.is_failure("SNMP counters not found.")
|
||||
return
|
||||
|
||||
# In case SNMP error counters not provided, It will check all the error counters.
|
||||
if not error_counters:
|
||||
error_counters = list(get_args(SnmpErrorCounter))
|
||||
|
||||
error_counters_not_ok = {counter: value for counter in error_counters if (value := snmp_counters.get(counter))}
|
||||
|
||||
# Check if any failures
|
||||
if not error_counters_not_ok:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(f"The following SNMP error counters are not found or have non-zero error counters:\n{error_counters_not_ok}")
|
||||
|
|
|
@ -1,35 +1,52 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to the EOS software
|
||||
"""
|
||||
"""Module related to the EOS software tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
# Need to keep List for pydantic in python 3.8
|
||||
from typing import List
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyEOSVersion(AntaTest):
|
||||
"""
|
||||
Verifies the device is running one of the allowed EOS version.
|
||||
"""Verifies that the device is running one of the allowed EOS version.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the device is running one of the allowed EOS version.
|
||||
* Failure: The test will fail if the device is not running one of the allowed EOS version.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.software:
|
||||
- VerifyEOSVersion:
|
||||
versions:
|
||||
- 4.25.4M
|
||||
- 4.26.1F
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyEOSVersion"
|
||||
description = "Verifies the device is running one of the allowed EOS version."
|
||||
categories = ["software"]
|
||||
commands = [AntaCommand(command="show version")]
|
||||
description = "Verifies the EOS version of the device."
|
||||
categories: ClassVar[list[str]] = ["software"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
versions: List[str]
|
||||
"""List of allowed EOS versions"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyEOSVersion test."""
|
||||
|
||||
versions: list[str]
|
||||
"""List of allowed EOS versions."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyEOSVersion."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["version"] in self.inputs.versions:
|
||||
self.result.is_success()
|
||||
|
@ -38,21 +55,37 @@ class VerifyEOSVersion(AntaTest):
|
|||
|
||||
|
||||
class VerifyTerminAttrVersion(AntaTest):
|
||||
"""
|
||||
Verifies the device is running one of the allowed TerminAttr version.
|
||||
"""Verifies that he device is running one of the allowed TerminAttr version.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the device is running one of the allowed TerminAttr version.
|
||||
* Failure: The test will fail if the device is not running one of the allowed TerminAttr version.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.software:
|
||||
- VerifyTerminAttrVersion:
|
||||
versions:
|
||||
- v1.13.6
|
||||
- v1.8.0
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTerminAttrVersion"
|
||||
description = "Verifies the device is running one of the allowed TerminAttr version."
|
||||
categories = ["software"]
|
||||
commands = [AntaCommand(command="show version detail")]
|
||||
description = "Verifies the TerminAttr version of the device."
|
||||
categories: ClassVar[list[str]] = ["software"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
versions: List[str]
|
||||
"""List of allowed TerminAttr versions"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyTerminAttrVersion test."""
|
||||
|
||||
versions: list[str]
|
||||
"""List of allowed TerminAttr versions."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTerminAttrVersion."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
command_output_data = command_output["details"]["packages"]["TerminAttr-core"]["version"]
|
||||
if command_output_data in self.inputs.versions:
|
||||
|
@ -62,17 +95,30 @@ class VerifyTerminAttrVersion(AntaTest):
|
|||
|
||||
|
||||
class VerifyEOSExtensions(AntaTest):
|
||||
"""
|
||||
Verifies all EOS extensions installed on the device are enabled for boot persistence.
|
||||
"""Verifies that all EOS extensions installed on the device are enabled for boot persistence.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all EOS extensions installed on the device are enabled for boot persistence.
|
||||
* Failure: The test will fail if some EOS extensions installed on the device are not enabled for boot persistence.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.software:
|
||||
- VerifyEOSExtensions:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyEOSExtensions"
|
||||
description = "Verifies all EOS extensions installed on the device are enabled for boot persistence."
|
||||
categories = ["software"]
|
||||
commands = [AntaCommand(command="show extensions"), AntaCommand(command="show boot-extensions")]
|
||||
categories: ClassVar[list[str]] = ["software"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaCommand(command="show extensions", revision=2),
|
||||
AntaCommand(command="show boot-extensions", revision=1),
|
||||
]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyEOSExtensions."""
|
||||
boot_extensions = []
|
||||
show_extensions_command_output = self.instance_commands[0].json_output
|
||||
show_boot_extensions_command_output = self.instance_commands[1].json_output
|
||||
|
@ -80,9 +126,9 @@ class VerifyEOSExtensions(AntaTest):
|
|||
extension for extension, extension_data in show_extensions_command_output["extensions"].items() if extension_data["status"] == "installed"
|
||||
]
|
||||
for extension in show_boot_extensions_command_output["extensions"]:
|
||||
extension = extension.strip("\n")
|
||||
if extension != "":
|
||||
boot_extensions.append(extension)
|
||||
formatted_extension = extension.strip("\n")
|
||||
if formatted_extension != "":
|
||||
boot_extensions.append(formatted_extension)
|
||||
installed_extensions.sort()
|
||||
boot_extensions.sort()
|
||||
if installed_extensions == boot_extensions:
|
||||
|
|
|
@ -1,52 +1,69 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to various Spanning Tree Protocol (STP) settings
|
||||
"""
|
||||
"""Module related to various Spanning Tree Protocol (STP) tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
# Need to keep List for pydantic in python 3.8
|
||||
from typing import List, Literal
|
||||
from typing import Any, ClassVar, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from anta.custom_types import Vlan
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
from anta.tools.get_value import get_value
|
||||
from anta.tools import get_value
|
||||
|
||||
|
||||
class VerifySTPMode(AntaTest):
|
||||
"""
|
||||
Verifies the configured STP mode for a provided list of VLAN(s).
|
||||
"""Verifies the configured STP mode for a provided list of VLAN(s).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the STP mode is configured properly in the specified VLAN(s).
|
||||
* failure: The test will fail if the STP mode is NOT configured properly for one or more specified VLAN(s).
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the STP mode is configured properly in the specified VLAN(s).
|
||||
* Failure: The test will fail if the STP mode is NOT configured properly for one or more specified VLAN(s).
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.stp:
|
||||
- VerifySTPMode:
|
||||
mode: rapidPvst
|
||||
vlans:
|
||||
- 10
|
||||
- 20
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySTPMode"
|
||||
description = "Verifies the configured STP mode for a provided list of VLAN(s)."
|
||||
categories = ["stp"]
|
||||
commands = [AntaTemplate(template="show spanning-tree vlan {vlan}")]
|
||||
categories: ClassVar[list[str]] = ["stp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree vlan {vlan}", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySTPMode test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
mode: Literal["mstp", "rstp", "rapidPvst"] = "mstp"
|
||||
"""STP mode to verify"""
|
||||
vlans: List[Vlan]
|
||||
"""List of VLAN on which to verify STP mode"""
|
||||
"""STP mode to verify. Supported values: mstp, rstp, rapidPvst. Defaults to mstp."""
|
||||
vlans: list[Vlan]
|
||||
"""List of VLAN on which to verify STP mode."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render the template for each VLAN in the input list."""
|
||||
return [template.render(vlan=vlan) for vlan in self.inputs.vlans]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySTPMode."""
|
||||
not_configured = []
|
||||
wrong_stp_mode = []
|
||||
for command in self.instance_commands:
|
||||
if "vlan" in command.params:
|
||||
vlan_id = command.params["vlan"]
|
||||
if not (stp_mode := get_value(command.json_output, f"spanningTreeVlanInstances.{vlan_id}.spanningTreeVlanInstance.protocol")):
|
||||
vlan_id = command.params.vlan
|
||||
if not (
|
||||
stp_mode := get_value(
|
||||
command.json_output,
|
||||
f"spanningTreeVlanInstances.{vlan_id}.spanningTreeVlanInstance.protocol",
|
||||
)
|
||||
):
|
||||
not_configured.append(vlan_id)
|
||||
elif stp_mode != self.inputs.mode:
|
||||
wrong_stp_mode.append(vlan_id)
|
||||
|
@ -59,21 +76,27 @@ class VerifySTPMode(AntaTest):
|
|||
|
||||
|
||||
class VerifySTPBlockedPorts(AntaTest):
|
||||
"""
|
||||
Verifies there is no STP blocked ports.
|
||||
"""Verifies there is no STP blocked ports.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if there are NO ports blocked by STP.
|
||||
* failure: The test will fail if there are ports blocked by STP.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if there are NO ports blocked by STP.
|
||||
* Failure: The test will fail if there are ports blocked by STP.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.stp:
|
||||
- VerifySTPBlockedPorts:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySTPBlockedPorts"
|
||||
description = "Verifies there is no STP blocked ports."
|
||||
categories = ["stp"]
|
||||
commands = [AntaCommand(command="show spanning-tree blockedports")]
|
||||
categories: ClassVar[list[str]] = ["stp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree blockedports", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySTPBlockedPorts."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if not (stp_instances := command_output["spanningTreeInstances"]):
|
||||
self.result.is_success()
|
||||
|
@ -84,21 +107,27 @@ class VerifySTPBlockedPorts(AntaTest):
|
|||
|
||||
|
||||
class VerifySTPCounters(AntaTest):
|
||||
"""
|
||||
Verifies there is no errors in STP BPDU packets.
|
||||
"""Verifies there is no errors in STP BPDU packets.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if there are NO STP BPDU packet errors under all interfaces participating in STP.
|
||||
* failure: The test will fail if there are STP BPDU packet errors on one or many interface(s).
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if there are NO STP BPDU packet errors under all interfaces participating in STP.
|
||||
* Failure: The test will fail if there are STP BPDU packet errors on one or many interface(s).
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.stp:
|
||||
- VerifySTPCounters:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySTPCounters"
|
||||
description = "Verifies there is no errors in STP BPDU packets."
|
||||
categories = ["stp"]
|
||||
commands = [AntaCommand(command="show spanning-tree counters")]
|
||||
categories: ClassVar[list[str]] = ["stp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree counters", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySTPCounters."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
interfaces_with_errors = [
|
||||
interface for interface, counters in command_output["interfaces"].items() if counters["bpduTaggedError"] or counters["bpduOtherError"] != 0
|
||||
|
@ -110,77 +139,102 @@ class VerifySTPCounters(AntaTest):
|
|||
|
||||
|
||||
class VerifySTPForwardingPorts(AntaTest):
|
||||
"""
|
||||
Verifies that all interfaces are in a forwarding state for a provided list of VLAN(s).
|
||||
"""Verifies that all interfaces are in a forwarding state for a provided list of VLAN(s).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if all interfaces are in a forwarding state for the specified VLAN(s).
|
||||
* failure: The test will fail if one or many interfaces are NOT in a forwarding state in the specified VLAN(s).
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all interfaces are in a forwarding state for the specified VLAN(s).
|
||||
* Failure: The test will fail if one or many interfaces are NOT in a forwarding state in the specified VLAN(s).
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.stp:
|
||||
- VerifySTPForwardingPorts:
|
||||
vlans:
|
||||
- 10
|
||||
- 20
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySTPForwardingPorts"
|
||||
description = "Verifies that all interfaces are forwarding for a provided list of VLAN(s)."
|
||||
categories = ["stp"]
|
||||
commands = [AntaTemplate(template="show spanning-tree topology vlan {vlan} status")]
|
||||
categories: ClassVar[list[str]] = ["stp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree topology vlan {vlan} status", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
vlans: List[Vlan]
|
||||
"""List of VLAN on which to verify forwarding states"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySTPForwardingPorts test."""
|
||||
|
||||
vlans: list[Vlan]
|
||||
"""List of VLAN on which to verify forwarding states."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render the template for each VLAN in the input list."""
|
||||
return [template.render(vlan=vlan) for vlan in self.inputs.vlans]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySTPForwardingPorts."""
|
||||
not_configured = []
|
||||
not_forwarding = []
|
||||
for command in self.instance_commands:
|
||||
if "vlan" in command.params:
|
||||
vlan_id = command.params["vlan"]
|
||||
vlan_id = command.params.vlan
|
||||
if not (topologies := get_value(command.json_output, "topologies")):
|
||||
not_configured.append(vlan_id)
|
||||
else:
|
||||
interfaces_not_forwarding = []
|
||||
for value in topologies.values():
|
||||
if int(vlan_id) in value["vlans"]:
|
||||
if vlan_id and int(vlan_id) in value["vlans"]:
|
||||
interfaces_not_forwarding = [interface for interface, state in value["interfaces"].items() if state["state"] != "forwarding"]
|
||||
if interfaces_not_forwarding:
|
||||
not_forwarding.append({f"VLAN {vlan_id}": interfaces_not_forwarding})
|
||||
if not_configured:
|
||||
self.result.is_failure(f"STP instance is not configured for the following VLAN(s): {not_configured}")
|
||||
if not_forwarding:
|
||||
self.result.is_failure(f"The following VLAN(s) have interface(s) that are not in a fowarding state: {not_forwarding}")
|
||||
self.result.is_failure(f"The following VLAN(s) have interface(s) that are not in a forwarding state: {not_forwarding}")
|
||||
if not not_configured and not interfaces_not_forwarding:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifySTPRootPriority(AntaTest):
|
||||
"""
|
||||
Verifies the STP root priority for a provided list of VLAN or MST instance ID(s).
|
||||
"""Verifies the STP root priority for a provided list of VLAN or MST instance ID(s).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the STP root priority is configured properly for the specified VLAN or MST instance ID(s).
|
||||
* failure: The test will fail if the STP root priority is NOT configured properly for the specified VLAN or MST instance ID(s).
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the STP root priority is configured properly for the specified VLAN or MST instance ID(s).
|
||||
* Failure: The test will fail if the STP root priority is NOT configured properly for the specified VLAN or MST instance ID(s).
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.stp:
|
||||
- VerifySTPRootPriority:
|
||||
priority: 32768
|
||||
instances:
|
||||
- 10
|
||||
- 20
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySTPRootPriority"
|
||||
description = "Verifies the STP root priority for a provided list of VLAN or MST instance ID(s)."
|
||||
categories = ["stp"]
|
||||
commands = [AntaCommand(command="show spanning-tree root detail")]
|
||||
categories: ClassVar[list[str]] = ["stp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree root detail", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySTPRootPriority test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
priority: int
|
||||
"""STP root priority to verify"""
|
||||
instances: List[Vlan] = []
|
||||
"""STP root priority to verify."""
|
||||
instances: list[Vlan] = Field(default=[])
|
||||
"""List of VLAN or MST instance ID(s). If empty, ALL VLAN or MST instance ID(s) will be verified."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySTPRootPriority."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if not (stp_instances := command_output["instances"]):
|
||||
self.result.is_failure("No STP instances configured")
|
||||
return
|
||||
# Checking the type of instances based on first instance
|
||||
first_name = list(stp_instances)[0]
|
||||
first_name = next(iter(stp_instances))
|
||||
if first_name.startswith("MST"):
|
||||
prefix = "MST"
|
||||
elif first_name.startswith("VL"):
|
||||
|
@ -196,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}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifyStpTopologyChanges(AntaTest):
|
||||
"""Verifies the number of changes across all interfaces in the Spanning Tree Protocol (STP) topology is below a threshold.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the total number of changes across all interfaces is less than the specified threshold.
|
||||
* Failure: The test will fail if the total number of changes across all interfaces meets or exceeds the specified threshold,
|
||||
indicating potential instability in the topology.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.stp:
|
||||
- VerifyStpTopologyChanges:
|
||||
threshold: 10
|
||||
```
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["stp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree topology status detail", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyStpTopologyChanges test."""
|
||||
|
||||
threshold: int
|
||||
"""The threshold number of changes in the STP topology."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyStpTopologyChanges."""
|
||||
failures: dict[str, Any] = {"topologies": {}}
|
||||
|
||||
command_output = self.instance_commands[0].json_output
|
||||
stp_topologies = command_output.get("topologies", {})
|
||||
|
||||
# verifies all available topologies except the "NoStp" topology.
|
||||
stp_topologies.pop("NoStp", None)
|
||||
|
||||
# Verify the STP topology(s).
|
||||
if not stp_topologies:
|
||||
self.result.is_failure("STP is not configured.")
|
||||
return
|
||||
|
||||
# Verifies the number of changes across all interfaces
|
||||
for topology, topology_details in stp_topologies.items():
|
||||
interfaces = {
|
||||
interface: {"Number of changes": num_of_changes}
|
||||
for interface, details in topology_details.get("interfaces", {}).items()
|
||||
if (num_of_changes := details.get("numChanges")) > self.inputs.threshold
|
||||
}
|
||||
if interfaces:
|
||||
failures["topologies"][topology] = interfaces
|
||||
|
||||
if failures["topologies"]:
|
||||
self.result.is_failure(f"The following STP topologies are not configured or number of changes not within the threshold:\n{failures}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
|
154
anta/tests/stun.py
Normal file
154
anta/tests/stun.py
Normal file
|
@ -0,0 +1,154 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""Test functions related to various STUN settings."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from anta.decorators import deprecated_test_class
|
||||
from anta.input_models.stun import StunClientTranslation
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
from anta.tools import get_value
|
||||
|
||||
|
||||
class VerifyStunClientTranslation(AntaTest):
|
||||
"""Verifies the translation for a source address on a STUN client.
|
||||
|
||||
This test performs the following checks for each specified address family:
|
||||
|
||||
1. Validates that there is a translation for the source address on the STUN client.
|
||||
2. If public IP and port details are provided, validates their correctness against the configuration.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: If all of the following conditions are met:
|
||||
- The test will pass if the source address translation is present.
|
||||
- If public IP and port details are provided, they must also match the translation information.
|
||||
* Failure: If any of the following occur:
|
||||
- There is no translation for the source address on the STUN client.
|
||||
- The public IP or port details, if specified, are incorrect.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.stun:
|
||||
- VerifyStunClientTranslation:
|
||||
stun_clients:
|
||||
- source_address: 172.18.3.2
|
||||
public_address: 172.18.3.21
|
||||
source_port: 4500
|
||||
public_port: 6006
|
||||
- source_address: 100.64.3.2
|
||||
public_address: 100.64.3.21
|
||||
source_port: 4500
|
||||
public_port: 6006
|
||||
```
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["stun"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show stun client translations {source_address} {source_port}", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyStunClientTranslation test."""
|
||||
|
||||
stun_clients: list[StunClientTranslation]
|
||||
"""List of STUN clients."""
|
||||
StunClientTranslation: ClassVar[type[StunClientTranslation]] = StunClientTranslation
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render the template for each STUN translation."""
|
||||
return [template.render(source_address=client.source_address, source_port=client.source_port) for client in self.inputs.stun_clients]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyStunClientTranslation."""
|
||||
self.result.is_success()
|
||||
|
||||
# Iterate over each command output and corresponding client input
|
||||
for command, client_input in zip(self.instance_commands, self.inputs.stun_clients):
|
||||
bindings = command.json_output["bindings"]
|
||||
input_public_address = client_input.public_address
|
||||
input_public_port = client_input.public_port
|
||||
|
||||
# If no bindings are found for the STUN client, mark the test as a failure and continue with the next client
|
||||
if not bindings:
|
||||
self.result.is_failure(f"{client_input} - STUN client translation not found.")
|
||||
continue
|
||||
|
||||
# Extract the transaction ID from the bindings
|
||||
transaction_id = next(iter(bindings.keys()))
|
||||
|
||||
# Verifying the public address if provided
|
||||
if input_public_address and str(input_public_address) != (actual_public_address := get_value(bindings, f"{transaction_id}.publicAddress.ip")):
|
||||
self.result.is_failure(f"{client_input} - Incorrect public-facing address - Expected: {input_public_address} Actual: {actual_public_address}")
|
||||
|
||||
# Verifying the public port if provided
|
||||
if input_public_port and input_public_port != (actual_public_port := get_value(bindings, f"{transaction_id}.publicAddress.port")):
|
||||
self.result.is_failure(f"{client_input} - Incorrect public-facing port - Expected: {input_public_port} Actual: {actual_public_port}")
|
||||
|
||||
|
||||
@deprecated_test_class(new_tests=["VerifyStunClientTranslation"], removal_in_version="v2.0.0")
|
||||
class VerifyStunClient(VerifyStunClientTranslation):
|
||||
"""(Deprecated) Verifies the translation for a source address on a STUN client.
|
||||
|
||||
Alias for the VerifyStunClientTranslation test to maintain backward compatibility.
|
||||
When initialized, it will emit a deprecation warning and call the VerifyStunClientTranslation test.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.stun:
|
||||
- VerifyStunClient:
|
||||
stun_clients:
|
||||
- source_address: 172.18.3.2
|
||||
public_address: 172.18.3.21
|
||||
source_port: 4500
|
||||
public_port: 6006
|
||||
```
|
||||
"""
|
||||
|
||||
# TODO: Remove this class in ANTA v2.0.0.
|
||||
|
||||
# required to redefine name an description to overwrite parent class.
|
||||
name = "VerifyStunClient"
|
||||
description = "(Deprecated) Verifies the translation for a source address on a STUN client."
|
||||
|
||||
|
||||
class VerifyStunServer(AntaTest):
|
||||
"""Verifies the STUN server status is enabled and running.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the STUN server status is enabled and running.
|
||||
* Failure: The test will fail if the STUN server is disabled or not running.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.stun:
|
||||
- VerifyStunServer:
|
||||
```
|
||||
"""
|
||||
|
||||
categories: ClassVar[list[str]] = ["stun"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show stun server status", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyStunServer."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
status_disabled = not command_output.get("enabled")
|
||||
not_running = command_output.get("pid") == 0
|
||||
|
||||
if status_disabled and not_running:
|
||||
self.result.is_failure("STUN server status is disabled and not running.")
|
||||
elif status_disabled:
|
||||
self.result.is_failure("STUN server status is disabled.")
|
||||
elif not_running:
|
||||
self.result.is_failure("STUN server is not running.")
|
||||
else:
|
||||
self.result.is_success()
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue