Adding upstream version 0.14.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
ecf5ca3300
commit
6721599912
211 changed files with 12174 additions and 6401 deletions
|
@ -15,8 +15,6 @@
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"settings": {},
|
"settings": {},
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"ms-python.black-formatter",
|
|
||||||
"ms-python.isort",
|
|
||||||
"formulahendry.github-actions",
|
"formulahendry.github-actions",
|
||||||
"matangover.mypy",
|
"matangover.mypy",
|
||||||
"ms-python.mypy-type-checker",
|
"ms-python.mypy-type-checker",
|
||||||
|
|
22
.github/generate_release.py
vendored
22
.github/generate_release.py
vendored
|
@ -1,6 +1,5 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""
|
"""generate_release.py.
|
||||||
generate_release.py
|
|
||||||
|
|
||||||
This script is used to generate the release.yml file as per
|
This script is used to generate the release.yml file as per
|
||||||
https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes
|
https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes
|
||||||
|
@ -20,18 +19,15 @@ CATEGORIES = {
|
||||||
"fix": "Bug Fixes",
|
"fix": "Bug Fixes",
|
||||||
"cut": "Cut",
|
"cut": "Cut",
|
||||||
"doc": "Documentation",
|
"doc": "Documentation",
|
||||||
# "CI": "CI",
|
|
||||||
"bump": "Bump",
|
"bump": "Bump",
|
||||||
# "test": "Test",
|
|
||||||
"revert": "Revert",
|
"revert": "Revert",
|
||||||
"refactor": "Refactoring",
|
"refactor": "Refactoring",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class SafeDumper(yaml.SafeDumper):
|
class SafeDumper(yaml.SafeDumper):
|
||||||
"""
|
"""Make yamllint happy
|
||||||
Make yamllint happy
|
https://github.com/yaml/pyyaml/issues/234#issuecomment-765894586.
|
||||||
https://github.com/yaml/pyyaml/issues/234#issuecomment-765894586
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=R0901,W0613,W1113
|
# pylint: disable=R0901,W0613,W1113
|
||||||
|
@ -60,7 +56,7 @@ if __name__ == "__main__":
|
||||||
{
|
{
|
||||||
"title": "Breaking Changes",
|
"title": "Breaking Changes",
|
||||||
"labels": breaking_labels,
|
"labels": breaking_labels,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add new features
|
# Add new features
|
||||||
|
@ -71,7 +67,7 @@ if __name__ == "__main__":
|
||||||
{
|
{
|
||||||
"title": "New features and enhancements",
|
"title": "New features and enhancements",
|
||||||
"labels": feat_labels,
|
"labels": feat_labels,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add fixes
|
# Add fixes
|
||||||
|
@ -82,7 +78,7 @@ if __name__ == "__main__":
|
||||||
{
|
{
|
||||||
"title": "Fixed issues",
|
"title": "Fixed issues",
|
||||||
"labels": fixes_labels,
|
"labels": fixes_labels,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add Documentation
|
# Add Documentation
|
||||||
|
@ -93,7 +89,7 @@ if __name__ == "__main__":
|
||||||
{
|
{
|
||||||
"title": "Documentation",
|
"title": "Documentation",
|
||||||
"labels": doc_labels,
|
"labels": doc_labels,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add the catch all
|
# Add the catch all
|
||||||
|
@ -101,7 +97,7 @@ if __name__ == "__main__":
|
||||||
{
|
{
|
||||||
"title": "Other Changes",
|
"title": "Other Changes",
|
||||||
"labels": ["*"],
|
"labels": ["*"],
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
with open(r"release.yml", "w", encoding="utf-8") as release_file:
|
with open(r"release.yml", "w", encoding="utf-8") as release_file:
|
||||||
yaml.dump(
|
yaml.dump(
|
||||||
|
@ -109,7 +105,7 @@ if __name__ == "__main__":
|
||||||
"changelog": {
|
"changelog": {
|
||||||
"exclude": {"labels": exclude_list},
|
"exclude": {"labels": exclude_list},
|
||||||
"categories": categories_list,
|
"categories": categories_list,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
release_file,
|
release_file,
|
||||||
Dumper=SafeDumper,
|
Dumper=SafeDumper,
|
||||||
|
|
12
.github/workflows/code-testing.yml
vendored
12
.github/workflows/code-testing.yml
vendored
|
@ -23,6 +23,8 @@ jobs:
|
||||||
- 'anta/**'
|
- 'anta/**'
|
||||||
- 'tests/*'
|
- 'tests/*'
|
||||||
- 'tests/**'
|
- 'tests/**'
|
||||||
|
# detect dependency changes
|
||||||
|
- 'pyproject.toml'
|
||||||
core:
|
core:
|
||||||
- 'anta/*'
|
- 'anta/*'
|
||||||
- 'anta/reporter/*'
|
- 'anta/reporter/*'
|
||||||
|
@ -44,7 +46,7 @@ jobs:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||||
needs: file-changes
|
needs: file-changes
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
@ -64,7 +66,7 @@ jobs:
|
||||||
if: needs.file-changes.outputs.cli == 'true' && needs.file-changes.outputs.docs == 'false'
|
if: needs.file-changes.outputs.cli == 'true' && needs.file-changes.outputs.docs == 'false'
|
||||||
steps:
|
steps:
|
||||||
- name: Documentation is missing
|
- name: Documentation is missing
|
||||||
uses: GrantBirki/comment@v2.0.9
|
uses: GrantBirki/comment@v2.0.10
|
||||||
with:
|
with:
|
||||||
body: |
|
body: |
|
||||||
Please consider that documentation is missing under `docs/` folder.
|
Please consider that documentation is missing under `docs/` folder.
|
||||||
|
@ -82,7 +84,7 @@ jobs:
|
||||||
config_file: .yamllint.yml
|
config_file: .yamllint.yml
|
||||||
file_or_dir: .
|
file_or_dir: .
|
||||||
lint-python:
|
lint-python:
|
||||||
name: Run isort, black, flake8 and pylint
|
name: Check the code style
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: file-changes
|
needs: file-changes
|
||||||
if: needs.file-changes.outputs.code == 'true'
|
if: needs.file-changes.outputs.code == 'true'
|
||||||
|
@ -97,7 +99,7 @@ jobs:
|
||||||
- name: "Run tox linting environment"
|
- name: "Run tox linting environment"
|
||||||
run: tox -e lint
|
run: tox -e lint
|
||||||
type-python:
|
type-python:
|
||||||
name: Run mypy
|
name: Check typing
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: file-changes
|
needs: file-changes
|
||||||
if: needs.file-changes.outputs.code == 'true'
|
if: needs.file-changes.outputs.code == 'true'
|
||||||
|
@ -117,7 +119,7 @@ jobs:
|
||||||
needs: [lint-python, type-python]
|
needs: [lint-python, type-python]
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
python: ["3.9", "3.10", "3.11", "3.12"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
|
|
2
.github/workflows/main-doc.yml
vendored
2
.github/workflows/main-doc.yml
vendored
|
@ -7,9 +7,9 @@ on:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
# Run only if any of the following paths are changed when pushing to main
|
# Run only if any of the following paths are changed when pushing to main
|
||||||
# May need to update this
|
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
- "mkdocs.yml"
|
- "mkdocs.yml"
|
||||||
|
- "anta/**"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -30,7 +30,6 @@ share/python-wheels/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
.flake8
|
|
||||||
|
|
||||||
# PyInstaller
|
# PyInstaller
|
||||||
# Usually these files are written by a python script from a template
|
# Usually these files are written by a python script from a template
|
||||||
|
|
|
@ -40,51 +40,42 @@ repos:
|
||||||
- --comment-style
|
- --comment-style
|
||||||
- '<!--| ~| -->'
|
- '<!--| ~| -->'
|
||||||
|
|
||||||
- repo: https://github.com/pycqa/isort
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: 5.13.2
|
rev: v0.3.5
|
||||||
hooks:
|
hooks:
|
||||||
- id: isort
|
- id: ruff
|
||||||
name: Check for changes when running isort on all python files
|
name: Run Ruff linter
|
||||||
|
args: [ --fix ]
|
||||||
- repo: https://github.com/psf/black
|
- id: ruff-format
|
||||||
rev: 24.1.1
|
name: Run Ruff formatter
|
||||||
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: local # as per https://pylint.pycqa.org/en/latest/user_guide/installation/pre-commit-integration.html
|
||||||
hooks:
|
hooks:
|
||||||
- id: pylint
|
- id: pylint
|
||||||
entry: pylint
|
entry: pylint
|
||||||
language: python
|
language: python
|
||||||
name: Check for Linting error on Python files
|
name: Check code style with pylint
|
||||||
description: This hook runs pylint.
|
description: This hook runs pylint.
|
||||||
types: [python]
|
types: [python]
|
||||||
args:
|
args:
|
||||||
- -rn # Only display messages
|
- -rn # Only display messages
|
||||||
- -sn # Don't display the score
|
- -sn # Don't display the score
|
||||||
- --rcfile=pylintrc # Link to config file
|
- --rcfile=pyproject.toml # Link to config file
|
||||||
|
|
||||||
# Prepare to turn on ruff
|
- repo: https://github.com/codespell-project/codespell
|
||||||
# - repo: https://github.com/astral-sh/ruff-pre-commit
|
rev: v2.2.6
|
||||||
# # Ruff version.
|
hooks:
|
||||||
# rev: v0.0.280
|
- id: codespell
|
||||||
# hooks:
|
name: Checks for common misspellings in text files.
|
||||||
# - id: ruff
|
entry: codespell
|
||||||
|
language: python
|
||||||
|
types: [text]
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
rev: v1.7.1
|
rev: v1.9.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
|
name: Check typing with mypy
|
||||||
args:
|
args:
|
||||||
- --config-file=pyproject.toml
|
- --config-file=pyproject.toml
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
|
|
18
.vscode/settings.json
vendored
18
.vscode/settings.json
vendored
|
@ -1,14 +1,8 @@
|
||||||
{
|
{
|
||||||
"black-formatter.importStrategy": "fromEnvironment",
|
"ruff.enable": true,
|
||||||
|
"python.testing.unittestEnabled": false,
|
||||||
|
"python.testing.pytestEnabled": true,
|
||||||
"pylint.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.importStrategy": "fromEnvironment",
|
||||||
"mypy-type-checker.args": [
|
"mypy-type-checker.args": [
|
||||||
"--config-file=pyproject.toml"
|
"--config-file=pyproject.toml"
|
||||||
|
@ -17,14 +11,10 @@
|
||||||
"refactor": "Warning"
|
"refactor": "Warning"
|
||||||
},
|
},
|
||||||
"pylint.args": [
|
"pylint.args": [
|
||||||
"--load-plugins pylint_pydantic",
|
"--load-plugins", "pylint_pydantic",
|
||||||
"--rcfile=pylintrc"
|
"--rcfile=pylintrc"
|
||||||
],
|
],
|
||||||
"python.testing.pytestArgs": [
|
"python.testing.pytestArgs": [
|
||||||
"tests"
|
"tests"
|
||||||
],
|
],
|
||||||
"python.testing.unittestEnabled": false,
|
|
||||||
"python.testing.pytestEnabled": true,
|
|
||||||
"isort.importStrategy": "fromEnvironment",
|
|
||||||
"isort.check": true,
|
|
||||||
}
|
}
|
|
@ -2,6 +2,7 @@
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""Arista Network Test Automation (ANTA) Framework."""
|
"""Arista Network Test Automation (ANTA) Framework."""
|
||||||
|
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
@ -18,7 +19,7 @@ __credits__ = [
|
||||||
]
|
]
|
||||||
__copyright__ = "Copyright 2022, Arista EMEA AS"
|
__copyright__ = "Copyright 2022, Arista EMEA AS"
|
||||||
|
|
||||||
# Global ANTA debug mode environment variable
|
# ANTA Debug Mode environment variable
|
||||||
__DEBUG__ = bool(os.environ.get("ANTA_DEBUG", "").lower() == "true")
|
__DEBUG__ = bool(os.environ.get("ANTA_DEBUG", "").lower() == "true")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""Patch for aioeapi waiting for https://github.com/jeremyschulman/aio-eapi/pull/13"""
|
"""Patch for aioeapi waiting for https://github.com/jeremyschulman/aio-eapi/pull/13."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, AnyStr
|
from typing import Any, AnyStr
|
||||||
|
@ -12,8 +12,7 @@ Device = aioeapi.Device
|
||||||
|
|
||||||
|
|
||||||
class EapiCommandError(RuntimeError):
|
class EapiCommandError(RuntimeError):
|
||||||
"""
|
"""Exception class for EAPI command errors.
|
||||||
Exception class for EAPI command errors
|
|
||||||
|
|
||||||
Attributes
|
Attributes
|
||||||
----------
|
----------
|
||||||
|
@ -25,8 +24,8 @@ class EapiCommandError(RuntimeError):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=too-many-arguments
|
# 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]]):
|
def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]) -> None:
|
||||||
"""Initializer for the EapiCommandError exception"""
|
"""Initializer for the EapiCommandError exception."""
|
||||||
self.failed = failed
|
self.failed = failed
|
||||||
self.errmsg = errmsg
|
self.errmsg = errmsg
|
||||||
self.errors = errors
|
self.errors = errors
|
||||||
|
@ -35,7 +34,7 @@ class EapiCommandError(RuntimeError):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""returns the error message associated with the exception"""
|
"""Returns the error message associated with the exception."""
|
||||||
return self.errmsg
|
return self.errmsg
|
||||||
|
|
||||||
|
|
||||||
|
@ -43,8 +42,7 @@ aioeapi.EapiCommandError = EapiCommandError
|
||||||
|
|
||||||
|
|
||||||
async def jsonrpc_exec(self, jsonrpc: dict) -> list[dict | AnyStr]: # type: ignore
|
async def jsonrpc_exec(self, jsonrpc: dict) -> list[dict | AnyStr]: # type: ignore
|
||||||
"""
|
"""Execute the JSON-RPC dictionary object.
|
||||||
Execute the JSON-RPC dictionary object.
|
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
|
@ -101,7 +99,7 @@ async def jsonrpc_exec(self, jsonrpc: dict) -> list[dict | AnyStr]: # type: ign
|
||||||
failed=commands[err_at]["cmd"],
|
failed=commands[err_at]["cmd"],
|
||||||
errors=cmd_data[err_at]["errors"],
|
errors=cmd_data[err_at]["errors"],
|
||||||
errmsg=err_msg,
|
errmsg=err_msg,
|
||||||
not_exec=commands[err_at + 1 :], # noqa: E203
|
not_exec=commands[err_at + 1 :],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
282
anta/catalog.py
282
anta/catalog.py
|
@ -1,37 +1,38 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Catalog related functions."""
|
||||||
Catalog related functions
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
from inspect import isclass
|
from inspect import isclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import ModuleType
|
from typing import TYPE_CHECKING, Any, Optional, Union
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Type, Union
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_validator
|
||||||
from pydantic.types import ImportString
|
from pydantic.types import ImportString
|
||||||
|
from pydantic_core import PydanticCustomError
|
||||||
from yaml import YAMLError, safe_load
|
from yaml import YAMLError, safe_load
|
||||||
|
|
||||||
from anta.logger import anta_log_exception
|
from anta.logger import anta_log_exception
|
||||||
from anta.models import AntaTest
|
from anta.models import AntaTest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# { <module_name> : [ { <test_class_name>: <input_as_dict_or_None> }, ... ] }
|
# { <module_name> : [ { <test_class_name>: <input_as_dict_or_None> }, ... ] }
|
||||||
RawCatalogInput = Dict[str, List[Dict[str, Optional[Dict[str, Any]]]]]
|
RawCatalogInput = dict[str, list[dict[str, Optional[dict[str, Any]]]]]
|
||||||
|
|
||||||
# [ ( <AntaTest class>, <input_as AntaTest.Input or dict or None > ), ... ]
|
# [ ( <AntaTest class>, <input_as AntaTest.Input or dict or None > ), ... ]
|
||||||
ListAntaTestTuples = List[Tuple[Type[AntaTest], Optional[Union[AntaTest.Input, Dict[str, Any]]]]]
|
ListAntaTestTuples = list[tuple[type[AntaTest], Optional[Union[AntaTest.Input, dict[str, Any]]]]]
|
||||||
|
|
||||||
|
|
||||||
class AntaTestDefinition(BaseModel):
|
class AntaTestDefinition(BaseModel):
|
||||||
"""
|
"""Define a test with its associated inputs.
|
||||||
Define a test with its associated inputs.
|
|
||||||
|
|
||||||
test: An AntaTest concrete subclass
|
test: An AntaTest concrete subclass
|
||||||
inputs: The associated AntaTest.Input subclass instance
|
inputs: The associated AntaTest.Input subclass instance
|
||||||
|
@ -39,13 +40,13 @@ class AntaTestDefinition(BaseModel):
|
||||||
|
|
||||||
model_config = ConfigDict(frozen=True)
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
test: Type[AntaTest]
|
test: type[AntaTest]
|
||||||
inputs: AntaTest.Input
|
inputs: AntaTest.Input
|
||||||
|
|
||||||
def __init__(self, **data: Any) -> None:
|
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.
|
||||||
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
|
https://docs.pydantic.dev/2.0/usage/validators/#using-validation-context-with-basemodel-initialization.
|
||||||
"""
|
"""
|
||||||
self.__pydantic_validator__.validate_python(
|
self.__pydantic_validator__.validate_python(
|
||||||
data,
|
data,
|
||||||
|
@ -56,133 +57,170 @@ class AntaTestDefinition(BaseModel):
|
||||||
|
|
||||||
@field_validator("inputs", mode="before")
|
@field_validator("inputs", mode="before")
|
||||||
@classmethod
|
@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 no inputs, allow the user to omit providing the `inputs` field.
|
||||||
If the test has inputs, allow the user to provide a valid dictionary of the input fields.
|
If the test has inputs, allow the user to provide a valid dictionary of the input fields.
|
||||||
This model validator will instantiate an Input class from the `test` class field.
|
This model validator will instantiate an Input class from the `test` class field.
|
||||||
"""
|
"""
|
||||||
if info.context is None:
|
if info.context is None:
|
||||||
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
|
# Pydantic guarantees at this stage that test_class is a subclass of AntaTest because of the ordering
|
||||||
# of fields in the class definition - so no need to check for this
|
# of fields in the class definition - so no need to check for this
|
||||||
test_class = info.context["test"]
|
test_class = info.context["test"]
|
||||||
if not (isclass(test_class) and issubclass(test_class, AntaTest)):
|
if not (isclass(test_class) and issubclass(test_class, AntaTest)):
|
||||||
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):
|
if isinstance(data, AntaTest.Input):
|
||||||
return data
|
return data
|
||||||
if isinstance(data, dict):
|
try:
|
||||||
return test_class.Input(**data)
|
if data is None:
|
||||||
raise ValueError(f"Coud not instantiate inputs as type {type(data).__name__} is not valid")
|
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")
|
@model_validator(mode="after")
|
||||||
def check_inputs(self) -> "AntaTestDefinition":
|
def check_inputs(self) -> AntaTestDefinition:
|
||||||
"""
|
"""Check the `inputs` field typing.
|
||||||
|
|
||||||
The `inputs` class attribute needs to be an instance of the AntaTest.Input subclass defined in the class `test`.
|
The `inputs` class attribute needs to be an instance of the AntaTest.Input subclass defined in the class `test`.
|
||||||
"""
|
"""
|
||||||
if not isinstance(self.inputs, self.test.Input):
|
if not isinstance(self.inputs, self.test.Input):
|
||||||
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
|
return self
|
||||||
|
|
||||||
|
|
||||||
class AntaCatalogFile(RootModel[Dict[ImportString[Any], List[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods
|
class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods
|
||||||
"""
|
"""Represents an ANTA Test Catalog File.
|
||||||
This model represents an ANTA Test Catalog File.
|
|
||||||
|
|
||||||
A valid test catalog file must have the following structure:
|
Example:
|
||||||
|
-------
|
||||||
|
A valid test catalog file must have the following structure:
|
||||||
|
```
|
||||||
<Python module>:
|
<Python module>:
|
||||||
- <AntaTest subclass>:
|
- <AntaTest subclass>:
|
||||||
<AntaTest.Input compliant dictionary>
|
<AntaTest.Input compliant dictionary>
|
||||||
|
```
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
root: Dict[ImportString[Any], List[AntaTestDefinition]]
|
root: dict[ImportString[Any], list[AntaTestDefinition]]
|
||||||
|
|
||||||
|
@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: # 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(AntaCatalogFile.flatten_modules(data=tests, package=module.__name__))
|
||||||
|
else:
|
||||||
|
if not isinstance(tests, list):
|
||||||
|
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
|
||||||
|
# This is a list of AntaTestDefinition
|
||||||
|
modules[module] = tests
|
||||||
|
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")
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def check_tests(cls, data: Any) -> Any:
|
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.
|
||||||
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
|
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
|
are actually defined in their respective Python module and instantiate Input instances
|
||||||
with provided value to validate test inputs.
|
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):
|
if isinstance(data, dict):
|
||||||
typed_data: dict[ModuleType, list[Any]] = flatten_modules(data)
|
typed_data: dict[ModuleType, list[Any]] = AntaCatalogFile.flatten_modules(data)
|
||||||
for module, tests in typed_data.items():
|
for module, tests in typed_data.items():
|
||||||
test_definitions: list[AntaTestDefinition] = []
|
test_definitions: list[AntaTestDefinition] = []
|
||||||
for test_definition in tests:
|
for test_definition in tests:
|
||||||
if not isinstance(test_definition, dict):
|
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:
|
if len(test_definition) != 1:
|
||||||
raise ValueError(
|
msg = (
|
||||||
f"Syntax error when parsing: {test_definition}\nIt must be a dictionary with a single entry. Check the indentation in the test catalog."
|
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():
|
for test_name, test_inputs in test_definition.copy().items():
|
||||||
test: type[AntaTest] | None = getattr(module, test_name, None)
|
test: type[AntaTest] | None = getattr(module, test_name, None)
|
||||||
if test is None:
|
if test is None:
|
||||||
raise ValueError(
|
msg = (
|
||||||
f"{test_name} is not defined in Python module {module.__name__}{f' (from {module.__file__})' if module.__file__ is not None else ''}"
|
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))
|
test_definitions.append(AntaTestDefinition(test=test, inputs=test_inputs))
|
||||||
typed_data[module] = test_definitions
|
typed_data[module] = test_definitions
|
||||||
return typed_data
|
return typed_data
|
||||||
|
|
||||||
|
|
||||||
class AntaCatalog:
|
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:
|
def __init__(
|
||||||
"""
|
self,
|
||||||
Constructor of AntaCatalog.
|
tests: list[AntaTestDefinition] | None = None,
|
||||||
|
filename: str | Path | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Instantiate an AntaCatalog instance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
tests: A list of AntaTestDefinition instances.
|
tests: A list of AntaTestDefinition instances.
|
||||||
filename: The path from which the catalog is loaded.
|
filename: The path from which the catalog is loaded.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self._tests: list[AntaTestDefinition] = []
|
self._tests: list[AntaTestDefinition] = []
|
||||||
if tests is not None:
|
if tests is not None:
|
||||||
|
@ -196,34 +234,38 @@ class AntaCatalog:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self) -> Path | None:
|
def filename(self) -> Path | None:
|
||||||
"""Path of the file used to create this AntaCatalog instance"""
|
"""Path of the file used to create this AntaCatalog instance."""
|
||||||
return self._filename
|
return self._filename
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tests(self) -> list[AntaTestDefinition]:
|
def tests(self) -> list[AntaTestDefinition]:
|
||||||
"""List of AntaTestDefinition in this catalog"""
|
"""List of AntaTestDefinition in this catalog."""
|
||||||
return self._tests
|
return self._tests
|
||||||
|
|
||||||
@tests.setter
|
@tests.setter
|
||||||
def tests(self, value: list[AntaTestDefinition]) -> None:
|
def tests(self, value: list[AntaTestDefinition]) -> None:
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
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:
|
for t in value:
|
||||||
if not isinstance(t, AntaTestDefinition):
|
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
|
self._tests = value
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse(filename: str | Path) -> AntaCatalog:
|
def parse(filename: str | Path) -> AntaCatalog:
|
||||||
"""
|
"""Create an AntaCatalog instance from a test catalog file.
|
||||||
Create an AntaCatalog instance from a test catalog file.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
filename: Path to test catalog YAML file
|
filename: Path to test catalog YAML file
|
||||||
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with open(file=filename, mode="r", encoding="UTF-8") as file:
|
file: Path = filename if isinstance(filename, Path) else Path(filename)
|
||||||
data = safe_load(file)
|
with file.open(encoding="UTF-8") as f:
|
||||||
|
data = safe_load(f)
|
||||||
except (TypeError, YAMLError, OSError) as e:
|
except (TypeError, YAMLError, OSError) as e:
|
||||||
message = f"Unable to parse ANTA Test Catalog file '{filename}'"
|
message = f"Unable to parse ANTA Test Catalog file '{filename}'"
|
||||||
anta_log_exception(e, message, logger)
|
anta_log_exception(e, message, logger)
|
||||||
|
@ -233,15 +275,17 @@ class AntaCatalog:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_dict(data: RawCatalogInput, filename: str | Path | None = None) -> AntaCatalog:
|
def from_dict(data: RawCatalogInput, filename: str | Path | None = None) -> AntaCatalog:
|
||||||
"""
|
"""Create an AntaCatalog instance from a dictionary data structure.
|
||||||
Create an AntaCatalog instance from a dictionary data structure.
|
|
||||||
See RawCatalogInput type alias for details.
|
See RawCatalogInput type alias for details.
|
||||||
It is the data structure returned by `yaml.load()` function of a valid
|
It is the data structure returned by `yaml.load()` function of a valid
|
||||||
YAML Test Catalog file.
|
YAML Test Catalog file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
data: Python dictionary used to instantiate the AntaCatalog instance
|
data: Python dictionary used to instantiate the AntaCatalog instance
|
||||||
filename: value to be set as AntaCatalog instance attribute
|
filename: value to be set as AntaCatalog instance attribute
|
||||||
|
|
||||||
"""
|
"""
|
||||||
tests: list[AntaTestDefinition] = []
|
tests: list[AntaTestDefinition] = []
|
||||||
if data is None:
|
if data is None:
|
||||||
|
@ -249,12 +293,17 @@ class AntaCatalog:
|
||||||
return AntaCatalog(filename=filename)
|
return AntaCatalog(filename=filename)
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
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:
|
try:
|
||||||
catalog_data = AntaCatalogFile(**data) # type: ignore[arg-type]
|
catalog_data = AntaCatalogFile(**data) # type: ignore[arg-type]
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
anta_log_exception(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
|
raise
|
||||||
for t in catalog_data.root.values():
|
for t in catalog_data.root.values():
|
||||||
tests.extend(t)
|
tests.extend(t)
|
||||||
|
@ -262,12 +311,14 @@ class AntaCatalog:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_list(data: ListAntaTestTuples) -> AntaCatalog:
|
def from_list(data: ListAntaTestTuples) -> AntaCatalog:
|
||||||
"""
|
"""Create an AntaCatalog instance from a list data structure.
|
||||||
Create an AntaCatalog instance from a list data structure.
|
|
||||||
See ListAntaTestTuples type alias for details.
|
See ListAntaTestTuples type alias for details.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
data: Python list used to instantiate the AntaCatalog instance
|
data: Python list used to instantiate the AntaCatalog instance
|
||||||
|
|
||||||
"""
|
"""
|
||||||
tests: list[AntaTestDefinition] = []
|
tests: list[AntaTestDefinition] = []
|
||||||
try:
|
try:
|
||||||
|
@ -277,15 +328,40 @@ class AntaCatalog:
|
||||||
raise
|
raise
|
||||||
return AntaCatalog(tests)
|
return AntaCatalog(tests)
|
||||||
|
|
||||||
def get_tests_by_tags(self, tags: list[str], strict: bool = False) -> list[AntaTestDefinition]:
|
def get_tests_by_tags(self, tags: set[str], *, strict: bool = False) -> list[AntaTestDefinition]:
|
||||||
"""
|
"""Return all the tests that have matching tags in their input filters.
|
||||||
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=True, return 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.
|
If strict=False, return all the tests that match at least one tag provided as input.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
----
|
||||||
|
tags: Tags of the tests to get.
|
||||||
|
strict: Specify if the returned tests must match all the tags provided.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
List of AntaTestDefinition that match the tags
|
||||||
"""
|
"""
|
||||||
result: list[AntaTestDefinition] = []
|
result: list[AntaTestDefinition] = []
|
||||||
for test in self.tests:
|
for test in self.tests:
|
||||||
if test.inputs.filters and (f := test.inputs.filters.tags):
|
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)):
|
if strict:
|
||||||
|
if all(t in tags for t in f):
|
||||||
|
result.append(test)
|
||||||
|
elif any(t in tags for t in f):
|
||||||
result.append(test)
|
result.append(test)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def get_tests_by_names(self, names: set[str]) -> list[AntaTestDefinition]:
|
||||||
|
"""Return all the tests that have matching a list of tests names.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
----
|
||||||
|
names: Names of the tests to get.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
List of AntaTestDefinition that match the names
|
||||||
|
"""
|
||||||
|
return [test for test in self.tests if test.test.name in names]
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""ANTA CLI."""
|
||||||
ANTA CLI
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
@ -16,7 +14,7 @@ import click
|
||||||
from anta import GITHUB_SUGGESTION, __version__
|
from anta import GITHUB_SUGGESTION, __version__
|
||||||
from anta.cli.check import check as check_command
|
from anta.cli.check import check as check_command
|
||||||
from anta.cli.debug import debug as debug_command
|
from anta.cli.debug import debug as debug_command
|
||||||
from anta.cli.exec import exec as exec_command
|
from anta.cli.exec import _exec as exec_command
|
||||||
from anta.cli.get import get as get_command
|
from anta.cli.get import get as get_command
|
||||||
from anta.cli.nrfu import nrfu as nrfu_command
|
from anta.cli.nrfu import nrfu as nrfu_command
|
||||||
from anta.cli.utils import AliasedGroup, ExitCode
|
from anta.cli.utils import AliasedGroup, ExitCode
|
||||||
|
@ -47,7 +45,7 @@ logger = logging.getLogger(__name__)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> None:
|
def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> None:
|
||||||
"""Arista Network Test Automation (ANTA) CLI"""
|
"""Arista Network Test Automation (ANTA) CLI."""
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
setup_logging(log_level, log_file)
|
setup_logging(log_level, log_file)
|
||||||
|
|
||||||
|
@ -60,11 +58,15 @@ anta.add_command(debug_command)
|
||||||
|
|
||||||
|
|
||||||
def cli() -> None:
|
def cli() -> None:
|
||||||
"""Entrypoint for pyproject.toml"""
|
"""Entrypoint for pyproject.toml."""
|
||||||
try:
|
try:
|
||||||
anta(obj={}, auto_envvar_prefix="ANTA")
|
anta(obj={}, auto_envvar_prefix="ANTA")
|
||||||
except Exception as e: # pylint: disable=broad-exception-caught
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
anta_log_exception(e, f"Uncaught Exception when running ANTA CLI\n{GITHUB_SUGGESTION}", logger)
|
anta_log_exception(
|
||||||
|
exc,
|
||||||
|
f"Uncaught Exception when running ANTA CLI\n{GITHUB_SUGGESTION}",
|
||||||
|
logger,
|
||||||
|
)
|
||||||
sys.exit(ExitCode.INTERNAL_ERROR)
|
sys.exit(ExitCode.INTERNAL_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Click commands to validate configuration files."""
|
||||||
Click commands to validate configuration files
|
|
||||||
"""
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from anta.cli.check import commands
|
from anta.cli.check import commands
|
||||||
|
@ -11,7 +10,7 @@ from anta.cli.check import commands
|
||||||
|
|
||||||
@click.group
|
@click.group
|
||||||
def check() -> None:
|
def check() -> None:
|
||||||
"""Commands to validate configuration files"""
|
"""Commands to validate configuration files."""
|
||||||
|
|
||||||
|
|
||||||
check.add_command(commands.catalog)
|
check.add_command(commands.catalog)
|
||||||
|
|
|
@ -2,28 +2,28 @@
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
# pylint: disable = redefined-outer-name
|
# pylint: disable = redefined-outer-name
|
||||||
"""
|
"""Click commands to validate configuration files."""
|
||||||
Click commands to validate configuration files
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from rich.pretty import pretty_repr
|
from rich.pretty import pretty_repr
|
||||||
|
|
||||||
from anta.catalog import AntaCatalog
|
|
||||||
from anta.cli.console import console
|
from anta.cli.console import console
|
||||||
from anta.cli.utils import catalog_options
|
from anta.cli.utils import catalog_options
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.catalog import AntaCatalog
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@click.command
|
@click.command
|
||||||
@catalog_options
|
@catalog_options
|
||||||
def catalog(catalog: AntaCatalog) -> None:
|
def catalog(catalog: AntaCatalog) -> None:
|
||||||
"""
|
"""Check that the catalog is valid."""
|
||||||
Check that the catalog is valid
|
|
||||||
"""
|
|
||||||
console.print(f"[bold][green]Catalog is valid: {catalog.filename}")
|
console.print(f"[bold][green]Catalog is valid: {catalog.filename}")
|
||||||
console.print(pretty_repr(catalog.tests))
|
console.print(pretty_repr(catalog.tests))
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""ANTA Top-level Console.
|
||||||
ANTA Top-level Console
|
|
||||||
https://rich.readthedocs.io/en/stable/console.html#console-api
|
https://rich.readthedocs.io/en/stable/console.html#console-api.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Click commands to execute EOS commands on remote devices."""
|
||||||
Click commands to execute EOS commands on remote devices
|
|
||||||
"""
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from anta.cli.debug import commands
|
from anta.cli.debug import commands
|
||||||
|
@ -11,7 +10,7 @@ from anta.cli.debug import commands
|
||||||
|
|
||||||
@click.group
|
@click.group
|
||||||
def debug() -> None:
|
def debug() -> None:
|
||||||
"""Commands to execute EOS commands on remote devices"""
|
"""Commands to execute EOS commands on remote devices."""
|
||||||
|
|
||||||
|
|
||||||
debug.add_command(commands.run_cmd)
|
debug.add_command(commands.run_cmd)
|
||||||
|
|
|
@ -2,23 +2,24 @@
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
# pylint: disable = redefined-outer-name
|
# pylint: disable = redefined-outer-name
|
||||||
"""
|
"""Click commands to execute EOS commands on remote devices."""
|
||||||
Click commands to execute EOS commands on remote devices
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from anta.cli.console import console
|
from anta.cli.console import console
|
||||||
from anta.cli.debug.utils import debug_options
|
from anta.cli.debug.utils import debug_options
|
||||||
from anta.cli.utils import ExitCode
|
from anta.cli.utils import ExitCode
|
||||||
from anta.device import AntaDevice
|
|
||||||
from anta.models import AntaCommand, AntaTemplate
|
from anta.models import AntaCommand, AntaTemplate
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.device import AntaDevice
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -26,8 +27,16 @@ logger = logging.getLogger(__name__)
|
||||||
@debug_options
|
@debug_options
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@click.option("--command", "-c", type=str, required=True, help="Command to run")
|
@click.option("--command", "-c", type=str, required=True, help="Command to run")
|
||||||
def run_cmd(ctx: click.Context, device: AntaDevice, command: str, ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int) -> None:
|
def run_cmd(
|
||||||
"""Run arbitrary command to an ANTA device"""
|
ctx: click.Context,
|
||||||
|
device: AntaDevice,
|
||||||
|
command: str,
|
||||||
|
ofmt: Literal["json", "text"],
|
||||||
|
version: Literal["1", "latest"],
|
||||||
|
revision: int,
|
||||||
|
) -> None:
|
||||||
|
# pylint: disable=too-many-arguments
|
||||||
|
"""Run arbitrary command to an ANTA device."""
|
||||||
console.print(f"Run command [green]{command}[/green] on [red]{device.name}[/red]")
|
console.print(f"Run command [green]{command}[/green] on [red]{device.name}[/red]")
|
||||||
# I do not assume the following line, but click make me do it
|
# I do not assume the following line, but click make me do it
|
||||||
v: Literal[1, "latest"] = version if version == "latest" else 1
|
v: Literal[1, "latest"] = version if version == "latest" else 1
|
||||||
|
@ -45,18 +54,32 @@ def run_cmd(ctx: click.Context, device: AntaDevice, command: str, ofmt: Literal[
|
||||||
@click.command
|
@click.command
|
||||||
@debug_options
|
@debug_options
|
||||||
@click.pass_context
|
@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)
|
@click.argument("params", required=True, nargs=-1)
|
||||||
def run_template(
|
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:
|
) -> None:
|
||||||
# pylint: disable=too-many-arguments
|
# pylint: disable=too-many-arguments
|
||||||
"""Run arbitrary templated command to an ANTA device.
|
"""Run arbitrary templated command to an ANTA device.
|
||||||
|
|
||||||
Takes a list of arguments (keys followed by a value) to build a dictionary used as template parameters.
|
Takes a list of arguments (keys followed by a value) to build a dictionary used as template parameters.
|
||||||
Example:
|
|
||||||
|
|
||||||
|
Example:
|
||||||
|
-------
|
||||||
anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1
|
anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1
|
||||||
|
|
||||||
"""
|
"""
|
||||||
template_params = dict(zip(params[::2], params[1::2]))
|
template_params = dict(zip(params[::2], params[1::2]))
|
||||||
|
|
||||||
|
@ -64,7 +87,7 @@ def run_template(
|
||||||
# I do not assume the following line, but click make me do it
|
# I do not assume the following line, but click make me do it
|
||||||
v: Literal[1, "latest"] = version if version == "latest" else 1
|
v: Literal[1, "latest"] = version if version == "latest" else 1
|
||||||
t = AntaTemplate(template=template, ofmt=ofmt, version=v, revision=revision)
|
t = AntaTemplate(template=template, ofmt=ofmt, version=v, revision=revision)
|
||||||
c = t.render(**template_params) # type: ignore
|
c = t.render(**template_params)
|
||||||
asyncio.run(device.collect(c))
|
asyncio.run(device.collect(c))
|
||||||
if not c.collected:
|
if not c.collected:
|
||||||
console.print(f"[bold red] Command '{c.command}' failed to execute!")
|
console.print(f"[bold red] Command '{c.command}' failed to execute!")
|
||||||
|
|
|
@ -1,35 +1,56 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Utils functions to use with anta.cli.debug module."""
|
||||||
Utils functions to use with anta.cli.debug module.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any, Callable
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from anta.cli.utils import ExitCode, inventory_options
|
from anta.cli.utils import ExitCode, inventory_options
|
||||||
from anta.inventory import AntaInventory
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.inventory import AntaInventory
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def debug_options(f: Any) -> Any:
|
def debug_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
"""Click common options required to execute a command on a specific device"""
|
"""Click common options required to execute a command on a specific device."""
|
||||||
|
|
||||||
@inventory_options
|
@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(
|
||||||
@click.option("--version", "-v", type=click.Choice(["1", "latest"]), default="latest", help="EOS eAPI version")
|
"--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("--revision", "-r", type=int, help="eAPI command revision", required=False)
|
||||||
@click.option("--device", "-d", type=str, required=True, help="Device from inventory to use")
|
@click.option("--device", "-d", type=str, required=True, help="Device from inventory to use")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@functools.wraps(f)
|
@functools.wraps(f)
|
||||||
def wrapper(ctx: click.Context, *args: tuple[Any], inventory: AntaInventory, tags: list[str] | None, device: str, **kwargs: dict[str, Any]) -> Any:
|
def wrapper(
|
||||||
|
ctx: click.Context,
|
||||||
|
*args: tuple[Any],
|
||||||
|
inventory: AntaInventory,
|
||||||
|
tags: set[str] | None,
|
||||||
|
device: str,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Any:
|
||||||
|
# TODO: @gmuloc - tags come from context https://github.com/arista-netdevops-community/anta/issues/584
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
|
# ruff: noqa: ARG001
|
||||||
try:
|
try:
|
||||||
d = inventory[device]
|
d = inventory[device]
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
|
|
|
@ -1,19 +1,18 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Click commands to execute various scripts on EOS devices."""
|
||||||
Click commands to execute various scripts on EOS devices
|
|
||||||
"""
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from anta.cli.exec import commands
|
from anta.cli.exec import commands
|
||||||
|
|
||||||
|
|
||||||
@click.group
|
@click.group("exec")
|
||||||
def exec() -> None: # pylint: disable=redefined-builtin
|
def _exec() -> None: # pylint: disable=redefined-builtin
|
||||||
"""Commands to execute various scripts on EOS devices"""
|
"""Commands to execute various scripts on EOS devices."""
|
||||||
|
|
||||||
|
|
||||||
exec.add_command(commands.clear_counters)
|
_exec.add_command(commands.clear_counters)
|
||||||
exec.add_command(commands.snapshot)
|
_exec.add_command(commands.snapshot)
|
||||||
exec.add_command(commands.collect_tech_support)
|
_exec.add_command(commands.collect_tech_support)
|
||||||
|
|
|
@ -1,31 +1,34 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Click commands to execute various scripts on EOS devices."""
|
||||||
Click commands to execute various scripts on EOS devices
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from yaml import safe_load
|
from yaml import safe_load
|
||||||
|
|
||||||
|
from anta.cli.console import console
|
||||||
from anta.cli.exec.utils import clear_counters_utils, collect_commands, collect_scheduled_show_tech
|
from anta.cli.exec.utils import clear_counters_utils, collect_commands, collect_scheduled_show_tech
|
||||||
from anta.cli.utils import inventory_options
|
from anta.cli.utils import inventory_options
|
||||||
from anta.inventory import AntaInventory
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.inventory import AntaInventory
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@click.command
|
@click.command
|
||||||
@inventory_options
|
@inventory_options
|
||||||
def clear_counters(inventory: AntaInventory, tags: list[str] | None) -> None:
|
def clear_counters(inventory: AntaInventory, tags: set[str] | None) -> None:
|
||||||
"""Clear counter statistics on EOS devices"""
|
"""Clear counter statistics on EOS devices."""
|
||||||
asyncio.run(clear_counters_utils(inventory, tags=tags))
|
asyncio.run(clear_counters_utils(inventory, tags=tags))
|
||||||
|
|
||||||
|
|
||||||
|
@ -45,27 +48,40 @@ def clear_counters(inventory: AntaInventory, tags: list[str] | None) -> None:
|
||||||
show_envvar=True,
|
show_envvar=True,
|
||||||
type=click.Path(file_okay=False, dir_okay=True, exists=False, writable=True, path_type=Path),
|
type=click.Path(file_okay=False, dir_okay=True, exists=False, writable=True, path_type=Path),
|
||||||
help="Directory to save commands output.",
|
help="Directory to save commands output.",
|
||||||
default=f"anta_snapshot_{datetime.now().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,
|
show_default=True,
|
||||||
)
|
)
|
||||||
def snapshot(inventory: AntaInventory, tags: list[str] | None, commands_list: Path, output: Path) -> None:
|
def snapshot(inventory: AntaInventory, tags: set[str] | None, commands_list: Path, output: Path) -> None:
|
||||||
"""Collect commands output from devices in inventory"""
|
"""Collect commands output from devices in inventory."""
|
||||||
print(f"Collecting data for {commands_list}")
|
console.print(f"Collecting data for {commands_list}")
|
||||||
print(f"Output directory is {output}")
|
console.print(f"Output directory is {output}")
|
||||||
try:
|
try:
|
||||||
with open(commands_list, "r", encoding="UTF-8") as file:
|
with commands_list.open(encoding="UTF-8") as file:
|
||||||
file_content = file.read()
|
file_content = file.read()
|
||||||
eos_commands = safe_load(file_content)
|
eos_commands = safe_load(file_content)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.error(f"Error reading {commands_list}")
|
logger.error("Error reading %s", commands_list)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
asyncio.run(collect_commands(inventory, eos_commands, output, tags=tags))
|
asyncio.run(collect_commands(inventory, eos_commands, output, tags=tags))
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@inventory_options
|
@inventory_options
|
||||||
@click.option("--output", "-o", default="./tech-support", show_default=True, help="Path for test catalog", type=click.Path(path_type=Path), required=False)
|
@click.option(
|
||||||
@click.option("--latest", help="Number of scheduled show-tech to retrieve", type=int, required=False)
|
"--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(
|
@click.option(
|
||||||
"--configure",
|
"--configure",
|
||||||
help="Ensure devices have 'aaa authorization exec default local' configured (required for SCP on EOS). THIS WILL CHANGE THE CONFIGURATION OF YOUR NETWORK.",
|
help="Ensure devices have 'aaa authorization exec default local' configured (required for SCP on EOS). THIS WILL CHANGE THE CONFIGURATION OF YOUR NETWORK.",
|
||||||
|
@ -73,6 +89,13 @@ def snapshot(inventory: AntaInventory, tags: list[str] | None, commands_list: Pa
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
show_default=True,
|
show_default=True,
|
||||||
)
|
)
|
||||||
def collect_tech_support(inventory: AntaInventory, tags: list[str] | None, output: Path, latest: int | None, configure: bool) -> None:
|
def collect_tech_support(
|
||||||
"""Collect scheduled tech-support from EOS devices"""
|
inventory: AntaInventory,
|
||||||
asyncio.run(collect_scheduled_show_tech(inventory, output, configure, tags=tags, latest=latest))
|
tags: set[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=configure, tags=tags, latest=latest))
|
||||||
|
|
|
@ -2,9 +2,8 @@
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
|
|
||||||
"""
|
"""Exec CLI helpers."""
|
||||||
Exec CLI helpers
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
@ -13,24 +12,25 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
from aioeapi import EapiCommandError
|
from aioeapi import EapiCommandError
|
||||||
|
from click.exceptions import UsageError
|
||||||
from httpx import ConnectError, HTTPError
|
from httpx import ConnectError, HTTPError
|
||||||
|
|
||||||
from anta.device import AntaDevice, AsyncEOSDevice
|
from anta.device import AntaDevice, AsyncEOSDevice
|
||||||
from anta.inventory import AntaInventory
|
|
||||||
from anta.models import AntaCommand
|
from anta.models import AntaCommand
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.inventory import AntaInventory
|
||||||
|
|
||||||
EOS_SCHEDULED_TECH_SUPPORT = "/mnt/flash/schedule/tech-support"
|
EOS_SCHEDULED_TECH_SUPPORT = "/mnt/flash/schedule/tech-support"
|
||||||
INVALID_CHAR = "`~!@#$/"
|
INVALID_CHAR = "`~!@#$/"
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def clear_counters_utils(anta_inventory: AntaInventory, tags: list[str] | None = None) -> None:
|
async def clear_counters_utils(anta_inventory: AntaInventory, tags: set[str] | None = None) -> None:
|
||||||
"""
|
"""Clear counters."""
|
||||||
Clear counters
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def clear(dev: AntaDevice) -> None:
|
async def clear(dev: AntaDevice) -> None:
|
||||||
commands = [AntaCommand(command="clear counters")]
|
commands = [AntaCommand(command="clear counters")]
|
||||||
|
@ -39,12 +39,12 @@ async def clear_counters_utils(anta_inventory: AntaInventory, tags: list[str] |
|
||||||
await dev.collect_commands(commands=commands)
|
await dev.collect_commands(commands=commands)
|
||||||
for command in commands:
|
for command in commands:
|
||||||
if not command.collected:
|
if not command.collected:
|
||||||
logger.error(f"Could not clear counters on device {dev.name}: {command.errors}")
|
logger.error("Could not clear counters on device %s: %s", dev.name, command.errors)
|
||||||
logger.info(f"Cleared counters on {dev.name} ({dev.hw_model})")
|
logger.info("Cleared counters on %s (%s)", dev.name, dev.hw_model)
|
||||||
|
|
||||||
logger.info("Connecting to devices...")
|
logger.info("Connecting to devices...")
|
||||||
await anta_inventory.connect_inventory()
|
await anta_inventory.connect_inventory()
|
||||||
devices = anta_inventory.get_inventory(established_only=True, tags=tags).values()
|
devices = anta_inventory.get_inventory(established_only=True, tags=tags).devices
|
||||||
logger.info("Clearing counters on remote devices...")
|
logger.info("Clearing counters on remote devices...")
|
||||||
await asyncio.gather(*(clear(device) for device in devices))
|
await asyncio.gather(*(clear(device) for device in devices))
|
||||||
|
|
||||||
|
@ -53,11 +53,9 @@ async def collect_commands(
|
||||||
inv: AntaInventory,
|
inv: AntaInventory,
|
||||||
commands: dict[str, str],
|
commands: dict[str, str],
|
||||||
root_dir: Path,
|
root_dir: Path,
|
||||||
tags: list[str] | None = None,
|
tags: set[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Collect EOS commands."""
|
||||||
Collect EOS commands
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "text"]) -> None:
|
async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "text"]) -> None:
|
||||||
outdir = Path() / root_dir / dev.name / outformat
|
outdir = Path() / root_dir / dev.name / outformat
|
||||||
|
@ -66,7 +64,7 @@ async def collect_commands(
|
||||||
c = AntaCommand(command=command, ofmt=outformat)
|
c = AntaCommand(command=command, ofmt=outformat)
|
||||||
await dev.collect(c)
|
await dev.collect(c)
|
||||||
if not c.collected:
|
if not c.collected:
|
||||||
logger.error(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
|
return
|
||||||
if c.ofmt == "json":
|
if c.ofmt == "json":
|
||||||
outfile = outdir / f"{safe_command}.json"
|
outfile = outdir / f"{safe_command}.json"
|
||||||
|
@ -76,11 +74,11 @@ async def collect_commands(
|
||||||
content = c.text_output
|
content = c.text_output
|
||||||
with outfile.open(mode="w", encoding="UTF-8") as f:
|
with outfile.open(mode="w", encoding="UTF-8") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
logger.info(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...")
|
logger.info("Connecting to devices...")
|
||||||
await inv.connect_inventory()
|
await inv.connect_inventory()
|
||||||
devices = inv.get_inventory(established_only=True, tags=tags).values()
|
devices = inv.get_inventory(established_only=True, tags=tags).devices
|
||||||
logger.info("Collecting commands from remote devices")
|
logger.info("Collecting commands from remote devices")
|
||||||
coros = []
|
coros = []
|
||||||
if "json_format" in commands:
|
if "json_format" in commands:
|
||||||
|
@ -90,18 +88,14 @@ async def collect_commands(
|
||||||
res = await asyncio.gather(*coros, return_exceptions=True)
|
res = await asyncio.gather(*coros, return_exceptions=True)
|
||||||
for r in res:
|
for r in res:
|
||||||
if isinstance(r, Exception):
|
if isinstance(r, Exception):
|
||||||
logger.error(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:
|
async def collect_scheduled_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."""
|
||||||
Collect scheduled show-tech on devices
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def collect(device: AntaDevice) -> None:
|
async def collect(device: AntaDevice) -> None:
|
||||||
"""
|
"""Collect all the tech-support files stored on Arista switches flash and copy them locally."""
|
||||||
Collect all the tech-support files stored on Arista switches flash and copy them locally
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
# Get the tech-support filename to retrieve
|
# Get the tech-support filename to retrieve
|
||||||
cmd = f"bash timeout 10 ls -1t {EOS_SCHEDULED_TECH_SUPPORT}"
|
cmd = f"bash timeout 10 ls -1t {EOS_SCHEDULED_TECH_SUPPORT}"
|
||||||
|
@ -110,9 +104,9 @@ async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, config
|
||||||
command = AntaCommand(command=cmd, ofmt="text")
|
command = AntaCommand(command=cmd, ofmt="text")
|
||||||
await device.collect(command=command)
|
await device.collect(command=command)
|
||||||
if command.collected and command.text_output:
|
if command.collected and command.text_output:
|
||||||
filenames = list(map(lambda f: Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}"), command.text_output.splitlines()))
|
filenames = [Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}") for f in command.text_output.splitlines()]
|
||||||
else:
|
else:
|
||||||
logger.error(f"Unable to get tech-support filenames on {device.name}: verify that {EOS_SCHEDULED_TECH_SUPPORT} is not empty")
|
logger.error("Unable to get tech-support filenames on %s: verify that %s is not empty", device.name, EOS_SCHEDULED_TECH_SUPPORT)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create directories
|
# Create directories
|
||||||
|
@ -124,12 +118,15 @@ async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, config
|
||||||
await device.collect(command=command)
|
await device.collect(command=command)
|
||||||
|
|
||||||
if command.collected and not command.text_output:
|
if command.collected and not command.text_output:
|
||||||
logger.debug(f"'aaa authorization exec default local' is not configured on device {device.name}")
|
logger.debug("'aaa authorization exec default local' is not configured on device %s", device.name)
|
||||||
if configure:
|
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 = []
|
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: # pylint: disable=protected-access
|
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
|
commands.append({"cmd": "enable", "input": device._enable_password}) # pylint: disable=protected-access
|
||||||
elif device.enable:
|
elif device.enable:
|
||||||
|
@ -138,24 +135,24 @@ async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, config
|
||||||
[
|
[
|
||||||
{"cmd": "configure terminal"},
|
{"cmd": "configure terminal"},
|
||||||
{"cmd": "aaa authorization exec default local"},
|
{"cmd": "aaa authorization exec default local"},
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
logger.warning(f"Configuring 'aaa authorization exec default local' on device {device.name}")
|
logger.warning("Configuring 'aaa authorization exec default local' on device %s", device.name)
|
||||||
command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text")
|
command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text")
|
||||||
await device._session.cli(commands=commands) # pylint: disable=protected-access
|
await device._session.cli(commands=commands) # pylint: disable=protected-access
|
||||||
logger.info(f"Configured 'aaa authorization exec default local' on device {device.name}")
|
logger.info("Configured 'aaa authorization exec default local' on device %s", device.name)
|
||||||
else:
|
else:
|
||||||
logger.error(f"Unable to collect tech-support on {device.name}: configuration 'aaa authorization exec default local' is not present")
|
logger.error("Unable to collect tech-support on %s: configuration 'aaa authorization exec default local' is not present", device.name)
|
||||||
return
|
return
|
||||||
logger.debug(f"'aaa authorization exec default local' is already configured on device {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")
|
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:
|
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...")
|
logger.info("Connecting to devices...")
|
||||||
await inv.connect_inventory()
|
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))
|
await asyncio.gather(*(collect(device) for device in devices))
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Click commands to get information from or generate inventories."""
|
||||||
Click commands to get information from or generate inventories
|
|
||||||
"""
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from anta.cli.get import commands
|
from anta.cli.get import commands
|
||||||
|
@ -11,7 +10,7 @@ from anta.cli.get import commands
|
||||||
|
|
||||||
@click.group
|
@click.group
|
||||||
def get() -> None:
|
def get() -> None:
|
||||||
"""Commands to get information from or generate inventories"""
|
"""Commands to get information from or generate inventories."""
|
||||||
|
|
||||||
|
|
||||||
get.add_command(commands.from_cvp)
|
get.add_command(commands.from_cvp)
|
||||||
|
|
|
@ -2,15 +2,15 @@
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
# pylint: disable = redefined-outer-name
|
# pylint: disable = redefined-outer-name
|
||||||
"""
|
"""Click commands to get information from or generate inventories."""
|
||||||
Click commands to get information from or generate inventories
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from cvprac.cvp_client import CvpClient
|
from cvprac.cvp_client import CvpClient
|
||||||
|
@ -20,10 +20,12 @@ from rich.pretty import pretty_repr
|
||||||
from anta.cli.console import console
|
from anta.cli.console import console
|
||||||
from anta.cli.get.utils import inventory_output_options
|
from anta.cli.get.utils import inventory_output_options
|
||||||
from anta.cli.utils import ExitCode, inventory_options
|
from anta.cli.utils import ExitCode, inventory_options
|
||||||
from anta.inventory import AntaInventory
|
|
||||||
|
|
||||||
from .utils import create_inventory_from_ansible, create_inventory_from_cvp, get_cv_token
|
from .utils import create_inventory_from_ansible, create_inventory_from_cvp, get_cv_token
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.inventory import AntaInventory
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -35,30 +37,30 @@ logger = logging.getLogger(__name__)
|
||||||
@click.option("--password", "-p", help="CloudVision password", type=str, required=True)
|
@click.option("--password", "-p", help="CloudVision password", type=str, required=True)
|
||||||
@click.option("--container", "-c", help="CloudVision container where devices are configured", type=str)
|
@click.option("--container", "-c", help="CloudVision container where devices are configured", type=str)
|
||||||
def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str | None) -> None:
|
def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str | None) -> None:
|
||||||
"""
|
# pylint: disable=too-many-arguments
|
||||||
Build ANTA inventory from Cloudvision
|
"""Build ANTA inventory from Cloudvision.
|
||||||
|
|
||||||
TODO - handle get_inventory and get_devices_in_container failure
|
TODO - handle get_inventory and get_devices_in_container failure
|
||||||
"""
|
"""
|
||||||
logger.info(f"Getting authentication token for user '{username}' from CloudVision instance '{host}'")
|
logger.info("Getting authentication token for user '%s' from CloudVision instance '%s'", username, host)
|
||||||
token = get_cv_token(cvp_ip=host, cvp_username=username, cvp_password=password)
|
token = get_cv_token(cvp_ip=host, cvp_username=username, cvp_password=password)
|
||||||
|
|
||||||
clnt = CvpClient()
|
clnt = CvpClient()
|
||||||
try:
|
try:
|
||||||
clnt.connect(nodes=[host], username="", password="", api_token=token)
|
clnt.connect(nodes=[host], username="", password="", api_token=token)
|
||||||
except CvpApiError as error:
|
except CvpApiError as error:
|
||||||
logger.error(f"Error connecting to CloudVision: {error}")
|
logger.error("Error connecting to CloudVision: %s", error)
|
||||||
ctx.exit(ExitCode.USAGE_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
|
cvp_inventory = None
|
||||||
if container is None:
|
if container is None:
|
||||||
# Get a list of all devices
|
# Get a list of all devices
|
||||||
logger.info(f"Getting full inventory from CloudVision instance '{host}'")
|
logger.info("Getting full inventory from CloudVision instance '%s'", host)
|
||||||
cvp_inventory = clnt.api.get_inventory()
|
cvp_inventory = clnt.api.get_inventory()
|
||||||
else:
|
else:
|
||||||
# Get devices under a container
|
# Get devices under a container
|
||||||
logger.info(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)
|
cvp_inventory = clnt.api.get_devices_in_container(container)
|
||||||
create_inventory_from_cvp(cvp_inventory, output)
|
create_inventory_from_cvp(cvp_inventory, output)
|
||||||
|
|
||||||
|
@ -74,8 +76,8 @@ def from_cvp(ctx: click.Context, output: Path, host: str, username: str, passwor
|
||||||
required=True,
|
required=True,
|
||||||
)
|
)
|
||||||
def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_inventory: Path) -> None:
|
def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_inventory: Path) -> None:
|
||||||
"""Build ANTA inventory from an ansible inventory YAML file"""
|
"""Build ANTA inventory from an ansible inventory YAML file."""
|
||||||
logger.info(f"Building inventory from ansible file '{ansible_inventory}'")
|
logger.info("Building inventory from ansible file '%s'", ansible_inventory)
|
||||||
try:
|
try:
|
||||||
create_inventory_from_ansible(
|
create_inventory_from_ansible(
|
||||||
inventory=ansible_inventory,
|
inventory=ansible_inventory,
|
||||||
|
@ -90,10 +92,11 @@ def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_i
|
||||||
@click.command
|
@click.command
|
||||||
@inventory_options
|
@inventory_options
|
||||||
@click.option("--connected/--not-connected", help="Display inventory after connection has been created", default=False, required=False)
|
@click.option("--connected/--not-connected", help="Display inventory after connection has been created", default=False, required=False)
|
||||||
def inventory(inventory: AntaInventory, tags: list[str] | None, connected: bool) -> None:
|
def inventory(inventory: AntaInventory, tags: set[str] | None, *, connected: bool) -> None:
|
||||||
"""Show inventory loaded in ANTA."""
|
"""Show inventory loaded in ANTA."""
|
||||||
|
# TODO: @gmuloc - tags come from context - we cannot have everything..
|
||||||
logger.debug(f"Requesting devices for tags: {tags}")
|
# ruff: noqa: ARG001
|
||||||
|
logger.debug("Requesting devices for tags: %s", tags)
|
||||||
console.print("Current inventory content is:", style="white on blue")
|
console.print("Current inventory content is:", style="white on blue")
|
||||||
|
|
||||||
if connected:
|
if connected:
|
||||||
|
@ -105,11 +108,11 @@ def inventory(inventory: AntaInventory, tags: list[str] | None, connected: bool)
|
||||||
|
|
||||||
@click.command
|
@click.command
|
||||||
@inventory_options
|
@inventory_options
|
||||||
def tags(inventory: AntaInventory, tags: list[str] | None) -> None: # pylint: disable=unused-argument
|
def tags(inventory: AntaInventory, **kwargs: Any) -> None:
|
||||||
|
# pylint: disable=unused-argument
|
||||||
"""Get list of configured tags in user inventory."""
|
"""Get list of configured tags in user inventory."""
|
||||||
tags_found = []
|
tags: set[str] = set()
|
||||||
for device in inventory.values():
|
for device in inventory.values():
|
||||||
tags_found += device.tags
|
tags.update(device.tags)
|
||||||
tags_found = sorted(set(tags_found))
|
|
||||||
console.print("Tags found:")
|
console.print("Tags found:")
|
||||||
console.print_json(json.dumps(tags_found, indent=2))
|
console.print_json(json.dumps(sorted(tags), indent=2))
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Utils functions to use with anta.cli.get.commands module."""
|
||||||
Utils functions to use with anta.cli.get.commands module.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
|
@ -11,7 +10,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sys import stdin
|
from sys import stdin
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import requests
|
import requests
|
||||||
|
@ -27,8 +26,8 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def inventory_output_options(f: Any) -> Any:
|
def inventory_output_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
"""Click common options required when an inventory is being generated"""
|
"""Click common options required when an inventory is being generated."""
|
||||||
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--output",
|
"--output",
|
||||||
|
@ -50,7 +49,13 @@ def inventory_output_options(f: Any) -> Any:
|
||||||
)
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@functools.wraps(f)
|
@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
|
# Boolean to check if the file is empty
|
||||||
output_is_not_empty = output.exists() and output.stat().st_size != 0
|
output_is_not_empty = output.exists() and output.stat().st_size != 0
|
||||||
# Check overwrite when file is not empty
|
# Check overwrite when file is not empty
|
||||||
|
@ -58,7 +63,10 @@ def inventory_output_options(f: Any) -> Any:
|
||||||
is_tty = stdin.isatty()
|
is_tty = stdin.isatty()
|
||||||
if is_tty:
|
if is_tty:
|
||||||
# File has content and it is in an interactive TTY --> Prompt user
|
# File has content and it is in an interactive TTY --> Prompt user
|
||||||
click.confirm(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:
|
else:
|
||||||
# File has content and it is not interactive TTY nor overwrite set to True --> execution stop
|
# File has content and it is not interactive TTY nor overwrite set to True --> execution stop
|
||||||
logger.critical("Conversion aborted since destination file is not empty (not running in interactive TTY)")
|
logger.critical("Conversion aborted since destination file is not empty (not running in interactive TTY)")
|
||||||
|
@ -70,84 +78,94 @@ def inventory_output_options(f: Any) -> Any:
|
||||||
|
|
||||||
|
|
||||||
def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str) -> str:
|
def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str) -> str:
|
||||||
"""Generate AUTH token from CVP using password"""
|
"""Generate AUTH token from CVP using password."""
|
||||||
# TODO, need to handle requests eror
|
# TODO: need to handle requests error
|
||||||
|
|
||||||
# use CVP REST API to generate a token
|
# use CVP REST API to generate a token
|
||||||
URL = f"https://{cvp_ip}/cvpservice/login/authenticate.do"
|
url = f"https://{cvp_ip}/cvpservice/login/authenticate.do"
|
||||||
payload = json.dumps({"userId": cvp_username, "password": cvp_password})
|
payload = json.dumps({"userId": cvp_username, "password": cvp_password})
|
||||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||||
|
|
||||||
response = requests.request("POST", URL, headers=headers, data=payload, verify=False, timeout=10)
|
response = requests.request("POST", url, headers=headers, data=payload, verify=False, timeout=10)
|
||||||
return response.json()["sessionId"]
|
return response.json()["sessionId"]
|
||||||
|
|
||||||
|
|
||||||
def write_inventory_to_file(hosts: list[AntaInventoryHost], output: Path) -> None:
|
def write_inventory_to_file(hosts: list[AntaInventoryHost], output: Path) -> None:
|
||||||
"""Write a file inventory from pydantic models"""
|
"""Write a file inventory from pydantic models."""
|
||||||
i = AntaInventoryInput(hosts=hosts)
|
i = AntaInventoryInput(hosts=hosts)
|
||||||
with open(output, "w", encoding="UTF-8") as out_fd:
|
with output.open(mode="w", encoding="UTF-8") as out_fd:
|
||||||
out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: i.model_dump(exclude_unset=True)}))
|
out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: i.model_dump(exclude_unset=True)}))
|
||||||
logger.info(f"ANTA inventory file has been created: '{output}'")
|
logger.info("ANTA inventory file has been created: '%s'", output)
|
||||||
|
|
||||||
|
|
||||||
def create_inventory_from_cvp(inv: list[dict[str, Any]], output: Path) -> None:
|
def create_inventory_from_cvp(inv: list[dict[str, Any]], output: Path) -> None:
|
||||||
"""
|
"""Create an inventory file from Arista CloudVision inventory."""
|
||||||
Create an inventory file from Arista CloudVision inventory
|
logger.debug("Received %s device(s) from CloudVision", len(inv))
|
||||||
"""
|
|
||||||
logger.debug(f"Received {len(inv)} device(s) from CloudVision")
|
|
||||||
hosts = []
|
hosts = []
|
||||||
for dev in inv:
|
for dev in inv:
|
||||||
logger.info(f" * adding entry for {dev['hostname']}")
|
logger.info(" * adding entry for %s", dev["hostname"])
|
||||||
hosts.append(AntaInventoryHost(name=dev["hostname"], host=dev["ipAddress"], tags=[dev["containerName"].lower()]))
|
hosts.append(
|
||||||
|
AntaInventoryHost(
|
||||||
|
name=dev["hostname"],
|
||||||
|
host=dev["ipAddress"],
|
||||||
|
tags={dev["containerName"].lower()},
|
||||||
|
)
|
||||||
|
)
|
||||||
write_inventory_to_file(hosts, output)
|
write_inventory_to_file(hosts, output)
|
||||||
|
|
||||||
|
|
||||||
|
def find_ansible_group(data: dict[str, Any], group: str) -> dict[str, Any] | None:
|
||||||
|
"""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:
|
def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group: str = "all") -> None:
|
||||||
"""
|
"""Create an ANTA inventory from an Ansible inventory YAML file.
|
||||||
Create an ANTA inventory from an Ansible inventory YAML file
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
inventory: Ansible Inventory file to read
|
inventory: Ansible Inventory file to read
|
||||||
output: ANTA inventory file to generate.
|
output: ANTA inventory file to generate.
|
||||||
ansible_group: Ansible group from where to extract data.
|
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:
|
try:
|
||||||
with open(inventory, encoding="utf-8") as inv:
|
with inventory.open(encoding="utf-8") as inv:
|
||||||
ansible_inventory = yaml.safe_load(inv)
|
ansible_inventory = yaml.safe_load(inv)
|
||||||
except OSError as 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:
|
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)
|
ansible_inventory = find_ansible_group(ansible_inventory, ansible_group)
|
||||||
|
|
||||||
if ansible_inventory is None:
|
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)
|
ansible_hosts = deep_yaml_parsing(ansible_inventory)
|
||||||
write_inventory_to_file(ansible_hosts, output)
|
write_inventory_to_file(ansible_hosts, output)
|
||||||
|
|
|
@ -1,38 +1,40 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Click commands that run ANTA tests using anta.runner."""
|
||||||
Click commands that run ANTA tests using anta.runner
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from typing import TYPE_CHECKING, get_args
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from anta.catalog import AntaCatalog
|
|
||||||
from anta.cli.nrfu import commands
|
from anta.cli.nrfu import commands
|
||||||
from anta.cli.utils import AliasedGroup, catalog_options, inventory_options
|
from anta.cli.utils import AliasedGroup, catalog_options, inventory_options
|
||||||
from anta.inventory import AntaInventory
|
from anta.custom_types import TestStatus
|
||||||
from anta.models import AntaTest
|
from anta.models import AntaTest
|
||||||
from anta.result_manager import ResultManager
|
from anta.result_manager import ResultManager
|
||||||
from anta.runner import main
|
from anta.runner import main
|
||||||
|
|
||||||
from .utils import anta_progress_bar, print_settings
|
from .utils import anta_progress_bar, print_settings
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.catalog import AntaCatalog
|
||||||
|
from anta.inventory import AntaInventory
|
||||||
|
|
||||||
|
|
||||||
class IgnoreRequiredWithHelp(AliasedGroup):
|
class IgnoreRequiredWithHelp(AliasedGroup):
|
||||||
"""
|
"""Custom Click Group.
|
||||||
|
|
||||||
https://stackoverflow.com/questions/55818737/python-click-application-required-parameters-have-precedence-over-sub-command-he
|
https://stackoverflow.com/questions/55818737/python-click-application-required-parameters-have-precedence-over-sub-command-he
|
||||||
|
|
||||||
Solution to allow help without required options on subcommand
|
Solution to allow help without required options on subcommand
|
||||||
This is not planned to be fixed in click as per: https://github.com/pallets/click/issues/295#issuecomment-708129734
|
This is not planned to be fixed in click as per: https://github.com/pallets/click/issues/295#issuecomment-708129734.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
||||||
"""
|
"""Ignore MissingParameter exception when parsing arguments if `--help` is present for a subcommand."""
|
||||||
Ignore MissingParameter exception when parsing arguments if `--help`
|
|
||||||
is present for a subcommand
|
|
||||||
"""
|
|
||||||
# Adding a flag for potential callbacks
|
# Adding a flag for potential callbacks
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
if "--help" in args:
|
if "--help" in args:
|
||||||
|
@ -51,14 +53,66 @@ class IgnoreRequiredWithHelp(AliasedGroup):
|
||||||
return super().parse_args(ctx, args)
|
return super().parse_args(ctx, args)
|
||||||
|
|
||||||
|
|
||||||
|
HIDE_STATUS: list[str] = list(get_args(TestStatus))
|
||||||
|
HIDE_STATUS.remove("unset")
|
||||||
|
|
||||||
|
|
||||||
@click.group(invoke_without_command=True, cls=IgnoreRequiredWithHelp)
|
@click.group(invoke_without_command=True, cls=IgnoreRequiredWithHelp)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@inventory_options
|
@inventory_options
|
||||||
@catalog_options
|
@catalog_options
|
||||||
@click.option("--ignore-status", help="Always exit with success", show_envvar=True, is_flag=True, default=False)
|
@click.option(
|
||||||
@click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False)
|
"--device",
|
||||||
def nrfu(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, catalog: AntaCatalog, ignore_status: bool, ignore_error: bool) -> None:
|
"-d",
|
||||||
"""Run ANTA tests on devices"""
|
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="Group result by test or device.",
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
# pylint: disable=too-many-arguments
|
||||||
|
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,
|
||||||
|
) -> None:
|
||||||
|
"""Run ANTA tests on selected inventory devices."""
|
||||||
# If help is invoke somewhere, skip the command
|
# If help is invoke somewhere, skip the command
|
||||||
if ctx.obj.get("_anta_help"):
|
if ctx.obj.get("_anta_help"):
|
||||||
return
|
return
|
||||||
|
@ -67,9 +121,10 @@ def nrfu(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, c
|
||||||
ctx.obj["result_manager"] = ResultManager()
|
ctx.obj["result_manager"] = ResultManager()
|
||||||
ctx.obj["ignore_status"] = ignore_status
|
ctx.obj["ignore_status"] = ignore_status
|
||||||
ctx.obj["ignore_error"] = ignore_error
|
ctx.obj["ignore_error"] = ignore_error
|
||||||
|
ctx.obj["hide"] = set(hide) if hide else None
|
||||||
print_settings(inventory, catalog)
|
print_settings(inventory, catalog)
|
||||||
with anta_progress_bar() as AntaTest.progress:
|
with anta_progress_bar() as AntaTest.progress:
|
||||||
asyncio.run(main(ctx.obj["result_manager"], inventory, catalog, tags=tags))
|
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))
|
||||||
# Invoke `anta nrfu table` if no command is passed
|
# Invoke `anta nrfu table` if no command is passed
|
||||||
if ctx.invoked_subcommand is None:
|
if ctx.invoked_subcommand is None:
|
||||||
ctx.invoke(commands.table)
|
ctx.invoke(commands.table)
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Click commands that render ANTA tests results."""
|
||||||
Click commands that render ANTA tests results
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
@ -20,14 +20,19 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@click.option("--device", "-d", help="Show a summary for this device", type=str, required=False)
|
|
||||||
@click.option("--test", "-t", help="Show a summary for this test", type=str, required=False)
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--group-by", 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:
|
def table(
|
||||||
"""ANTA command to check network states with table result"""
|
ctx: click.Context,
|
||||||
print_table(results=ctx.obj["result_manager"], device=device, group_by=group_by, test=test)
|
group_by: Literal["device", "test"] | None,
|
||||||
|
) -> None:
|
||||||
|
"""ANTA command to check network states with table result."""
|
||||||
|
print_table(ctx, group_by=group_by)
|
||||||
exit_with_code(ctx)
|
exit_with_code(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@ -42,18 +47,16 @@ def table(ctx: click.Context, device: str | None, test: str | None, group_by: st
|
||||||
help="Path to save report as a file",
|
help="Path to save report as a file",
|
||||||
)
|
)
|
||||||
def json(ctx: click.Context, output: pathlib.Path | None) -> None:
|
def json(ctx: click.Context, output: pathlib.Path | None) -> None:
|
||||||
"""ANTA command to check network state with JSON result"""
|
"""ANTA command to check network state with JSON result."""
|
||||||
print_json(results=ctx.obj["result_manager"], output=output)
|
print_json(ctx, output=output)
|
||||||
exit_with_code(ctx)
|
exit_with_code(ctx)
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@click.option("--search", "-s", help="Regular expression to search in both name and test", type=str, required=False)
|
def text(ctx: click.Context) -> None:
|
||||||
@click.option("--skip-error", help="Hide tests in errors due to connectivity issue", default=False, is_flag=True, show_default=True, required=False)
|
"""ANTA command to check network states with text result."""
|
||||||
def text(ctx: click.Context, search: str | None, skip_error: bool) -> None:
|
print_text(ctx)
|
||||||
"""ANTA command to check network states with text result"""
|
|
||||||
print_text(results=ctx.obj["result_manager"], search=search, skip_error=skip_error)
|
|
||||||
exit_with_code(ctx)
|
exit_with_code(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@ -76,6 +79,6 @@ def text(ctx: click.Context, search: str | None, skip_error: bool) -> None:
|
||||||
help="Path to save report as a file",
|
help="Path to save report as a file",
|
||||||
)
|
)
|
||||||
def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path | None) -> None:
|
def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path | None) -> None:
|
||||||
"""ANTA command to check network state with templated report"""
|
"""ANTA command to check network state with templated report."""
|
||||||
print_jinja(results=ctx.obj["result_manager"], template=template, output=output)
|
print_jinja(results=ctx.obj["result_manager"], template=template, output=output)
|
||||||
exit_with_code(ctx)
|
exit_with_code(ctx)
|
||||||
|
|
|
@ -1,101 +1,96 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Utils functions to use with anta.cli.nrfu.commands module."""
|
||||||
Utils functions to use with anta.cli.nrfu.commands module.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
from typing import TYPE_CHECKING, Literal
|
||||||
import re
|
|
||||||
|
|
||||||
import rich
|
import rich
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.pretty import pprint
|
|
||||||
from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn
|
from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn
|
||||||
|
|
||||||
from anta.catalog import AntaCatalog
|
|
||||||
from anta.cli.console import console
|
from anta.cli.console import console
|
||||||
from anta.inventory import AntaInventory
|
|
||||||
from anta.reporter import ReportJinja, ReportTable
|
from anta.reporter import ReportJinja, ReportTable
|
||||||
from anta.result_manager import ResultManager
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from anta.catalog import AntaCatalog
|
||||||
|
from anta.inventory import AntaInventory
|
||||||
|
from anta.result_manager import ResultManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_result_manager(ctx: click.Context) -> ResultManager:
|
||||||
|
"""Get a ResultManager instance based on Click context."""
|
||||||
|
return ctx.obj["result_manager"].filter(ctx.obj.get("hide")) if ctx.obj.get("hide") is not None else ctx.obj["result_manager"]
|
||||||
|
|
||||||
|
|
||||||
def print_settings(
|
def print_settings(
|
||||||
inventory: AntaInventory,
|
inventory: AntaInventory,
|
||||||
catalog: AntaCatalog,
|
catalog: AntaCatalog,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Print ANTA settings before running tests"""
|
"""Print ANTA settings before running tests."""
|
||||||
message = f"Running ANTA tests:\n- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests"
|
message = f"Running ANTA tests:\n- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests"
|
||||||
console.print(Panel.fit(message, style="cyan", title="[green]Settings"))
|
console.print(Panel.fit(message, style="cyan", title="[green]Settings"))
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
def print_table(results: ResultManager, device: str | None = None, test: str | None = None, group_by: str | None = None) -> None:
|
def print_table(ctx: click.Context, group_by: Literal["device", "test"] | None = None) -> None:
|
||||||
"""Print result in a table"""
|
"""Print result in a table."""
|
||||||
reporter = ReportTable()
|
reporter = ReportTable()
|
||||||
console.print()
|
console.print()
|
||||||
if device:
|
results = _get_result_manager(ctx)
|
||||||
console.print(reporter.report_all(result_manager=results, host=device))
|
|
||||||
elif test:
|
if group_by == "device":
|
||||||
console.print(reporter.report_all(result_manager=results, testcase=test))
|
console.print(reporter.report_summary_devices(results))
|
||||||
elif group_by == "device":
|
|
||||||
console.print(reporter.report_summary_hosts(result_manager=results, host=None))
|
|
||||||
elif group_by == "test":
|
elif group_by == "test":
|
||||||
console.print(reporter.report_summary_tests(result_manager=results, testcase=None))
|
console.print(reporter.report_summary_tests(results))
|
||||||
else:
|
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:
|
def print_json(ctx: click.Context, output: pathlib.Path | None = None) -> None:
|
||||||
"""Print result in a json format"""
|
"""Print result in a json format."""
|
||||||
|
results = _get_result_manager(ctx)
|
||||||
console.print()
|
console.print()
|
||||||
console.print(Panel("JSON results of all tests", style="cyan"))
|
console.print(Panel("JSON results", style="cyan"))
|
||||||
rich.print_json(results.get_json_results())
|
rich.print_json(results.json)
|
||||||
if output is not None:
|
if output is not None:
|
||||||
with open(output, "w", encoding="utf-8") as fout:
|
with output.open(mode="w", encoding="utf-8") as fout:
|
||||||
fout.write(results.get_json_results())
|
fout.write(results.json)
|
||||||
|
|
||||||
|
|
||||||
def print_list(results: ResultManager, output: pathlib.Path | None = None) -> None:
|
def print_text(ctx: click.Context) -> None:
|
||||||
"""Print result in a list"""
|
"""Print results as simple text."""
|
||||||
console.print()
|
console.print()
|
||||||
console.print(Panel.fit("List results of all tests", style="cyan"))
|
for test in _get_result_manager(ctx).results:
|
||||||
pprint(results.get_results())
|
message = f" ({test.messages[0]!s})" if len(test.messages) > 0 else ""
|
||||||
if output is not None:
|
console.print(f"{test.name} :: {test.test} :: [{test.result}]{test.result.upper()}[/{test.result}]{message}", highlight=False)
|
||||||
with open(output, "w", encoding="utf-8") as fout:
|
|
||||||
fout.write(str(results.get_results()))
|
|
||||||
|
|
||||||
|
|
||||||
def print_text(results: ResultManager, search: str | None = None, skip_error: bool = False) -> None:
|
|
||||||
"""Print results as simple text"""
|
|
||||||
console.print()
|
|
||||||
regexp = re.compile(search or ".*")
|
|
||||||
for line in results.get_results():
|
|
||||||
if any(regexp.match(entry) for entry in [line.name, line.test]) and (not skip_error or line.result != "error"):
|
|
||||||
message = f" ({str(line.messages[0])})" if len(line.messages) > 0 else ""
|
|
||||||
console.print(f"{line.name} :: {line.test} :: [{line.result}]{line.result.upper()}[/{line.result}]{message}", highlight=False)
|
|
||||||
|
|
||||||
|
|
||||||
def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib.Path | None = None) -> None:
|
def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib.Path | None = None) -> None:
|
||||||
"""Print result based on template."""
|
"""Print result based on template."""
|
||||||
console.print()
|
console.print()
|
||||||
reporter = ReportJinja(template_path=template)
|
reporter = ReportJinja(template_path=template)
|
||||||
json_data = json.loads(results.get_json_results())
|
json_data = json.loads(results.json)
|
||||||
report = reporter.render(json_data)
|
report = reporter.render(json_data)
|
||||||
console.print(report)
|
console.print(report)
|
||||||
if output is not None:
|
if output is not None:
|
||||||
with open(output, "w", encoding="utf-8") as file:
|
with output.open(mode="w", encoding="utf-8") as file:
|
||||||
file.write(report)
|
file.write(report)
|
||||||
|
|
||||||
|
|
||||||
# Adding our own ANTA spinner - overriding rich SPINNERS for our own
|
# Adding our own ANTA spinner - overriding rich SPINNERS for our own
|
||||||
# so ignore warning for redefinition
|
# so ignore warning for redefinition
|
||||||
rich.spinner.SPINNERS = { # type: ignore[attr-defined] # noqa: F811
|
rich.spinner.SPINNERS = { # type: ignore[attr-defined]
|
||||||
"anta": {
|
"anta": {
|
||||||
"interval": 150,
|
"interval": 150,
|
||||||
"frames": [
|
"frames": [
|
||||||
|
@ -112,14 +107,12 @@ rich.spinner.SPINNERS = { # type: ignore[attr-defined] # noqa: F811
|
||||||
"( 🐌 )",
|
"( 🐌 )",
|
||||||
"( 🐌)",
|
"( 🐌)",
|
||||||
],
|
],
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def anta_progress_bar() -> Progress:
|
def anta_progress_bar() -> Progress:
|
||||||
"""
|
"""Return a customized Progress for progress bar."""
|
||||||
Return a customized Progress for progress bar
|
|
||||||
"""
|
|
||||||
return Progress(
|
return Progress(
|
||||||
SpinnerColumn("anta"),
|
SpinnerColumn("anta"),
|
||||||
TextColumn("•"),
|
TextColumn("•"),
|
||||||
|
|
|
@ -1,16 +1,15 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Utils functions to use with anta.cli module."""
|
||||||
Utils functions to use with anta.cli module.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, Callable
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
@ -18,7 +17,7 @@ from yaml import YAMLError
|
||||||
|
|
||||||
from anta.catalog import AntaCatalog
|
from anta.catalog import AntaCatalog
|
||||||
from anta.inventory import AntaInventory
|
from anta.inventory import AntaInventory
|
||||||
from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError
|
from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from click import Option
|
from click import Option
|
||||||
|
@ -27,10 +26,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ExitCode(enum.IntEnum):
|
class ExitCode(enum.IntEnum):
|
||||||
"""
|
"""Encodes the valid exit codes by anta inspired from pytest."""
|
||||||
Encodes the valid exit codes by anta
|
|
||||||
inspired from pytest
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Tests passed.
|
# Tests passed.
|
||||||
OK = 0
|
OK = 0
|
||||||
|
@ -44,19 +40,18 @@ class ExitCode(enum.IntEnum):
|
||||||
TESTS_FAILED = 4
|
TESTS_FAILED = 4
|
||||||
|
|
||||||
|
|
||||||
def parse_tags(ctx: click.Context, param: Option, value: str) -> list[str] | None:
|
def parse_tags(ctx: click.Context, param: Option, value: str | None) -> set[str] | None:
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
"""
|
# ruff: noqa: ARG001
|
||||||
Click option callback to parse an ANTA inventory tags
|
"""Click option callback to parse an ANTA inventory tags."""
|
||||||
"""
|
|
||||||
if value is not None:
|
if value is not None:
|
||||||
return value.split(",") if "," in value else [value]
|
return set(value.split(",")) if "," in value else {value}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def exit_with_code(ctx: click.Context) -> None:
|
def exit_with_code(ctx: click.Context) -> None:
|
||||||
"""
|
"""Exit the Click application with an exit code.
|
||||||
Exit the Click application with an exit code.
|
|
||||||
This function determines the global test status to be either `unset`, `skipped`, `success` or `error`
|
This function determines the global test status to be either `unset`, `skipped`, `success` or `error`
|
||||||
from the `ResultManger` instance.
|
from the `ResultManger` instance.
|
||||||
If flag `ignore_error` is set, the `error` status will be ignored in all the tests.
|
If flag `ignore_error` is set, the `error` status will be ignored in all the tests.
|
||||||
|
@ -64,10 +59,12 @@ def exit_with_code(ctx: click.Context) -> None:
|
||||||
Exit the application with the following exit code:
|
Exit the application with the following exit code:
|
||||||
* 0 if `ignore_status` is `True` or global test status is `unset`, `skipped` or `success`
|
* 0 if `ignore_status` is `True` or global test status is `unset`, `skipped` or `success`
|
||||||
* 1 if status is `failure`
|
* 1 if status is `failure`
|
||||||
* 2 if status is `error`
|
* 2 if status is `error`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
ctx: Click Context
|
ctx: Click Context
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if ctx.obj.get("ignore_status"):
|
if ctx.obj.get("ignore_status"):
|
||||||
ctx.exit(ExitCode.OK)
|
ctx.exit(ExitCode.OK)
|
||||||
|
@ -83,18 +80,19 @@ def exit_with_code(ctx: click.Context) -> None:
|
||||||
ctx.exit(ExitCode.TESTS_ERROR)
|
ctx.exit(ExitCode.TESTS_ERROR)
|
||||||
|
|
||||||
logger.error("Please gather logs and open an issue on Github.")
|
logger.error("Please gather logs and open an issue on Github.")
|
||||||
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):
|
class AliasedGroup(click.Group):
|
||||||
"""
|
"""Implements a subclass of Group that accepts a prefix for a command.
|
||||||
Implements a subclass of Group that accepts a prefix for a command.
|
|
||||||
If there were a command called push, it would accept pus as an alias (so long as it was unique)
|
If there were a command called push, it would accept pus as an alias (so long as it was unique)
|
||||||
From Click documentation
|
From Click documentation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_command(self, ctx: click.Context, cmd_name: str) -> Any:
|
def get_command(self, ctx: click.Context, cmd_name: str) -> Any:
|
||||||
"""Todo: document code"""
|
"""Todo: document code."""
|
||||||
rv = click.Group.get_command(self, ctx, cmd_name)
|
rv = click.Group.get_command(self, ctx, cmd_name)
|
||||||
if rv is not None:
|
if rv is not None:
|
||||||
return rv
|
return rv
|
||||||
|
@ -107,15 +105,16 @@ class AliasedGroup(click.Group):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def resolve_command(self, ctx: click.Context, args: Any) -> Any:
|
def resolve_command(self, ctx: click.Context, args: Any) -> Any:
|
||||||
"""Todo: document code"""
|
"""Todo: document code."""
|
||||||
# always return the full command name
|
# always return the full command name
|
||||||
_, cmd, args = super().resolve_command(ctx, args)
|
_, cmd, args = super().resolve_command(ctx, args)
|
||||||
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: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
def inventory_options(f: Any) -> Any:
|
"""Click common options when requiring an inventory to interact with devices."""
|
||||||
"""Click common options when requiring an inventory to interact with devices"""
|
|
||||||
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--username",
|
"--username",
|
||||||
|
@ -159,26 +158,34 @@ def inventory_options(f: Any) -> Any:
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--timeout",
|
"--timeout",
|
||||||
help="Global connection timeout",
|
help="Global API timeout. This value will be used for all devices.",
|
||||||
default=30,
|
default=30.0,
|
||||||
show_envvar=True,
|
show_envvar=True,
|
||||||
envvar="ANTA_TIMEOUT",
|
envvar="ANTA_TIMEOUT",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--insecure",
|
"--insecure",
|
||||||
help="Disable SSH Host Key validation",
|
help="Disable SSH Host Key validation.",
|
||||||
default=False,
|
default=False,
|
||||||
show_envvar=True,
|
show_envvar=True,
|
||||||
envvar="ANTA_INSECURE",
|
envvar="ANTA_INSECURE",
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
show_default=True,
|
show_default=True,
|
||||||
)
|
)
|
||||||
@click.option("--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(
|
@click.option(
|
||||||
"--inventory",
|
"--inventory",
|
||||||
"-i",
|
"-i",
|
||||||
help="Path to the inventory YAML file",
|
help="Path to the inventory YAML file.",
|
||||||
envvar="ANTA_INVENTORY",
|
envvar="ANTA_INVENTORY",
|
||||||
show_envvar=True,
|
show_envvar=True,
|
||||||
required=True,
|
required=True,
|
||||||
|
@ -186,8 +193,7 @@ def inventory_options(f: Any) -> Any:
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--tags",
|
"--tags",
|
||||||
"-t",
|
help="List of tags using comma as separator: tag1,tag2,tag3.",
|
||||||
help="List of tags using comma as separator: tag1,tag2,tag3",
|
|
||||||
show_envvar=True,
|
show_envvar=True,
|
||||||
envvar="ANTA_TAGS",
|
envvar="ANTA_TAGS",
|
||||||
type=str,
|
type=str,
|
||||||
|
@ -200,13 +206,13 @@ def inventory_options(f: Any) -> Any:
|
||||||
ctx: click.Context,
|
ctx: click.Context,
|
||||||
*args: tuple[Any],
|
*args: tuple[Any],
|
||||||
inventory: Path,
|
inventory: Path,
|
||||||
tags: list[str] | None,
|
tags: set[str] | None,
|
||||||
username: str,
|
username: str,
|
||||||
password: str | None,
|
password: str | None,
|
||||||
enable_password: str | None,
|
enable_password: str | None,
|
||||||
enable: bool,
|
enable: bool,
|
||||||
prompt: bool,
|
prompt: bool,
|
||||||
timeout: int,
|
timeout: float,
|
||||||
insecure: bool,
|
insecure: bool,
|
||||||
disable_cache: bool,
|
disable_cache: bool,
|
||||||
**kwargs: dict[str, Any],
|
**kwargs: dict[str, Any],
|
||||||
|
@ -218,17 +224,25 @@ def inventory_options(f: Any) -> Any:
|
||||||
if prompt:
|
if prompt:
|
||||||
# User asked for a password prompt
|
# User asked for a password prompt
|
||||||
if password is None:
|
if password is None:
|
||||||
password = click.prompt("Please enter a password to connect to EOS", type=str, hide_input=True, confirmation_prompt=True)
|
password = click.prompt(
|
||||||
if enable:
|
"Please enter a password to connect to EOS",
|
||||||
if enable_password is None:
|
type=str,
|
||||||
if click.confirm("Is a password required to enter EOS privileged EXEC mode?"):
|
hide_input=True,
|
||||||
enable_password = click.prompt(
|
confirmation_prompt=True,
|
||||||
"Please enter a password to enter EOS privileged EXEC mode", 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:
|
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:
|
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:
|
try:
|
||||||
i = AntaInventory.parse(
|
i = AntaInventory.parse(
|
||||||
filename=inventory,
|
filename=inventory,
|
||||||
|
@ -240,15 +254,15 @@ def inventory_options(f: Any) -> Any:
|
||||||
insecure=insecure,
|
insecure=insecure,
|
||||||
disable_cache=disable_cache,
|
disable_cache=disable_cache,
|
||||||
)
|
)
|
||||||
except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchema, InventoryRootKeyError):
|
except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError):
|
||||||
ctx.exit(ExitCode.USAGE_ERROR)
|
ctx.exit(ExitCode.USAGE_ERROR)
|
||||||
return f(*args, inventory=i, tags=tags, **kwargs)
|
return f(*args, inventory=i, tags=tags, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
def catalog_options(f: Any) -> Any:
|
def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
"""Click common options when requiring a test catalog to execute ANTA tests"""
|
"""Click common options when requiring a test catalog to execute ANTA tests."""
|
||||||
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--catalog",
|
"--catalog",
|
||||||
|
@ -256,12 +270,23 @@ def catalog_options(f: Any) -> Any:
|
||||||
envvar="ANTA_CATALOG",
|
envvar="ANTA_CATALOG",
|
||||||
show_envvar=True,
|
show_envvar=True,
|
||||||
help="Path to the test catalog YAML file",
|
help="Path to the test catalog YAML file",
|
||||||
type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path),
|
type=click.Path(
|
||||||
|
file_okay=True,
|
||||||
|
dir_okay=False,
|
||||||
|
exists=True,
|
||||||
|
readable=True,
|
||||||
|
path_type=Path,
|
||||||
|
),
|
||||||
required=True,
|
required=True,
|
||||||
)
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@functools.wraps(f)
|
@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,
|
||||||
|
**kwargs: dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
# If help is invoke somewhere, do not parse catalog
|
# If help is invoke somewhere, do not parse catalog
|
||||||
if ctx.obj.get("_anta_help"):
|
if ctx.obj.get("_anta_help"):
|
||||||
return f(*args, catalog=None, **kwargs)
|
return f(*args, catalog=None, **kwargs)
|
||||||
|
|
|
@ -1,19 +1,17 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module that provides predefined types for AntaTest.Input instances."""
|
||||||
Module that provides predefined types for AntaTest.Input instances
|
|
||||||
"""
|
|
||||||
import re
|
import re
|
||||||
from typing import Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic.functional_validators import AfterValidator, BeforeValidator
|
from pydantic.functional_validators import AfterValidator, BeforeValidator
|
||||||
from typing_extensions import Annotated
|
|
||||||
|
|
||||||
|
|
||||||
def aaa_group_prefix(v: str) -> str:
|
def aaa_group_prefix(v: str) -> str:
|
||||||
"""Prefix the AAA method with 'group' if it is known"""
|
"""Prefix the AAA method with 'group' if it is known."""
|
||||||
built_in_methods = ["local", "none", "logging"]
|
built_in_methods = ["local", "none", "logging"]
|
||||||
return f"group {v}" if v not in built_in_methods and not v.startswith("group ") else v
|
return f"group {v}" if v not in built_in_methods and not v.startswith("group ") else v
|
||||||
|
|
||||||
|
@ -24,11 +22,13 @@ def interface_autocomplete(v: str) -> str:
|
||||||
Supported alias:
|
Supported alias:
|
||||||
- `et`, `eth` will be changed to `Ethernet`
|
- `et`, `eth` will be changed to `Ethernet`
|
||||||
- `po` will be changed to `Port-Channel`
|
- `po` will be changed to `Port-Channel`
|
||||||
- `lo` will be changed to `Loopback`"""
|
- `lo` will be changed to `Loopback`
|
||||||
|
"""
|
||||||
intf_id_re = re.compile(r"[0-9]+(\/[0-9]+)*(\.[0-9]+)?")
|
intf_id_re = re.compile(r"[0-9]+(\/[0-9]+)*(\.[0-9]+)?")
|
||||||
m = intf_id_re.search(v)
|
m = intf_id_re.search(v)
|
||||||
if m is None:
|
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]
|
intf_id = m[0]
|
||||||
|
|
||||||
alias_map = {"et": "Ethernet", "eth": "Ethernet", "po": "Port-Channel", "lo": "Loopback"}
|
alias_map = {"et": "Ethernet", "eth": "Ethernet", "po": "Port-Channel", "lo": "Loopback"}
|
||||||
|
@ -43,10 +43,12 @@ def interface_autocomplete(v: str) -> str:
|
||||||
def interface_case_sensitivity(v: str) -> str:
|
def interface_case_sensitivity(v: str) -> str:
|
||||||
"""Reformat interface name to match expected case sensitivity.
|
"""Reformat interface name to match expected case sensitivity.
|
||||||
|
|
||||||
Examples:
|
Examples
|
||||||
|
--------
|
||||||
- ethernet -> Ethernet
|
- ethernet -> Ethernet
|
||||||
- vlan -> Vlan
|
- vlan -> Vlan
|
||||||
- loopback -> Loopback
|
- loopback -> Loopback
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if isinstance(v, str) and len(v) > 0 and not v[0].isupper():
|
if isinstance(v, str) and len(v) > 0 and not v[0].isupper():
|
||||||
return f"{v[0].upper()}{v[1:]}"
|
return f"{v[0].upper()}{v[1:]}"
|
||||||
|
@ -54,13 +56,15 @@ def interface_case_sensitivity(v: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str:
|
def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str:
|
||||||
"""
|
"""Abbreviations for different BGP multiprotocol capabilities.
|
||||||
Abbreviations for different BGP multiprotocol capabilities.
|
|
||||||
Examples:
|
Examples
|
||||||
|
--------
|
||||||
- IPv4 Unicast
|
- IPv4 Unicast
|
||||||
- L2vpnEVPN
|
- L2vpnEVPN
|
||||||
- ipv4 MPLS Labels
|
- ipv4 MPLS Labels
|
||||||
- ipv4Mplsvpn
|
- ipv4Mplsvpn
|
||||||
|
|
||||||
"""
|
"""
|
||||||
patterns = {
|
patterns = {
|
||||||
r"\b(l2[\s\-]?vpn[\s\-]?evpn)\b": "l2VpnEvpn",
|
r"\b(l2[\s\-]?vpn[\s\-]?evpn)\b": "l2VpnEvpn",
|
||||||
|
@ -97,8 +101,8 @@ VxlanSrcIntf = Annotated[
|
||||||
BeforeValidator(interface_autocomplete),
|
BeforeValidator(interface_autocomplete),
|
||||||
BeforeValidator(interface_case_sensitivity),
|
BeforeValidator(interface_case_sensitivity),
|
||||||
]
|
]
|
||||||
Afi = Literal["ipv4", "ipv6", "vpn-ipv4", "vpn-ipv6", "evpn", "rt-membership"]
|
Afi = Literal["ipv4", "ipv6", "vpn-ipv4", "vpn-ipv6", "evpn", "rt-membership", "path-selection", "link-state"]
|
||||||
Safi = Literal["unicast", "multicast", "labeled-unicast"]
|
Safi = Literal["unicast", "multicast", "labeled-unicast", "sr-te"]
|
||||||
EncryptionAlgorithm = Literal["RSA", "ECDSA"]
|
EncryptionAlgorithm = Literal["RSA", "ECDSA"]
|
||||||
RsaKeySize = Literal[2048, 3072, 4096]
|
RsaKeySize = Literal[2048, 3072, 4096]
|
||||||
EcdsaKeySize = Literal[256, 384, 521]
|
EcdsaKeySize = Literal[256, 384, 521]
|
||||||
|
@ -120,3 +124,8 @@ ErrDisableReasons = Literal[
|
||||||
"uplink-failure-detection",
|
"uplink-failure-detection",
|
||||||
]
|
]
|
||||||
ErrDisableInterval = Annotated[int, Field(ge=30, le=86400)]
|
ErrDisableInterval = Annotated[int, Field(ge=30, le=86400)]
|
||||||
|
Percent = Annotated[float, Field(ge=0.0, le=100.0)]
|
||||||
|
PositiveInteger = Annotated[int, Field(ge=0)]
|
||||||
|
Revision = Annotated[int, Field(ge=1, le=99)]
|
||||||
|
Hostname = Annotated[str, Field(pattern=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])$")]
|
||||||
|
Port = Annotated[int, Field(ge=1, le=65535)]
|
||||||
|
|
|
@ -2,40 +2,45 @@
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""decorators for tests."""
|
"""decorators for tests."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, cast
|
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
|
||||||
|
|
||||||
from anta.models import AntaTest, logger
|
from anta.models import AntaTest, logger
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from anta.result_manager.models import TestResult
|
from anta.result_manager.models import TestResult
|
||||||
|
|
||||||
# TODO - should probably use mypy Awaitable in some places rather than this everywhere - @gmuloc
|
# TODO: should probably use mypy Awaitable in some places rather than this everywhere - @gmuloc
|
||||||
F = TypeVar("F", bound=Callable[..., Any])
|
F = TypeVar("F", bound=Callable[..., Any])
|
||||||
|
|
||||||
|
|
||||||
def deprecated_test(new_tests: Optional[list[str]] = None) -> Callable[[F], F]:
|
def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]:
|
||||||
"""
|
"""Return a decorator to log a message of WARNING severity when a test is deprecated.
|
||||||
Return a decorator to log a message of WARNING severity when a test is deprecated.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
new_tests (Optional[list[str]]): A list of new test classes that should replace the deprecated test.
|
----
|
||||||
|
new_tests: A list of new test classes that should replace the deprecated test.
|
||||||
|
|
||||||
Returns:
|
Returns
|
||||||
|
-------
|
||||||
Callable[[F], F]: A decorator that can be used to wrap test functions.
|
Callable[[F], F]: A decorator that can be used to wrap test functions.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(function: F) -> F:
|
def decorator(function: F) -> F:
|
||||||
"""
|
"""Actual decorator that logs the message.
|
||||||
Actual decorator that logs the message.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
function (F): The test function to be decorated.
|
----
|
||||||
|
function: The test function to be decorated.
|
||||||
|
|
||||||
Returns:
|
Returns
|
||||||
|
-------
|
||||||
F: The decorated function.
|
F: The decorated function.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(function)
|
@wraps(function)
|
||||||
|
@ -43,9 +48,9 @@ def deprecated_test(new_tests: Optional[list[str]] = None) -> Callable[[F], F]:
|
||||||
anta_test = args[0]
|
anta_test = args[0]
|
||||||
if new_tests:
|
if new_tests:
|
||||||
new_test_names = ", ".join(new_tests)
|
new_test_names = ", ".join(new_tests)
|
||||||
logger.warning(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:
|
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 await function(*args, **kwargs)
|
||||||
|
|
||||||
return cast(F, wrapper)
|
return cast(F, wrapper)
|
||||||
|
@ -54,34 +59,37 @@ def deprecated_test(new_tests: Optional[list[str]] = None) -> Callable[[F], F]:
|
||||||
|
|
||||||
|
|
||||||
def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]:
|
def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]:
|
||||||
"""
|
"""Return a decorator to skip a test based on the device's hardware model.
|
||||||
Return a decorator to skip a test based on the device's hardware model.
|
|
||||||
|
|
||||||
This decorator factory generates a decorator that will check the hardware model of the device
|
This decorator factory generates a decorator that will check the hardware model of the device
|
||||||
the test is run on. If the model is in the list of platforms specified, the test will be skipped.
|
the test is run on. If the model is in the list of platforms specified, the test will be skipped.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
platforms (list[str]): List of hardware models on which the test should be skipped.
|
----
|
||||||
|
platforms: List of hardware models on which the test should be skipped.
|
||||||
|
|
||||||
Returns:
|
Returns
|
||||||
|
-------
|
||||||
Callable[[F], F]: A decorator that can be used to wrap test functions.
|
Callable[[F], F]: A decorator that can be used to wrap test functions.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(function: F) -> F:
|
def decorator(function: F) -> F:
|
||||||
"""
|
"""Actual decorator that either runs the test or skips it based on the device's hardware model.
|
||||||
Actual decorator that either runs the test or skips it based on the device's hardware model.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
function (F): The test function to be decorated.
|
----
|
||||||
|
function: The test function to be decorated.
|
||||||
|
|
||||||
Returns:
|
Returns
|
||||||
|
-------
|
||||||
F: The decorated function.
|
F: The decorated function.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(function)
|
@wraps(function)
|
||||||
async def wrapper(*args: Any, **kwargs: Any) -> TestResult:
|
async def wrapper(*args: Any, **kwargs: Any) -> TestResult:
|
||||||
"""
|
"""Check the device's hardware model and conditionally run or skip the test.
|
||||||
Check the device's hardware model and conditionally run or skip the test.
|
|
||||||
|
|
||||||
This wrapper inspects the hardware model of the device the test is run on.
|
This wrapper inspects the hardware model of the device the test is run on.
|
||||||
If the model is in the list of specified platforms, the test is either skipped.
|
If the model is in the list of specified platforms, the test is either skipped.
|
||||||
|
|
323
anta/device.py
323
anta/device.py
|
@ -1,65 +1,75 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""ANTA Device Abstraction Module."""
|
||||||
ANTA Device Abstraction Module
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
from typing import Any, Iterator, Literal, Optional, Union
|
|
||||||
|
|
||||||
import asyncssh
|
import asyncssh
|
||||||
|
import httpcore
|
||||||
from aiocache import Cache
|
from aiocache import Cache
|
||||||
from aiocache.plugins import HitMissRatioPlugin
|
from aiocache.plugins import HitMissRatioPlugin
|
||||||
from asyncssh import SSHClientConnection, SSHClientConnectionOptions
|
from asyncssh import SSHClientConnection, SSHClientConnectionOptions
|
||||||
from httpx import ConnectError, HTTPError
|
from httpx import ConnectError, HTTPError, TimeoutException
|
||||||
|
|
||||||
from anta import __DEBUG__, aioeapi
|
from anta import __DEBUG__, aioeapi
|
||||||
|
from anta.logger import anta_log_exception, exc_to_str
|
||||||
from anta.models import AntaCommand
|
from anta.models import AntaCommand
|
||||||
from anta.tools.misc import exc_to_str
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Do not load the default keypairs multiple times due to a performance issue introduced in cryptography 37.0
|
||||||
|
# https://github.com/pyca/cryptography/issues/7236#issuecomment-1131908472
|
||||||
|
CLIENT_KEYS = asyncssh.public_key.load_default_keypairs()
|
||||||
|
|
||||||
|
|
||||||
class AntaDevice(ABC):
|
class AntaDevice(ABC):
|
||||||
"""
|
"""Abstract class representing a device in ANTA.
|
||||||
Abstract class representing a device in ANTA.
|
|
||||||
An implementation of this class must override the abstract coroutines `_collect()` and
|
An implementation of this class must override the abstract coroutines `_collect()` and
|
||||||
`refresh()`.
|
`refresh()`.
|
||||||
|
|
||||||
Attributes:
|
Attributes
|
||||||
|
----------
|
||||||
name: Device name
|
name: Device name
|
||||||
is_online: True if the device IP is reachable and a port can be open
|
is_online: True if the device IP is reachable and a port can be open.
|
||||||
established: True if remote command execution succeeds
|
established: True if remote command execution succeeds.
|
||||||
hw_model: Hardware model of the device
|
hw_model: Hardware model of the device.
|
||||||
tags: List of tags for this device
|
tags: Tags for this device.
|
||||||
cache: In-memory cache from aiocache library for this device (None if cache is disabled)
|
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
|
cache_locks: 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:
|
def __init__(self, name: str, tags: set[str] | None = None, *, disable_cache: bool = False) -> None:
|
||||||
"""
|
"""Initialize an AntaDevice.
|
||||||
Constructor of AntaDevice
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Device name
|
----
|
||||||
tags: List of tags for this device
|
name: Device name.
|
||||||
disable_cache: Disable caching for all commands for this device. Defaults to False.
|
tags: Tags for this device.
|
||||||
|
disable_cache: Disable caching for all commands for this device.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self.name: str = name
|
self.name: str = name
|
||||||
self.hw_model: Optional[str] = None
|
self.hw_model: str | None = None
|
||||||
self.tags: list[str] = tags if tags is not None else []
|
self.tags: set[str] = tags if tags is not None else set()
|
||||||
# A device always has its own name as tag
|
# 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.is_online: bool = False
|
||||||
self.established: bool = False
|
self.established: bool = False
|
||||||
self.cache: Optional[Cache] = None
|
self.cache: Cache | None = None
|
||||||
self.cache_locks: Optional[defaultdict[str, asyncio.Lock]] = None
|
self.cache_locks: defaultdict[str, asyncio.Lock] | None = None
|
||||||
|
|
||||||
# Initialize cache if not disabled
|
# Initialize cache if not disabled
|
||||||
if not disable_cache:
|
if not disable_cache:
|
||||||
|
@ -68,34 +78,24 @@ class AntaDevice(ABC):
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _keys(self) -> tuple[Any, ...]:
|
def _keys(self) -> tuple[Any, ...]:
|
||||||
"""
|
"""Read-only property to implement hashing and equality for AntaDevice classes."""
|
||||||
Read-only property to implement hashing and equality for AntaDevice classes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __eq__(self, other: object) -> bool:
|
def __eq__(self, other: object) -> bool:
|
||||||
"""
|
"""Implement equality for AntaDevice objects."""
|
||||||
Implement equality for AntaDevice objects.
|
|
||||||
"""
|
|
||||||
return self._keys == other._keys if isinstance(other, self.__class__) else False
|
return self._keys == other._keys if isinstance(other, self.__class__) else False
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
"""
|
"""Implement hashing for AntaDevice objects."""
|
||||||
Implement hashing for AntaDevice objects.
|
|
||||||
"""
|
|
||||||
return hash(self._keys)
|
return hash(self._keys)
|
||||||
|
|
||||||
def _init_cache(self) -> None:
|
def _init_cache(self) -> None:
|
||||||
"""
|
"""Initialize cache for the device, can be overridden by subclasses to manipulate how it works."""
|
||||||
Initialize cache for the device, can be overriden by subclasses to manipulate how it works
|
|
||||||
"""
|
|
||||||
self.cache = Cache(cache_class=Cache.MEMORY, ttl=60, namespace=self.name, plugins=[HitMissRatioPlugin()])
|
self.cache = Cache(cache_class=Cache.MEMORY, ttl=60, namespace=self.name, plugins=[HitMissRatioPlugin()])
|
||||||
self.cache_locks = defaultdict(asyncio.Lock)
|
self.cache_locks = defaultdict(asyncio.Lock)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cache_statistics(self) -> dict[str, Any] | None:
|
def cache_statistics(self) -> dict[str, Any] | None:
|
||||||
"""
|
"""Returns the device cache statistics for logging purposes."""
|
||||||
Returns the device cache statistics for logging purposes
|
|
||||||
"""
|
|
||||||
# Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough
|
# Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough
|
||||||
# https://github.com/pylint-dev/pylint/issues/7258
|
# https://github.com/pylint-dev/pylint/issues/7258
|
||||||
if self.cache is not None:
|
if self.cache is not None:
|
||||||
|
@ -104,9 +104,9 @@ class AntaDevice(ABC):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def __rich_repr__(self) -> Iterator[tuple[str, Any]]:
|
def __rich_repr__(self) -> Iterator[tuple[str, Any]]:
|
||||||
"""
|
"""Implement Rich Repr Protocol.
|
||||||
Implements Rich Repr Protocol
|
|
||||||
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
|
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol.
|
||||||
"""
|
"""
|
||||||
yield "name", self.name
|
yield "name", self.name
|
||||||
yield "tags", self.tags
|
yield "tags", self.tags
|
||||||
|
@ -117,8 +117,8 @@ class AntaDevice(ABC):
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def _collect(self, command: AntaCommand) -> None:
|
async def _collect(self, command: AntaCommand) -> None:
|
||||||
"""
|
"""Collect device command output.
|
||||||
Collect device command output.
|
|
||||||
This abstract coroutine can be used to implement any command collection method
|
This abstract coroutine can be used to implement any command collection method
|
||||||
for a device in ANTA.
|
for a device in ANTA.
|
||||||
|
|
||||||
|
@ -130,12 +130,13 @@ class AntaDevice(ABC):
|
||||||
`AntaCommand` object passed as argument would be `None` in this case.
|
`AntaCommand` object passed as argument would be `None` in this case.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
command: the command to collect
|
command: the command to collect
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def collect(self, command: AntaCommand) -> None:
|
async def collect(self, command: AntaCommand) -> None:
|
||||||
"""
|
"""Collect the output for a specified command.
|
||||||
Collects the output for a specified command.
|
|
||||||
|
|
||||||
When caching is activated on both the device and the command,
|
When caching is activated on both the device and the command,
|
||||||
this method prioritizes retrieving the output from the cache. In cases where the output isn't cached yet,
|
this method prioritizes retrieving the output from the cache. In cases where the output isn't cached yet,
|
||||||
|
@ -146,7 +147,9 @@ class AntaDevice(ABC):
|
||||||
via the private `_collect` method without interacting with the cache.
|
via the private `_collect` method without interacting with the cache.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
command (AntaCommand): The command to process.
|
command (AntaCommand): The command to process.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough
|
# Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough
|
||||||
# https://github.com/pylint-dev/pylint/issues/7258
|
# https://github.com/pylint-dev/pylint/issues/7258
|
||||||
|
@ -155,7 +158,7 @@ class AntaDevice(ABC):
|
||||||
cached_output = await self.cache.get(command.uid) # pylint: disable=no-member
|
cached_output = await self.cache.get(command.uid) # pylint: disable=no-member
|
||||||
|
|
||||||
if cached_output is not None:
|
if cached_output is not None:
|
||||||
logger.debug(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
|
command.output = cached_output
|
||||||
else:
|
else:
|
||||||
await self._collect(command=command)
|
await self._collect(command=command)
|
||||||
|
@ -164,26 +167,18 @@ class AntaDevice(ABC):
|
||||||
await self._collect(command=command)
|
await self._collect(command=command)
|
||||||
|
|
||||||
async def collect_commands(self, commands: list[AntaCommand]) -> None:
|
async def collect_commands(self, commands: list[AntaCommand]) -> None:
|
||||||
"""
|
"""Collect multiple commands.
|
||||||
Collect multiple commands.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
commands: the commands to collect
|
commands: the commands to collect
|
||||||
|
|
||||||
"""
|
"""
|
||||||
await asyncio.gather(*(self.collect(command=command) for command in commands))
|
await asyncio.gather(*(self.collect(command=command) for command in commands))
|
||||||
|
|
||||||
def supports(self, command: AntaCommand) -> bool:
|
|
||||||
"""Returns True if the command is supported on the device hardware platform, False otherwise."""
|
|
||||||
unsupported = any("not supported on this hardware platform" in e for e in command.errors)
|
|
||||||
logger.debug(command)
|
|
||||||
if unsupported:
|
|
||||||
logger.debug(f"{command.command} is not supported on {self.hw_model}")
|
|
||||||
return not unsupported
|
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def refresh(self) -> None:
|
async def refresh(self) -> None:
|
||||||
"""
|
"""Update attributes of an AntaDevice instance.
|
||||||
Update attributes of an AntaDevice instance.
|
|
||||||
|
|
||||||
This coroutine must update the following attributes of AntaDevice:
|
This coroutine must update the following attributes of AntaDevice:
|
||||||
- `is_online`: When the device IP is reachable and a port can be open
|
- `is_online`: When the device IP is reachable and a port can be open
|
||||||
|
@ -192,63 +187,71 @@ class AntaDevice(ABC):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None:
|
async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None:
|
||||||
"""
|
"""Copy files to and from the device, usually through SCP.
|
||||||
Copy files to and from the device, usually through SCP.
|
|
||||||
It is not mandatory to implement this for a valid AntaDevice subclass.
|
It is not mandatory to implement this for a valid AntaDevice subclass.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
sources: List of files to copy to or from the device.
|
sources: List of files to copy to or from the device.
|
||||||
destination: Local or remote destination when copying the files. Can be a folder.
|
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.
|
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):
|
class AsyncEOSDevice(AntaDevice):
|
||||||
"""
|
"""Implementation of AntaDevice for EOS using aio-eapi.
|
||||||
Implementation of AntaDevice for EOS using aio-eapi.
|
|
||||||
|
|
||||||
Attributes:
|
Attributes
|
||||||
|
----------
|
||||||
name: Device name
|
name: Device name
|
||||||
is_online: True if the device IP is reachable and a port can be open
|
is_online: True if the device IP is reachable and a port can be open
|
||||||
established: True if remote command execution succeeds
|
established: True if remote command execution succeeds
|
||||||
hw_model: Hardware model of the device
|
hw_model: Hardware model of the device
|
||||||
tags: List of tags for this device
|
tags: Tags for this device
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__( # pylint: disable=R0913
|
# pylint: disable=R0913
|
||||||
|
def __init__(
|
||||||
self,
|
self,
|
||||||
host: str,
|
host: str,
|
||||||
username: str,
|
username: str,
|
||||||
password: str,
|
password: str,
|
||||||
name: Optional[str] = None,
|
name: str | None = None,
|
||||||
enable: bool = False,
|
enable_password: str | None = None,
|
||||||
enable_password: Optional[str] = None,
|
port: int | None = None,
|
||||||
port: Optional[int] = None,
|
ssh_port: int | None = 22,
|
||||||
ssh_port: Optional[int] = 22,
|
tags: set[str] | None = None,
|
||||||
tags: Optional[list[str]] = None,
|
timeout: float | None = None,
|
||||||
timeout: Optional[float] = None,
|
|
||||||
insecure: bool = False,
|
|
||||||
proto: Literal["http", "https"] = "https",
|
proto: Literal["http", "https"] = "https",
|
||||||
|
*,
|
||||||
|
enable: bool = False,
|
||||||
|
insecure: bool = False,
|
||||||
disable_cache: bool = False,
|
disable_cache: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Instantiate an AsyncEOSDevice.
|
||||||
Constructor of AsyncEOSDevice
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
host: Device FQDN or IP
|
----
|
||||||
username: Username to connect to eAPI and SSH
|
host: Device FQDN or IP.
|
||||||
password: Password to connect to eAPI and SSH
|
username: Username to connect to eAPI and SSH.
|
||||||
name: Device name
|
password: Password to connect to eAPI and SSH.
|
||||||
enable: Device needs privileged access
|
name: Device name.
|
||||||
enable_password: Password used to gain privileged access on EOS
|
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'.
|
port: eAPI port. Defaults to 80 is proto is 'http' or 443 if proto is 'https'.
|
||||||
ssh_port: SSH port
|
ssh_port: SSH port.
|
||||||
tags: List of tags for this device
|
tags: Tags for this device.
|
||||||
timeout: Timeout value in seconds for outgoing connections. Default to 10 secs.
|
timeout: Timeout value in seconds for outgoing API calls.
|
||||||
insecure: Disable SSH Host Key validation
|
insecure: Disable SSH Host Key validation.
|
||||||
proto: eAPI protocol. Value can be 'http' or 'https'
|
proto: eAPI protocol. Value can be 'http' or 'https'.
|
||||||
disable_cache: Disable caching for all commands for this device. Defaults to False.
|
disable_cache: Disable caching for all commands for this device.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if host is None:
|
if host is None:
|
||||||
message = "'host' is required to create an AsyncEOSDevice"
|
message = "'host' is required to create an AsyncEOSDevice"
|
||||||
|
@ -256,7 +259,7 @@ class AsyncEOSDevice(AntaDevice):
|
||||||
raise ValueError(message)
|
raise ValueError(message)
|
||||||
if name is None:
|
if name is None:
|
||||||
name = f"{host}{f':{port}' if port else ''}"
|
name = f"{host}{f':{port}' if port else ''}"
|
||||||
super().__init__(name, tags, disable_cache)
|
super().__init__(name, tags, disable_cache=disable_cache)
|
||||||
if username is None:
|
if username is None:
|
||||||
message = f"'username' is required to instantiate device '{self.name}'"
|
message = f"'username' is required to instantiate device '{self.name}'"
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
|
@ -271,12 +274,14 @@ class AsyncEOSDevice(AntaDevice):
|
||||||
ssh_params: dict[str, Any] = {}
|
ssh_params: dict[str, Any] = {}
|
||||||
if insecure:
|
if insecure:
|
||||||
ssh_params["known_hosts"] = None
|
ssh_params["known_hosts"] = None
|
||||||
self._ssh_opts: SSHClientConnectionOptions = SSHClientConnectionOptions(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]]:
|
def __rich_repr__(self) -> Iterator[tuple[str, Any]]:
|
||||||
"""
|
"""Implement Rich Repr Protocol.
|
||||||
Implements Rich Repr Protocol
|
|
||||||
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
|
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol.
|
||||||
"""
|
"""
|
||||||
yield from super().__rich_repr__()
|
yield from super().__rich_repr__()
|
||||||
yield ("host", self._session.host)
|
yield ("host", self._session.host)
|
||||||
|
@ -286,107 +291,123 @@ class AsyncEOSDevice(AntaDevice):
|
||||||
yield ("insecure", self._ssh_opts.known_hosts is None)
|
yield ("insecure", self._ssh_opts.known_hosts is None)
|
||||||
if __DEBUG__:
|
if __DEBUG__:
|
||||||
_ssh_opts = vars(self._ssh_opts).copy()
|
_ssh_opts = vars(self._ssh_opts).copy()
|
||||||
PASSWORD_VALUE = "<removed>"
|
removed_pw = "<removed>"
|
||||||
_ssh_opts["password"] = PASSWORD_VALUE
|
_ssh_opts["password"] = removed_pw
|
||||||
_ssh_opts["kwargs"]["password"] = PASSWORD_VALUE
|
_ssh_opts["kwargs"]["password"] = removed_pw
|
||||||
yield ("_session", vars(self._session))
|
yield ("_session", vars(self._session))
|
||||||
yield ("_ssh_opts", _ssh_opts)
|
yield ("_ssh_opts", _ssh_opts)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _keys(self) -> tuple[Any, ...]:
|
def _keys(self) -> tuple[Any, ...]:
|
||||||
"""
|
"""Two AsyncEOSDevice objects are equal if the hostname and the port are the same.
|
||||||
Two AsyncEOSDevice objects are equal if the hostname and the port are the same.
|
|
||||||
This covers the use case of port forwarding when the host is localhost and the devices have different ports.
|
This covers the use case of port forwarding when the host is localhost and the devices have different ports.
|
||||||
"""
|
"""
|
||||||
return (self._session.host, self._session.port)
|
return (self._session.host, self._session.port)
|
||||||
|
|
||||||
async def _collect(self, command: AntaCommand) -> None:
|
async def _collect(self, command: AntaCommand) -> None: # noqa: C901 function is too complex - because of many required except blocks
|
||||||
"""
|
"""Collect device command output from EOS using aio-eapi.
|
||||||
Collect device command output from EOS using aio-eapi.
|
|
||||||
|
|
||||||
Supports outformat `json` and `text` as output structure.
|
Supports outformat `json` and `text` as output structure.
|
||||||
Gain privileged access using the `enable_password` attribute
|
Gain privileged access using the `enable_password` attribute
|
||||||
of the `AntaDevice` instance if populated.
|
of the `AntaDevice` instance if populated.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
command: the command to collect
|
----
|
||||||
|
command: the AntaCommand to collect.
|
||||||
"""
|
"""
|
||||||
commands = []
|
commands: list[dict[str, Any]] = []
|
||||||
if self.enable and self._enable_password is not None:
|
if self.enable and self._enable_password is not None:
|
||||||
commands.append(
|
commands.append(
|
||||||
{
|
{
|
||||||
"cmd": "enable",
|
"cmd": "enable",
|
||||||
"input": str(self._enable_password),
|
"input": str(self._enable_password),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
elif self.enable:
|
elif self.enable:
|
||||||
# No password
|
# No password
|
||||||
commands.append({"cmd": "enable"})
|
commands.append({"cmd": "enable"})
|
||||||
if command.revision:
|
commands += [{"cmd": command.command, "revision": command.revision}] if command.revision else [{"cmd": command.command}]
|
||||||
commands.append({"cmd": command.command, "revision": command.revision})
|
|
||||||
else:
|
|
||||||
commands.append({"cmd": command.command})
|
|
||||||
try:
|
try:
|
||||||
response: list[dict[str, Any]] = await self._session.cli(
|
response: list[dict[str, Any]] = await self._session.cli(
|
||||||
commands=commands,
|
commands=commands,
|
||||||
ofmt=command.ofmt,
|
ofmt=command.ofmt,
|
||||||
version=command.version,
|
version=command.version,
|
||||||
)
|
)
|
||||||
except aioeapi.EapiCommandError as e:
|
# Do not keep response of 'enable' command
|
||||||
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
|
|
||||||
command.output = response[-1]
|
command.output = response[-1]
|
||||||
logger.debug(f"{self.name}: {command}")
|
except aioeapi.EapiCommandError as e:
|
||||||
|
# This block catches exceptions related to EOS issuing an 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 command.supported:
|
||||||
|
logger.error("Command '%s' failed on %s: %s", command.command, self.name, e.errors[0] if len(e.errors) == 1 else e.errors)
|
||||||
|
else:
|
||||||
|
logger.debug("Command '%s' is not supported on '%s' (%s)", command.command, self.name, self.hw_model)
|
||||||
|
except TimeoutException as e:
|
||||||
|
# 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)
|
||||||
|
|
||||||
async def refresh(self) -> None:
|
async def refresh(self) -> None:
|
||||||
"""
|
"""Update attributes of an AsyncEOSDevice instance.
|
||||||
Update attributes of an AsyncEOSDevice instance.
|
|
||||||
|
|
||||||
This coroutine must update the following attributes of AsyncEOSDevice:
|
This coroutine must update the following attributes of AsyncEOSDevice:
|
||||||
- is_online: When a device IP is reachable and a port can be open
|
- is_online: When a device IP is reachable and a port can be open
|
||||||
- established: When a command execution succeeds
|
- established: When a command execution succeeds
|
||||||
- hw_model: The hardware model of the device
|
- hw_model: The hardware model of the device
|
||||||
"""
|
"""
|
||||||
logger.debug(f"Refreshing device {self.name}")
|
logger.debug("Refreshing device %s", self.name)
|
||||||
self.is_online = await self._session.check_connection()
|
self.is_online = await self._session.check_connection()
|
||||||
if self.is_online:
|
if self.is_online:
|
||||||
COMMAND: str = "show version"
|
show_version = AntaCommand(command="show version")
|
||||||
HW_MODEL_KEY: str = "modelName"
|
await self._collect(show_version)
|
||||||
try:
|
if not show_version.collected:
|
||||||
response = await self._session.cli(command=COMMAND)
|
logger.warning("Cannot get hardware information from device %s", self.name)
|
||||||
except aioeapi.EapiCommandError as e:
|
|
||||||
logger.warning(f"Cannot get hardware information from device {self.name}: {e.errmsg}")
|
|
||||||
|
|
||||||
except (HTTPError, ConnectError) as e:
|
|
||||||
logger.warning(f"Cannot get hardware information from device {self.name}: {exc_to_str(e)}")
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if HW_MODEL_KEY in response:
|
self.hw_model = show_version.json_output.get("modelName", None)
|
||||||
self.hw_model = response[HW_MODEL_KEY]
|
if self.hw_model is None:
|
||||||
else:
|
logger.critical("Cannot parse 'show version' returned by device %s", self.name)
|
||||||
logger.warning(f"Cannot get hardware information from device {self.name}: cannot parse '{COMMAND}'")
|
|
||||||
|
|
||||||
else:
|
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)
|
self.established = bool(self.is_online and self.hw_model)
|
||||||
|
|
||||||
async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None:
|
async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None:
|
||||||
"""
|
"""Copy files to and from the device using asyncssh.scp().
|
||||||
Copy files to and from the device using asyncssh.scp().
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
sources: List of files to copy to or from the device.
|
sources: List of files to copy to or from the device.
|
||||||
destination: Local or remote destination when copying the files. Can be a folder.
|
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.
|
direction: Defines if this coroutine copies files to or from the device.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
async with asyncssh.connect(
|
async with asyncssh.connect(
|
||||||
host=self._ssh_opts.host,
|
host=self._ssh_opts.host,
|
||||||
|
@ -396,22 +417,24 @@ class AsyncEOSDevice(AntaDevice):
|
||||||
local_addr=self._ssh_opts.local_addr,
|
local_addr=self._ssh_opts.local_addr,
|
||||||
options=self._ssh_opts,
|
options=self._ssh_opts,
|
||||||
) as conn:
|
) as conn:
|
||||||
src: Union[list[tuple[SSHClientConnection, Path]], list[Path]]
|
src: list[tuple[SSHClientConnection, Path]] | list[Path]
|
||||||
dst: Union[tuple[SSHClientConnection, Path], Path]
|
dst: tuple[SSHClientConnection, Path] | Path
|
||||||
if direction == "from":
|
if direction == "from":
|
||||||
src = [(conn, file) for file in sources]
|
src = [(conn, file) for file in sources]
|
||||||
dst = destination
|
dst = destination
|
||||||
for file in sources:
|
for file in sources:
|
||||||
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":
|
elif direction == "to":
|
||||||
src = sources
|
src = sources
|
||||||
dst = conn, destination
|
dst = conn, destination
|
||||||
for file in src:
|
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:
|
else:
|
||||||
logger.critical(f"'direction' argument to copy() fonction is invalid: {direction}")
|
logger.critical("'direction' argument to copy() function is invalid: %s", direction)
|
||||||
|
|
||||||
return
|
return
|
||||||
await asyncssh.scp(src, dst)
|
await asyncssh.scp(src, dst)
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Inventory module for ANTA."""
|
||||||
Inventory Module for ANTA.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
@ -11,32 +9,29 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
from ipaddress import ip_address, ip_network
|
from ipaddress import ip_address, ip_network
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from yaml import YAMLError, safe_load
|
from yaml import YAMLError, safe_load
|
||||||
|
|
||||||
from anta.device import AntaDevice, AsyncEOSDevice
|
from anta.device import AntaDevice, AsyncEOSDevice
|
||||||
from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError
|
from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError
|
||||||
from anta.inventory.models import AntaInventoryInput
|
from anta.inventory.models import AntaInventoryInput
|
||||||
from anta.logger import anta_log_exception
|
from anta.logger import anta_log_exception
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AntaInventory(dict): # type: ignore
|
class AntaInventory(dict[str, AntaDevice]):
|
||||||
# dict[str, AntaDevice] - not working in python 3.8 hence the ignore
|
"""Inventory abstraction for ANTA framework."""
|
||||||
"""
|
|
||||||
Inventory abstraction for ANTA framework.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Root key of inventory part of the inventory file
|
# Root key of inventory part of the inventory file
|
||||||
INVENTORY_ROOT_KEY = "anta_inventory"
|
INVENTORY_ROOT_KEY = "anta_inventory"
|
||||||
# Supported Output format
|
# Supported Output format
|
||||||
INVENTORY_OUTPUT_FORMAT = ["native", "json"]
|
INVENTORY_OUTPUT_FORMAT: ClassVar[list[str]] = ["native", "json"]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""Human readable string representing the inventory"""
|
"""Human readable string representing the inventory."""
|
||||||
devs = {}
|
devs = {}
|
||||||
for dev in self.values():
|
for dev in self.values():
|
||||||
if (dev_type := dev.__class__.__name__) not in devs:
|
if (dev_type := dev.__class__.__name__) not in devs:
|
||||||
|
@ -46,80 +41,106 @@ class AntaInventory(dict): # type: ignore
|
||||||
return f"ANTA Inventory contains {' '.join([f'{n} devices ({t})' for t, n in devs.items()])}"
|
return f"ANTA Inventory contains {' '.join([f'{n} devices ({t})' for t, n in devs.items()])}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _update_disable_cache(inventory_disable_cache: bool, kwargs: dict[str, Any]) -> dict[str, Any]:
|
def _update_disable_cache(kwargs: dict[str, Any], *, inventory_disable_cache: bool) -> dict[str, Any]:
|
||||||
"""
|
"""Return new dictionary, replacing kwargs with added disable_cache value from inventory_value if disable_cache has not been set by CLI.
|
||||||
Return new dictionary, replacing kwargs with added disable_cache value from inventory_value
|
|
||||||
if disable_cache has not been set by CLI.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
inventory_disable_cache (bool): The value of disable_cache in the inventory
|
----
|
||||||
|
inventory_disable_cache: The value of disable_cache in the inventory
|
||||||
kwargs: The kwargs to instantiate the device
|
kwargs: The kwargs to instantiate the device
|
||||||
|
|
||||||
"""
|
"""
|
||||||
updated_kwargs = kwargs.copy()
|
updated_kwargs = kwargs.copy()
|
||||||
updated_kwargs["disable_cache"] = inventory_disable_cache or kwargs.get("disable_cache")
|
updated_kwargs["disable_cache"] = inventory_disable_cache or kwargs.get("disable_cache")
|
||||||
return updated_kwargs
|
return updated_kwargs
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_hosts(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None:
|
def _parse_hosts(
|
||||||
"""
|
inventory_input: AntaInventoryInput,
|
||||||
Parses the host section of an AntaInventoryInput and add the devices to the inventory
|
inventory: AntaInventory,
|
||||||
|
**kwargs: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Parse the host section of an AntaInventoryInput and add the devices to the inventory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices
|
----
|
||||||
inventory (AntaInventory): AntaInventory to add the parsed devices to
|
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
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if inventory_input.hosts is None:
|
if inventory_input.hosts is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
for host in inventory_input.hosts:
|
for host in inventory_input.hosts:
|
||||||
updated_kwargs = AntaInventory._update_disable_cache(host.disable_cache, 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)
|
device = AsyncEOSDevice(
|
||||||
|
name=host.name,
|
||||||
|
host=str(host.host),
|
||||||
|
port=host.port,
|
||||||
|
tags=host.tags,
|
||||||
|
**updated_kwargs,
|
||||||
|
)
|
||||||
inventory.add_device(device)
|
inventory.add_device(device)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_networks(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None:
|
def _parse_networks(
|
||||||
"""
|
inventory_input: AntaInventoryInput,
|
||||||
Parses the network section of an AntaInventoryInput and add the devices to the inventory.
|
inventory: AntaInventory,
|
||||||
|
**kwargs: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Parse the network section of an AntaInventoryInput and add the devices to the inventory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices
|
----
|
||||||
inventory (AntaInventory): AntaInventory to add the parsed devices to
|
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:
|
if inventory_input.networks is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
for network in inventory_input.networks:
|
try:
|
||||||
try:
|
for network in inventory_input.networks:
|
||||||
updated_kwargs = AntaInventory._update_disable_cache(network.disable_cache, kwargs)
|
updated_kwargs = AntaInventory._update_disable_cache(kwargs, inventory_disable_cache=network.disable_cache)
|
||||||
for host_ip in ip_network(str(network.network)):
|
for host_ip in ip_network(str(network.network)):
|
||||||
device = AsyncEOSDevice(host=str(host_ip), tags=network.tags, **updated_kwargs)
|
device = AsyncEOSDevice(host=str(host_ip), tags=network.tags, **updated_kwargs)
|
||||||
inventory.add_device(device)
|
inventory.add_device(device)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
message = "Could not parse network {network.network} in the inventory"
|
message = "Could not parse the network section in the inventory"
|
||||||
anta_log_exception(e, message, logger)
|
anta_log_exception(e, message, logger)
|
||||||
raise InventoryIncorrectSchema(message) from e
|
raise InventoryIncorrectSchemaError(message) from e
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_ranges(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None:
|
def _parse_ranges(
|
||||||
"""
|
inventory_input: AntaInventoryInput,
|
||||||
Parses the range section of an AntaInventoryInput and add the devices to the inventory.
|
inventory: AntaInventory,
|
||||||
|
**kwargs: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Parse the range section of an AntaInventoryInput and add the devices to the inventory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices
|
----
|
||||||
inventory (AntaInventory): AntaInventory to add the parsed devices to
|
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:
|
if inventory_input.ranges is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
for range_def in inventory_input.ranges:
|
try:
|
||||||
try:
|
for range_def in inventory_input.ranges:
|
||||||
updated_kwargs = AntaInventory._update_disable_cache(range_def.disable_cache, kwargs)
|
updated_kwargs = AntaInventory._update_disable_cache(kwargs, inventory_disable_cache=range_def.disable_cache)
|
||||||
range_increment = ip_address(str(range_def.start))
|
range_increment = ip_address(str(range_def.start))
|
||||||
range_stop = ip_address(str(range_def.end))
|
range_stop = ip_address(str(range_def.end))
|
||||||
while range_increment <= range_stop: # type: ignore[operator]
|
while range_increment <= range_stop: # type: ignore[operator]
|
||||||
|
@ -128,46 +149,49 @@ class AntaInventory(dict): # type: ignore
|
||||||
device = AsyncEOSDevice(host=str(range_increment), tags=range_def.tags, **updated_kwargs)
|
device = AsyncEOSDevice(host=str(range_increment), tags=range_def.tags, **updated_kwargs)
|
||||||
inventory.add_device(device)
|
inventory.add_device(device)
|
||||||
range_increment += 1
|
range_increment += 1
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
message = f"Could not parse the following range in the inventory: {range_def.start} - {range_def.end}"
|
message = "Could not parse the range section in the inventory"
|
||||||
anta_log_exception(e, message, logger)
|
anta_log_exception(e, message, logger)
|
||||||
raise InventoryIncorrectSchema(message) from e
|
raise InventoryIncorrectSchemaError(message) from e
|
||||||
except TypeError as 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}"
|
message = "A range in the inventory has different address families (IPv4 vs IPv6)"
|
||||||
anta_log_exception(e, message, logger)
|
anta_log_exception(e, message, logger)
|
||||||
raise InventoryIncorrectSchema(message) from e
|
raise InventoryIncorrectSchemaError(message) from e
|
||||||
|
|
||||||
|
# pylint: disable=too-many-arguments
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse(
|
def parse(
|
||||||
filename: str | Path,
|
filename: str | Path,
|
||||||
username: str,
|
username: str,
|
||||||
password: str,
|
password: str,
|
||||||
|
enable_password: str | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
*,
|
||||||
enable: bool = False,
|
enable: bool = False,
|
||||||
enable_password: Optional[str] = None,
|
|
||||||
timeout: Optional[float] = None,
|
|
||||||
insecure: bool = False,
|
insecure: bool = False,
|
||||||
disable_cache: bool = False,
|
disable_cache: bool = False,
|
||||||
) -> AntaInventory:
|
) -> AntaInventory:
|
||||||
# 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.
|
The inventory devices are AsyncEOSDevice instances.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filename (str): Path to device inventory YAML file
|
----
|
||||||
username (str): Username to use to connect to devices
|
filename: Path to device inventory YAML file.
|
||||||
password (str): Password to use to connect to devices
|
username: Username to use to connect to devices.
|
||||||
enable (bool): Whether or not the commands need to be run in enable mode towards the devices
|
password: Password to use to connect to devices.
|
||||||
enable_password (str, optional): Enable password to use if required
|
enable_password: Enable password to use if required.
|
||||||
timeout (float, optional): timeout in seconds for every API call.
|
timeout: Timeout value in seconds for outgoing API calls.
|
||||||
insecure (bool): Disable SSH Host Key validation
|
enable: Whether or not the commands need to be run in enable mode towards the devices.
|
||||||
disable_cache (bool): Disable cache globally
|
insecure: Disable SSH Host Key validation.
|
||||||
|
disable_cache: Disable cache globally.
|
||||||
|
|
||||||
Raises:
|
Raises
|
||||||
|
------
|
||||||
InventoryRootKeyError: Root key of inventory is missing.
|
InventoryRootKeyError: Root key of inventory is missing.
|
||||||
InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema.
|
InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema.
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
inventory = AntaInventory()
|
inventory = AntaInventory()
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"username": username,
|
"username": username,
|
||||||
|
@ -188,7 +212,8 @@ class AntaInventory(dict): # type: ignore
|
||||||
raise ValueError(message)
|
raise ValueError(message)
|
||||||
|
|
||||||
try:
|
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)
|
data = safe_load(file)
|
||||||
except (TypeError, YAMLError, OSError) as e:
|
except (TypeError, YAMLError, OSError) as e:
|
||||||
message = f"Unable to parse ANTA Device Inventory file '{filename}'"
|
message = f"Unable to parse ANTA Device Inventory file '{filename}'"
|
||||||
|
@ -213,6 +238,11 @@ class AntaInventory(dict): # type: ignore
|
||||||
|
|
||||||
return inventory
|
return inventory
|
||||||
|
|
||||||
|
@property
|
||||||
|
def devices(self) -> list[AntaDevice]:
|
||||||
|
"""List of AntaDevice in this inventory."""
|
||||||
|
return list(self.values())
|
||||||
|
|
||||||
###########################################################################
|
###########################################################################
|
||||||
# Public methods
|
# Public methods
|
||||||
###########################################################################
|
###########################################################################
|
||||||
|
@ -221,30 +251,31 @@ class AntaInventory(dict): # type: ignore
|
||||||
# GET methods
|
# GET methods
|
||||||
###########################################################################
|
###########################################################################
|
||||||
|
|
||||||
def get_inventory(self, established_only: bool = False, tags: Optional[list[str]] = None) -> AntaInventory:
|
def get_inventory(self, *, established_only: bool = False, tags: set[str] | None = None, devices: set[str] | None = None) -> AntaInventory:
|
||||||
"""
|
"""Return a filtered inventory.
|
||||||
Returns a filtered inventory.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
established_only: Whether or not to include only established devices. Default False.
|
----
|
||||||
tags: List of tags to filter devices.
|
established_only: Whether or not to include only established devices.
|
||||||
|
tags: Tags to filter devices.
|
||||||
|
devices: Names to filter devices.
|
||||||
|
|
||||||
Returns:
|
Returns
|
||||||
AntaInventory: An inventory with filtered AntaDevice objects.
|
-------
|
||||||
|
An inventory with filtered AntaDevice objects.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _filter_devices(device: AntaDevice) -> bool:
|
def _filter_devices(device: AntaDevice) -> bool:
|
||||||
"""
|
"""Select the devices based on the inputs `tags`, `devices` and `established_only`."""
|
||||||
Helper function to select the devices based on the input tags
|
|
||||||
and the requirement for an established connection.
|
|
||||||
"""
|
|
||||||
if tags is not None and all(tag not in tags for tag in device.tags):
|
if tags is not None and all(tag not in tags for tag in device.tags):
|
||||||
return False
|
return False
|
||||||
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()
|
result = AntaInventory()
|
||||||
for device in devices:
|
for device in filtered_devices:
|
||||||
result.add_device(device)
|
result.add_device(device)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@ -253,15 +284,19 @@ class AntaInventory(dict): # type: ignore
|
||||||
###########################################################################
|
###########################################################################
|
||||||
|
|
||||||
def __setitem__(self, key: str, value: AntaDevice) -> None:
|
def __setitem__(self, key: str, value: AntaDevice) -> None:
|
||||||
|
"""Set a device in the inventory."""
|
||||||
if key != value.name:
|
if key != value.name:
|
||||||
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)
|
return super().__setitem__(key, value)
|
||||||
|
|
||||||
def add_device(self, device: AntaDevice) -> None:
|
def add_device(self, device: AntaDevice) -> None:
|
||||||
"""Add a device to final inventory.
|
"""Add a device to final inventory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
device: Device object to be added
|
device: Device object to be added
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self[device.name] = device
|
self[device.name] = device
|
||||||
|
|
||||||
|
|
|
@ -8,5 +8,5 @@ class InventoryRootKeyError(Exception):
|
||||||
"""Error raised when inventory root key is not found."""
|
"""Error raised when inventory root key is not found."""
|
||||||
|
|
||||||
|
|
||||||
class InventoryIncorrectSchema(Exception):
|
class InventoryIncorrectSchemaError(Exception):
|
||||||
"""Error when user data does not follow ANTA schema."""
|
"""Error when user data does not follow ANTA schema."""
|
||||||
|
|
|
@ -6,87 +6,79 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Optional, Union
|
|
||||||
|
|
||||||
# Need to keep List for pydantic in python 3.8
|
from pydantic import BaseModel, ConfigDict, IPvAnyAddress, IPvAnyNetwork
|
||||||
from pydantic import BaseModel, ConfigDict, IPvAnyAddress, IPvAnyNetwork, conint, constr
|
|
||||||
|
from anta.custom_types import Hostname, Port
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Pydantic models for input validation
|
|
||||||
|
|
||||||
RFC_1123_REGEX = r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$"
|
|
||||||
|
|
||||||
|
|
||||||
class AntaInventoryHost(BaseModel):
|
class AntaInventoryHost(BaseModel):
|
||||||
"""
|
"""Host entry of AntaInventoryInput.
|
||||||
Host definition for user's inventory.
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
host: IP Address or FQDN of the device.
|
||||||
|
port: Custom eAPI port to use.
|
||||||
|
name: Custom name of the device.
|
||||||
|
tags: Tags of the device.
|
||||||
|
disable_cache: Disable cache for this device.
|
||||||
|
|
||||||
Attributes:
|
|
||||||
host (IPvAnyAddress): IPv4 or IPv6 address of the device
|
|
||||||
port (int): (Optional) eAPI port to use Default is 443.
|
|
||||||
name (str): (Optional) Name to display during tests report. Default is hostname:port
|
|
||||||
tags (list[str]): List of attached tags read from inventory file.
|
|
||||||
disable_cache (bool): Disable cache per host. Defaults to False.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
name: Optional[str] = None
|
name: str | None = None
|
||||||
host: Union[constr(pattern=RFC_1123_REGEX), IPvAnyAddress] # type: ignore
|
host: Hostname | IPvAnyAddress
|
||||||
port: Optional[conint(gt=1, lt=65535)] = None # type: ignore
|
port: Port | None = None
|
||||||
tags: Optional[List[str]] = None
|
tags: set[str] | None = None
|
||||||
disable_cache: bool = False
|
disable_cache: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AntaInventoryNetwork(BaseModel):
|
class AntaInventoryNetwork(BaseModel):
|
||||||
"""
|
"""Network entry of AntaInventoryInput.
|
||||||
Network definition for user's inventory.
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
network: Subnet to use for scanning.
|
||||||
|
tags: Tags of the devices in this network.
|
||||||
|
disable_cache: Disable cache for all devices in this network.
|
||||||
|
|
||||||
Attributes:
|
|
||||||
network (IPvAnyNetwork): Subnet to use for testing.
|
|
||||||
tags (list[str]): List of attached tags read from inventory file.
|
|
||||||
disable_cache (bool): Disable cache per network. Defaults to False.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
network: IPvAnyNetwork
|
network: IPvAnyNetwork
|
||||||
tags: Optional[List[str]] = None
|
tags: set[str] | None = None
|
||||||
disable_cache: bool = False
|
disable_cache: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AntaInventoryRange(BaseModel):
|
class AntaInventoryRange(BaseModel):
|
||||||
"""
|
"""IP Range entry of AntaInventoryInput.
|
||||||
IP Range definition for user's inventory.
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
start: IPv4 or IPv6 address for the beginning of the range.
|
||||||
|
stop: IPv4 or IPv6 address for the end of the range.
|
||||||
|
tags: Tags of the devices in this IP range.
|
||||||
|
disable_cache: Disable cache for all devices in this IP range.
|
||||||
|
|
||||||
Attributes:
|
|
||||||
start (IPvAnyAddress): IPv4 or IPv6 address for the begining of the range.
|
|
||||||
stop (IPvAnyAddress): IPv4 or IPv6 address for the end of the range.
|
|
||||||
tags (list[str]): List of attached tags read from inventory file.
|
|
||||||
disable_cache (bool): Disable cache per range of hosts. Defaults to False.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
start: IPvAnyAddress
|
start: IPvAnyAddress
|
||||||
end: IPvAnyAddress
|
end: IPvAnyAddress
|
||||||
tags: Optional[List[str]] = None
|
tags: set[str] | None = None
|
||||||
disable_cache: bool = False
|
disable_cache: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AntaInventoryInput(BaseModel):
|
class AntaInventoryInput(BaseModel):
|
||||||
"""
|
"""Device inventory input model."""
|
||||||
User's inventory model.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
networks (list[AntaInventoryNetwork],Optional): List of AntaInventoryNetwork objects for networks.
|
|
||||||
hosts (list[AntaInventoryHost],Optional): List of AntaInventoryHost objects for hosts.
|
|
||||||
range (list[AntaInventoryRange],Optional): List of AntaInventoryRange objects for ranges.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
networks: Optional[List[AntaInventoryNetwork]] = None
|
networks: list[AntaInventoryNetwork] | None = None
|
||||||
hosts: Optional[List[AntaInventoryHost]] = None
|
hosts: list[AntaInventoryHost] | None = None
|
||||||
ranges: Optional[List[AntaInventoryRange]] = None
|
ranges: list[AntaInventoryRange] | None = None
|
||||||
|
|
|
@ -1,26 +1,27 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Configure logging for ANTA."""
|
||||||
Configure logging for ANTA
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import traceback
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from typing import TYPE_CHECKING, Literal
|
||||||
from typing import Literal, Optional
|
|
||||||
|
|
||||||
from rich.logging import RichHandler
|
from rich.logging import RichHandler
|
||||||
|
|
||||||
from anta import __DEBUG__
|
from anta import __DEBUG__
|
||||||
from anta.tools.misc import exc_to_str
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Log(str, Enum):
|
class Log(str, Enum):
|
||||||
"""Represent log levels from logging module as immutable strings"""
|
"""Represent log levels from logging module as immutable strings."""
|
||||||
|
|
||||||
CRITICAL = logging.getLevelName(logging.CRITICAL)
|
CRITICAL = logging.getLevelName(logging.CRITICAL)
|
||||||
ERROR = logging.getLevelName(logging.ERROR)
|
ERROR = logging.getLevelName(logging.ERROR)
|
||||||
|
@ -33,8 +34,8 @@ LogLevel = Literal[Log.CRITICAL, Log.ERROR, Log.WARNING, Log.INFO, Log.DEBUG]
|
||||||
|
|
||||||
|
|
||||||
def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None:
|
def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None:
|
||||||
"""
|
"""Configure logging for ANTA.
|
||||||
Configure logging for ANTA.
|
|
||||||
By default, the logging level is INFO for all loggers except for httpx and asyncssh which are too verbose:
|
By default, the logging level is INFO for all loggers except for httpx and asyncssh which are too verbose:
|
||||||
their logging level is WARNING.
|
their logging level is WARNING.
|
||||||
|
|
||||||
|
@ -48,12 +49,14 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None:
|
||||||
be logged to stdout while all levels will be logged in the file.
|
be logged to stdout while all levels will be logged in the file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
level: ANTA logging level
|
level: ANTA logging level
|
||||||
file: Send logs to a file
|
file: Send logs to a file
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Init root logger
|
# Init root logger
|
||||||
root = logging.getLogger()
|
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())
|
loglevel = logging.DEBUG if __DEBUG__ else getattr(logging, level.upper())
|
||||||
root.setLevel(loglevel)
|
root.setLevel(loglevel)
|
||||||
# Silence the logging of chatty Python modules when level is INFO
|
# Silence the logging of chatty Python modules when level is INFO
|
||||||
|
@ -64,44 +67,51 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None:
|
||||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
|
||||||
# Add RichHandler for stdout
|
# Add RichHandler for stdout
|
||||||
richHandler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False)
|
rich_handler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False)
|
||||||
# In ANTA debug mode, show Python module in stdout
|
# Show Python module in stdout at DEBUG level
|
||||||
if __DEBUG__:
|
fmt_string = "[grey58]\\[%(name)s][/grey58] %(message)s" if loglevel == logging.DEBUG else "%(message)s"
|
||||||
fmt_string = r"[grey58]\[%(name)s][/grey58] %(message)s"
|
|
||||||
else:
|
|
||||||
fmt_string = "%(message)s"
|
|
||||||
formatter = logging.Formatter(fmt=fmt_string, datefmt="[%X]")
|
formatter = logging.Formatter(fmt=fmt_string, datefmt="[%X]")
|
||||||
richHandler.setFormatter(formatter)
|
rich_handler.setFormatter(formatter)
|
||||||
root.addHandler(richHandler)
|
root.addHandler(rich_handler)
|
||||||
# Add FileHandler if file is provided
|
# Add FileHandler if file is provided
|
||||||
if file:
|
if file:
|
||||||
fileHandler = logging.FileHandler(file)
|
file_handler = logging.FileHandler(file)
|
||||||
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||||
fileHandler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
root.addHandler(fileHandler)
|
root.addHandler(file_handler)
|
||||||
# If level is DEBUG and file is provided, do not send DEBUG level to stdout
|
# If level is DEBUG and file is provided, do not send DEBUG level to stdout
|
||||||
if loglevel == logging.DEBUG:
|
if loglevel == logging.DEBUG:
|
||||||
richHandler.setLevel(logging.INFO)
|
rich_handler.setLevel(logging.INFO)
|
||||||
|
|
||||||
if __DEBUG__:
|
if __DEBUG__:
|
||||||
logger.debug("ANTA Debug Mode enabled")
|
logger.debug("ANTA Debug Mode enabled")
|
||||||
|
|
||||||
|
|
||||||
def anta_log_exception(exception: BaseException, message: Optional[str] = None, calling_logger: Optional[logging.Logger] = None) -> None:
|
def exc_to_str(exception: BaseException) -> str:
|
||||||
"""
|
"""Return a human readable string from an BaseException object."""
|
||||||
Helper function to help log exceptions:
|
return f"{type(exception).__name__}{f': {exception}' if str(exception) else ''}"
|
||||||
* if anta.__DEBUG__ is True then the logger.exception method is called to get the traceback
|
|
||||||
* otherwise logger.error is called
|
|
||||||
|
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.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
exception (BAseException): The Exception being logged
|
----
|
||||||
message (str): An optional message
|
exception: The Exception being logged.
|
||||||
calling_logger (logging.Logger): A logger to which the exception should be logged
|
message: An optional message.
|
||||||
if not present, the logger in this file is used.
|
calling_logger: A logger to which the exception should be logged. If not present, the logger in this file is used.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if calling_logger is None:
|
if calling_logger is None:
|
||||||
calling_logger = logger
|
calling_logger = logger
|
||||||
calling_logger.critical(f"{message}\n{exc_to_str(exception)}" if message else exc_to_str(exception))
|
calling_logger.critical(f"{message}\n{exc_to_str(exception)}" if message else exc_to_str(exception))
|
||||||
if __DEBUG__:
|
if __DEBUG__:
|
||||||
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__))
|
||||||
|
|
335
anta/models.py
335
anta/models.py
|
@ -1,9 +1,8 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Models to define a TestStructure."""
|
||||||
Models to define a TestStructure
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
@ -14,77 +13,99 @@ from abc import ABC, abstractmethod
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from string import Formatter
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, TypeVar
|
||||||
|
|
||||||
# Need to keep Dict and List for pydantic in python 3.8
|
from pydantic import BaseModel, ConfigDict, ValidationError, create_model
|
||||||
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Coroutine, Dict, List, Literal, Optional, TypeVar, Union
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, ValidationError, conint
|
|
||||||
from rich.progress import Progress, TaskID
|
|
||||||
|
|
||||||
from anta import GITHUB_SUGGESTION
|
from anta import GITHUB_SUGGESTION
|
||||||
from anta.logger import anta_log_exception
|
from anta.custom_types import Revision
|
||||||
|
from anta.logger import anta_log_exception, exc_to_str
|
||||||
from anta.result_manager.models import TestResult
|
from anta.result_manager.models import TestResult
|
||||||
from anta.tools.misc import exc_to_str
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Coroutine
|
||||||
|
|
||||||
|
from rich.progress import Progress, TaskID
|
||||||
|
|
||||||
from anta.device import AntaDevice
|
from anta.device import AntaDevice
|
||||||
|
|
||||||
F = TypeVar("F", bound=Callable[..., Any])
|
F = TypeVar("F", bound=Callable[..., Any])
|
||||||
# Proper way to type input class - revisit this later if we get any issue @gmuloc
|
# Proper way to type input class - revisit this later if we get any issue @gmuloc
|
||||||
# This would imply overhead to define classes
|
# This would imply overhead to define classes
|
||||||
# https://stackoverflow.com/questions/74103528/type-hinting-an-instance-of-a-nested-class
|
# https://stackoverflow.com/questions/74103528/type-hinting-an-instance-of-a-nested-class
|
||||||
# N = TypeVar("N", bound="AntaTest.Input")
|
|
||||||
|
|
||||||
|
# TODO: make this configurable - with an env var maybe?
|
||||||
# TODO - make this configurable - with an env var maybe?
|
|
||||||
BLACKLIST_REGEX = [r"^reload.*", r"^conf\w*\s*(terminal|session)*", r"^wr\w*\s*\w+"]
|
BLACKLIST_REGEX = [r"^reload.*", r"^conf\w*\s*(terminal|session)*", r"^wr\w*\s*\w+"]
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AntaMissingParamException(Exception):
|
class AntaParamsBaseModel(BaseModel):
|
||||||
"""
|
"""Extends BaseModel and overwrite __getattr__ to return None on missing attribute."""
|
||||||
This Exception should be used when an expected key in an AntaCommand.params dictionary
|
|
||||||
was not found.
|
|
||||||
|
|
||||||
This Exception should in general never be raised in normal usage of ANTA.
|
model_config = ConfigDict(extra="forbid")
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, message: str) -> None:
|
if not TYPE_CHECKING:
|
||||||
self.message = "\n".join([message, GITHUB_SUGGESTION])
|
# Following pydantic declaration and keeping __getattr__ only when TYPE_CHECKING is false.
|
||||||
super().__init__(self.message)
|
# Disabling 1 Dynamically typed expressions (typing.Any) are disallowed in `__getattr__
|
||||||
|
# ruff: noqa: ANN401
|
||||||
|
def __getattr__(self, item: str) -> Any:
|
||||||
|
"""For AntaParams if we try to access an attribute that is not present We want it to be None."""
|
||||||
|
try:
|
||||||
|
return super().__getattr__(item)
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class AntaTemplate(BaseModel):
|
class AntaTemplate(BaseModel):
|
||||||
"""Class to define a command template as Python f-string.
|
"""Class to define a command template as Python f-string.
|
||||||
|
|
||||||
Can render a command from parameters.
|
Can render a command from parameters.
|
||||||
|
|
||||||
Attributes:
|
Attributes
|
||||||
|
----------
|
||||||
template: Python f-string. Example: 'show vlan {vlan_id}'
|
template: Python f-string. Example: 'show vlan {vlan_id}'
|
||||||
version: eAPI version - valid values are 1 or "latest" - default is "latest"
|
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.
|
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
|
ofmt: eAPI output - json or text.
|
||||||
use_cache: Enable or disable caching for this AntaTemplate if the AntaDevice supports it - default is True
|
use_cache: Enable or disable caching for this AntaTemplate if the AntaDevice supports it.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template: str
|
template: str
|
||||||
version: Literal[1, "latest"] = "latest"
|
version: Literal[1, "latest"] = "latest"
|
||||||
revision: Optional[conint(ge=1, le=99)] = None # type: ignore
|
revision: Revision | None = None
|
||||||
ofmt: Literal["json", "text"] = "json"
|
ofmt: Literal["json", "text"] = "json"
|
||||||
use_cache: bool = True
|
use_cache: bool = True
|
||||||
|
|
||||||
def render(self, **params: dict[str, Any]) -> AntaCommand:
|
def render(self, **params: str | int | bool) -> AntaCommand:
|
||||||
"""Render an AntaCommand from an AntaTemplate instance.
|
"""Render an AntaCommand from an AntaTemplate instance.
|
||||||
|
|
||||||
Keep the parameters used in the AntaTemplate instance.
|
Keep the parameters used in the AntaTemplate instance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
params: dictionary of variables with string values to render the Python f-string
|
params: dictionary of variables with string values to render the Python f-string
|
||||||
|
|
||||||
Returns:
|
Returns
|
||||||
|
-------
|
||||||
command: The rendered AntaCommand.
|
command: The rendered AntaCommand.
|
||||||
This AntaCommand instance have a template attribute that references this
|
This AntaCommand instance have a template attribute that references this
|
||||||
AntaTemplate instance.
|
AntaTemplate instance.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
# Create params schema on the fly
|
||||||
|
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: (type(params.get(key)), ...) for key in field_names}
|
||||||
|
# Accepting ParamsSchema as non lowercase variable
|
||||||
|
ParamsSchema = create_model( # noqa: N806
|
||||||
|
"ParamsSchema",
|
||||||
|
__base__=AntaParamsBaseModel,
|
||||||
|
**fields,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return AntaCommand(
|
return AntaCommand(
|
||||||
command=self.template.format(**params),
|
command=self.template.format(**params),
|
||||||
|
@ -92,7 +113,7 @@ class AntaTemplate(BaseModel):
|
||||||
version=self.version,
|
version=self.version,
|
||||||
revision=self.revision,
|
revision=self.revision,
|
||||||
template=self,
|
template=self,
|
||||||
params=params,
|
params=ParamsSchema(**params),
|
||||||
use_cache=self.use_cache,
|
use_cache=self.use_cache,
|
||||||
)
|
)
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
|
@ -113,70 +134,115 @@ class AntaCommand(BaseModel):
|
||||||
|
|
||||||
__Revision has precedence over version.__
|
__Revision has precedence over version.__
|
||||||
|
|
||||||
Attributes:
|
Attributes
|
||||||
|
----------
|
||||||
command: Device command
|
command: Device command
|
||||||
version: eAPI version - valid values are 1 or "latest" - default is "latest"
|
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.
|
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
|
ofmt: eAPI output - json or text.
|
||||||
output: Output of the command populated by the collect() function
|
output: Output of the command. Only defined if there was no errors.
|
||||||
template: AntaTemplate object used to render this command
|
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(s).
|
||||||
errors: If the command execution fails, eAPI returns a list of strings detailing the error
|
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 - default is True
|
use_cache: Enable or disable caching for this AntaCommand if the AntaDevice supports it.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
command: str
|
command: str
|
||||||
version: Literal[1, "latest"] = "latest"
|
version: Literal[1, "latest"] = "latest"
|
||||||
revision: Optional[conint(ge=1, le=99)] = None # type: ignore
|
revision: Revision | None = None
|
||||||
ofmt: Literal["json", "text"] = "json"
|
ofmt: Literal["json", "text"] = "json"
|
||||||
output: Optional[Union[Dict[str, Any], str]] = None
|
output: dict[str, Any] | str | None = None
|
||||||
template: Optional[AntaTemplate] = None
|
template: AntaTemplate | None = None
|
||||||
errors: List[str] = []
|
errors: list[str] = []
|
||||||
params: Dict[str, Any] = {}
|
params: AntaParamsBaseModel = AntaParamsBaseModel()
|
||||||
use_cache: bool = True
|
use_cache: bool = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def uid(self) -> str:
|
def uid(self) -> str:
|
||||||
"""Generate a unique identifier for this command"""
|
"""Generate a unique identifier for this command."""
|
||||||
uid_str = f"{self.command}_{self.version}_{self.revision or 'NA'}_{self.ofmt}"
|
uid_str = f"{self.command}_{self.version}_{self.revision or 'NA'}_{self.ofmt}"
|
||||||
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
|
@property
|
||||||
def json_output(self) -> dict[str, Any]:
|
def json_output(self) -> dict[str, Any]:
|
||||||
"""Get the command output as JSON"""
|
"""Get the command output as JSON."""
|
||||||
if self.output is None:
|
if self.output is None:
|
||||||
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):
|
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)
|
return dict(self.output)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def text_output(self) -> str:
|
def text_output(self) -> str:
|
||||||
"""Get the command output as a string"""
|
"""Get the command output as a string."""
|
||||||
if self.output is None:
|
if self.output is None:
|
||||||
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):
|
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)
|
return str(self.output)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error(self) -> bool:
|
||||||
|
"""Return True if the command returned an error, False otherwise."""
|
||||||
|
return len(self.errors) > 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def collected(self) -> bool:
|
def collected(self) -> bool:
|
||||||
"""Return True if the command has been collected"""
|
"""Return True if the command has been collected, False otherwise.
|
||||||
return self.output is not None and not self.errors
|
|
||||||
|
A command that has not been collected could have returned an error.
|
||||||
|
See error property.
|
||||||
|
"""
|
||||||
|
return not self.error and self.output is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def requires_privileges(self) -> bool:
|
||||||
|
"""Return True if the command requires privileged mode, False otherwise.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
RuntimeError
|
||||||
|
If the command has not been collected and has not returned an error.
|
||||||
|
AntaDevice.collect() must be called before this property.
|
||||||
|
"""
|
||||||
|
if not self.collected and not self.error:
|
||||||
|
msg = f"Command '{self.command}' has not been collected and has not returned an error. Call AntaDevice.collect()."
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
return any("privileged mode required" in e for e in self.errors)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported(self) -> bool:
|
||||||
|
"""Return 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 not any("not supported on this hardware platform" in e for e in self.errors)
|
||||||
|
|
||||||
|
|
||||||
class AntaTemplateRenderError(RuntimeError):
|
class AntaTemplateRenderError(RuntimeError):
|
||||||
"""
|
"""Raised when an AntaTemplate object could not be rendered because of missing parameters."""
|
||||||
Raised when an AntaTemplate object could not be rendered
|
|
||||||
because of missing parameters
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, template: AntaTemplate, key: str):
|
def __init__(self, template: AntaTemplate, key: str) -> None:
|
||||||
"""Constructor for AntaTemplateRenderError
|
"""Initialize an AntaTemplateRenderError.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
template: The AntaTemplate instance that failed to render
|
template: The AntaTemplate instance that failed to render
|
||||||
key: Key that has not been provided to render the template
|
key: Key that has not been provided to render the template
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self.template = template
|
self.template = template
|
||||||
self.key = key
|
self.key = key
|
||||||
|
@ -184,12 +250,13 @@ class AntaTemplateRenderError(RuntimeError):
|
||||||
|
|
||||||
|
|
||||||
class AntaTest(ABC):
|
class AntaTest(ABC):
|
||||||
"""Abstract class defining a test in ANTA
|
"""Abstract class defining a test in ANTA.
|
||||||
|
|
||||||
The goal of this class is to handle the heavy lifting and make
|
The goal of this class is to handle the heavy lifting and make
|
||||||
writing a test as simple as possible.
|
writing a test as simple as possible.
|
||||||
|
|
||||||
Examples:
|
Examples
|
||||||
|
--------
|
||||||
The following is an example of an AntaTest subclass implementation:
|
The following is an example of an AntaTest subclass implementation:
|
||||||
```python
|
```python
|
||||||
class VerifyReachability(AntaTest):
|
class VerifyReachability(AntaTest):
|
||||||
|
@ -227,22 +294,24 @@ class AntaTest(ABC):
|
||||||
instance_commands: List of AntaCommand instances of this test
|
instance_commands: List of AntaCommand instances of this test
|
||||||
result: TestResult instance representing the result of this test
|
result: TestResult instance representing the result of this test
|
||||||
logger: Python logger for this test instance
|
logger: Python logger for this test instance
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Mandatory class attributes
|
# Mandatory class attributes
|
||||||
# TODO - find a way to tell mypy these are mandatory for child classes - maybe Protocol
|
# TODO: find a way to tell mypy these are mandatory for child classes - maybe Protocol
|
||||||
name: ClassVar[str]
|
name: ClassVar[str]
|
||||||
description: ClassVar[str]
|
description: ClassVar[str]
|
||||||
categories: ClassVar[list[str]]
|
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
|
# Class attributes to handle the progress bar of ANTA CLI
|
||||||
progress: Optional[Progress] = None
|
progress: Progress | None = None
|
||||||
nrfu_task: Optional[TaskID] = None
|
nrfu_task: TaskID | None = None
|
||||||
|
|
||||||
class Input(BaseModel):
|
class Input(BaseModel):
|
||||||
"""Class defining inputs for a test in ANTA.
|
"""Class defining inputs for a test in ANTA.
|
||||||
|
|
||||||
Examples:
|
Examples
|
||||||
|
--------
|
||||||
A valid test catalog will look like the following:
|
A valid test catalog will look like the following:
|
||||||
```yaml
|
```yaml
|
||||||
<Python module>:
|
<Python module>:
|
||||||
|
@ -255,72 +324,85 @@ class AntaTest(ABC):
|
||||||
```
|
```
|
||||||
Attributes:
|
Attributes:
|
||||||
result_overwrite: Define fields to overwrite in the TestResult object
|
result_overwrite: Define fields to overwrite in the TestResult object
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
result_overwrite: Optional[ResultOverwrite] = None
|
result_overwrite: ResultOverwrite | None = None
|
||||||
filters: Optional[Filters] = None
|
filters: Filters | None = None
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
"""
|
"""Implement generic hashing for AntaTest.Input.
|
||||||
Implement generic hashing for AntaTest.Input.
|
|
||||||
This will work in most cases but this does not consider 2 lists with different ordering as equal.
|
This will work in most cases but this does not consider 2 lists with different ordering as equal.
|
||||||
"""
|
"""
|
||||||
return hash(self.model_dump_json())
|
return hash(self.model_dump_json())
|
||||||
|
|
||||||
class ResultOverwrite(BaseModel):
|
class ResultOverwrite(BaseModel):
|
||||||
"""Test inputs model to overwrite result fields
|
"""Test inputs model to overwrite result fields.
|
||||||
|
|
||||||
Attributes:
|
Attributes
|
||||||
|
----------
|
||||||
description: overwrite TestResult.description
|
description: overwrite TestResult.description
|
||||||
categories: overwrite TestResult.categories
|
categories: overwrite TestResult.categories
|
||||||
custom_field: a free string that will be included in the TestResult object
|
custom_field: a free string that will be included in the TestResult object
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
description: Optional[str] = None
|
description: str | None = None
|
||||||
categories: Optional[List[str]] = None
|
categories: list[str] | None = None
|
||||||
custom_field: Optional[str] = None
|
custom_field: str | None = None
|
||||||
|
|
||||||
class Filters(BaseModel):
|
class Filters(BaseModel):
|
||||||
"""Runtime filters to map tests with list of tags or devices
|
"""Runtime filters to map tests with list of tags or devices.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
tags: Tag of devices on which to run the test.
|
||||||
|
|
||||||
Attributes:
|
|
||||||
tags: List of device's tags for the test.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
tags: Optional[List[str]] = None
|
tags: set[str] | None = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
device: AntaDevice,
|
device: AntaDevice,
|
||||||
inputs: dict[str, Any] | AntaTest.Input | None = None,
|
inputs: dict[str, Any] | AntaTest.Input | None = None,
|
||||||
eos_data: list[dict[Any, Any] | str] | None = None,
|
eos_data: list[dict[Any, Any] | str] | None = None,
|
||||||
):
|
) -> None:
|
||||||
"""AntaTest Constructor
|
"""AntaTest Constructor.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
device: AntaDevice instance on which the test will be run
|
device: AntaDevice instance on which the test will be run
|
||||||
inputs: dictionary of attributes used to instantiate the AntaTest.Input instance
|
inputs: dictionary of attributes used to instantiate the AntaTest.Input instance
|
||||||
eos_data: Populate outputs of the test commands instead of collecting from devices.
|
eos_data: Populate outputs of the test commands instead of collecting from devices.
|
||||||
This list must have the same length and order than the `instance_commands` instance attribute.
|
This list must have the same length and order than the `instance_commands` instance attribute.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self.logger: logging.Logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}")
|
self.logger: logging.Logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}")
|
||||||
self.device: AntaDevice = device
|
self.device: AntaDevice = device
|
||||||
self.inputs: AntaTest.Input
|
self.inputs: AntaTest.Input
|
||||||
self.instance_commands: list[AntaCommand] = []
|
self.instance_commands: list[AntaCommand] = []
|
||||||
self.result: TestResult = TestResult(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)
|
self._init_inputs(inputs)
|
||||||
if self.result.result == "unset":
|
if self.result.result == "unset":
|
||||||
self._init_commands(eos_data)
|
self._init_commands(eos_data)
|
||||||
|
|
||||||
def _init_inputs(self, inputs: dict[str, Any] | AntaTest.Input | None) -> None:
|
def _init_inputs(self, inputs: dict[str, Any] | AntaTest.Input | None) -> None:
|
||||||
"""Instantiate the `inputs` instance attribute with an `AntaTest.Input` instance
|
"""Instantiate the `inputs` instance attribute with an `AntaTest.Input` instance to validate test inputs using the model.
|
||||||
to validate test inputs from defined model.
|
|
||||||
Overwrite result fields based on `ResultOverwrite` input definition.
|
Overwrite result fields based on `ResultOverwrite` input definition.
|
||||||
|
|
||||||
Any input validation error will set this test result status as 'error'."""
|
Any input validation error will set this test result status as 'error'.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
if inputs is None:
|
if inputs is None:
|
||||||
self.inputs = self.Input()
|
self.inputs = self.Input()
|
||||||
|
@ -340,10 +422,11 @@ class AntaTest(ABC):
|
||||||
self.result.description = res_ow.description
|
self.result.description = res_ow.description
|
||||||
self.result.custom_field = res_ow.custom_field
|
self.result.custom_field = res_ow.custom_field
|
||||||
|
|
||||||
def _init_commands(self, eos_data: 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.
|
"""Instantiate the `instance_commands` instance attribute from the `commands` class attribute.
|
||||||
|
|
||||||
- Copy of the `AntaCommand` instances
|
- Copy of the `AntaCommand` instances
|
||||||
- Render all `AntaTemplate` instances using the `render()` method
|
- Render all `AntaTemplate` instances using the `render()` method.
|
||||||
|
|
||||||
Any template rendering error will set this test result status as 'error'.
|
Any template rendering error will set this test result status as 'error'.
|
||||||
Any exception in user code in `render()` will set this test result status as 'error'.
|
Any exception in user code in `render()` will set this test result status as 'error'.
|
||||||
|
@ -371,11 +454,11 @@ class AntaTest(ABC):
|
||||||
return
|
return
|
||||||
|
|
||||||
if eos_data is not None:
|
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)
|
self.save_commands_data(eos_data)
|
||||||
|
|
||||||
def save_commands_data(self, eos_data: list[dict[str, Any] | str]) -> None:
|
def save_commands_data(self, eos_data: list[dict[str, Any] | str]) -> None:
|
||||||
"""Populate output of all AntaCommand instances in `instance_commands`"""
|
"""Populate output of all AntaCommand instances in `instance_commands`."""
|
||||||
if len(eos_data) > len(self.instance_commands):
|
if len(eos_data) > len(self.instance_commands):
|
||||||
self.result.is_error(message="Test initialization error: Trying to save more data than there are commands for the test")
|
self.result.is_error(message="Test initialization error: Trying to save more data than there are commands for the test")
|
||||||
return
|
return
|
||||||
|
@ -386,11 +469,12 @@ class AntaTest(ABC):
|
||||||
self.instance_commands[index].output = data
|
self.instance_commands[index].output = data
|
||||||
|
|
||||||
def __init_subclass__(cls) -> None:
|
def __init_subclass__(cls) -> None:
|
||||||
"""Verify that the mandatory class attributes are defined"""
|
"""Verify that the mandatory class attributes are defined."""
|
||||||
mandatory_attributes = ["name", "description", "categories", "commands"]
|
mandatory_attributes = ["name", "description", "categories", "commands"]
|
||||||
for attr in mandatory_attributes:
|
for attr in mandatory_attributes:
|
||||||
if not hasattr(cls, attr):
|
if not hasattr(cls, attr):
|
||||||
raise NotImplementedError(f"Class {cls.__module__}.{cls.__name__} is missing required class attribute {attr}")
|
msg = f"Class {cls.__module__}.{cls.__name__} is missing required class attribute {attr}"
|
||||||
|
raise NotImplementedError(msg)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def collected(self) -> bool:
|
def collected(self) -> bool:
|
||||||
|
@ -400,15 +484,17 @@ class AntaTest(ABC):
|
||||||
@property
|
@property
|
||||||
def failed_commands(self) -> list[AntaCommand]:
|
def failed_commands(self) -> list[AntaCommand]:
|
||||||
"""Returns a list of all the commands that have failed."""
|
"""Returns a list of all the commands that have failed."""
|
||||||
return [command for command in self.instance_commands if command.errors]
|
return [command for command in self.instance_commands if command.error]
|
||||||
|
|
||||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||||
"""Render an AntaTemplate instance of this AntaTest using the provided
|
"""Render an AntaTemplate instance of this AntaTest using the provided AntaTest.Input instance at self.inputs.
|
||||||
AntaTest.Input instance at self.inputs.
|
|
||||||
|
|
||||||
This is not an abstract method because it does not need to be implemented if there is
|
This is not an abstract method because it does not need to be implemented if there is
|
||||||
no AntaTemplate for this test."""
|
no AntaTemplate for this test.
|
||||||
raise NotImplementedError(f"AntaTemplate are provided but render() method has not been implemented for {self.__module__}.{self.name}")
|
"""
|
||||||
|
_ = template
|
||||||
|
msg = f"AntaTemplate are provided but render() method has not been implemented for {self.__module__}.{self.name}"
|
||||||
|
raise NotImplementedError(msg)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def blocked(self) -> bool:
|
def blocked(self) -> bool:
|
||||||
|
@ -417,15 +503,17 @@ class AntaTest(ABC):
|
||||||
for command in self.instance_commands:
|
for command in self.instance_commands:
|
||||||
for pattern in BLACKLIST_REGEX:
|
for pattern in BLACKLIST_REGEX:
|
||||||
if re.match(pattern, command.command):
|
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,
|
||||||
|
BLACKLIST_REGEX,
|
||||||
|
)
|
||||||
self.result.is_error(f"<{command.command}> is blocked for security reason")
|
self.result.is_error(f"<{command.command}> is blocked for security reason")
|
||||||
state = True
|
state = True
|
||||||
return state
|
return state
|
||||||
|
|
||||||
async def collect(self) -> None:
|
async def collect(self) -> None:
|
||||||
"""
|
"""Collect outputs of all commands of this test class from the device of this test instance."""
|
||||||
Method used to collect outputs of all commands of this test class from the device of this test instance.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
if self.blocked is False:
|
if self.blocked is False:
|
||||||
await self.device.collect_commands(self.instance_commands)
|
await self.device.collect_commands(self.instance_commands)
|
||||||
|
@ -439,8 +527,7 @@ class AntaTest(ABC):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def anta_test(function: F) -> Callable[..., Coroutine[Any, Any, TestResult]]:
|
def anta_test(function: F) -> Callable[..., Coroutine[Any, Any, TestResult]]:
|
||||||
"""
|
"""Decorate the `test()` method in child classes.
|
||||||
Decorator for the `test()` method.
|
|
||||||
|
|
||||||
This decorator implements (in this order):
|
This decorator implements (in this order):
|
||||||
|
|
||||||
|
@ -454,15 +541,21 @@ class AntaTest(ABC):
|
||||||
async def wrapper(
|
async def wrapper(
|
||||||
self: AntaTest,
|
self: AntaTest,
|
||||||
eos_data: list[dict[Any, Any] | str] | None = None,
|
eos_data: list[dict[Any, Any] | str] | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: dict[str, Any],
|
||||||
) -> TestResult:
|
) -> TestResult:
|
||||||
"""
|
"""Inner function for the anta_test decorator.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
|
self: The test instance.
|
||||||
eos_data: Populate outputs of the test commands instead of collecting from devices.
|
eos_data: Populate outputs of the test commands instead of collecting from devices.
|
||||||
This list must have the same length and order than the `instance_commands` instance attribute.
|
This list must have the same length and order than the `instance_commands` instance attribute.
|
||||||
|
kwargs: Any keyword argument to pass to the test.
|
||||||
|
|
||||||
Returns:
|
Returns
|
||||||
|
-------
|
||||||
result: TestResult instance attribute populated with error status if any
|
result: TestResult instance attribute populated with error status if any
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def format_td(seconds: float, digits: int = 3) -> str:
|
def format_td(seconds: float, digits: int = 3) -> str:
|
||||||
|
@ -476,7 +569,7 @@ class AntaTest(ABC):
|
||||||
# Data
|
# Data
|
||||||
if eos_data is not None:
|
if eos_data is not None:
|
||||||
self.save_commands_data(eos_data)
|
self.save_commands_data(eos_data)
|
||||||
self.logger.debug(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 some data is missing, try to collect
|
||||||
if not self.collected:
|
if not self.collected:
|
||||||
|
@ -485,11 +578,10 @@ class AntaTest(ABC):
|
||||||
return self.result
|
return self.result
|
||||||
|
|
||||||
if cmds := self.failed_commands:
|
if cmds := self.failed_commands:
|
||||||
self.logger.debug(self.device.supports)
|
unsupported_commands = [f"'{c.command}' is not supported on {self.device.hw_model}" for c in cmds if not c.supported]
|
||||||
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:
|
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}")
|
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))
|
self.result.is_skipped("\n".join(unsupported_commands))
|
||||||
return self.result
|
return self.result
|
||||||
self.result.is_error(message="\n".join([f"{c.command} has failed: {', '.join(c.errors)}" for c in cmds]))
|
self.result.is_error(message="\n".join([f"{c.command} has failed: {', '.join(c.errors)}" for c in cmds]))
|
||||||
|
@ -506,7 +598,8 @@ class AntaTest(ABC):
|
||||||
self.result.is_error(message=exc_to_str(e))
|
self.result.is_error(message=exc_to_str(e))
|
||||||
|
|
||||||
test_duration = time.time() - start_time
|
test_duration = time.time() - start_time
|
||||||
self.logger.debug(f"Executing test {self.name} on device {self.device.name} took {format_td(test_duration)}")
|
msg = f"Executing test {self.name} on device {self.device.name} took {format_td(test_duration)}"
|
||||||
|
self.logger.debug(msg)
|
||||||
|
|
||||||
AntaTest.update_progress()
|
AntaTest.update_progress()
|
||||||
return self.result
|
return self.result
|
||||||
|
@ -514,21 +607,20 @@ class AntaTest(ABC):
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def update_progress(cls) -> None:
|
def update_progress(cls: type[AntaTest]) -> None:
|
||||||
"""
|
"""Update progress bar for all AntaTest objects if it exists."""
|
||||||
Update progress bar for all AntaTest objects if it exists
|
|
||||||
"""
|
|
||||||
if cls.progress and (cls.nrfu_task is not None):
|
if cls.progress and (cls.nrfu_task is not None):
|
||||||
cls.progress.update(cls.nrfu_task, advance=1)
|
cls.progress.update(cls.nrfu_task, advance=1)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def test(self) -> Coroutine[Any, Any, TestResult]:
|
def test(self) -> Coroutine[Any, Any, TestResult]:
|
||||||
"""
|
"""Core of the test logic.
|
||||||
This abstract method is the core of the test logic.
|
|
||||||
It must set the correct status of the `result` instance attribute
|
|
||||||
with the appropriate outcome of the test.
|
|
||||||
|
|
||||||
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:
|
It must be implemented using the `AntaTest.anta_test` decorator:
|
||||||
```python
|
```python
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
|
@ -536,6 +628,7 @@ class AntaTest(ABC):
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
for command in self.instance_commands:
|
for command in self.instance_commands:
|
||||||
if not self._test_command(command): # _test_command() is an arbitrary test logic
|
if not self._test_command(command): # _test_command() is an arbitrary test logic
|
||||||
self.result.is_failure("Failure reson")
|
self.result.is_failure("Failure reason")
|
||||||
```
|
```
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -1,23 +1,25 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Report management for ANTA."""
|
||||||
Report management for ANTA.
|
|
||||||
"""
|
|
||||||
# pylint: disable = too-few-public-methods
|
# pylint: disable = too-few-public-methods
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os.path
|
from typing import TYPE_CHECKING, Any
|
||||||
import pathlib
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from anta import RICH_COLOR_PALETTE, RICH_COLOR_THEME
|
from anta import RICH_COLOR_PALETTE, RICH_COLOR_THEME
|
||||||
from anta.custom_types import TestStatus
|
|
||||||
from anta.result_manager import ResultManager
|
if TYPE_CHECKING:
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
from anta.custom_types import TestStatus
|
||||||
|
from anta.result_manager import ResultManager
|
||||||
|
from anta.result_manager.models import TestResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -25,33 +27,37 @@ logger = logging.getLogger(__name__)
|
||||||
class ReportTable:
|
class ReportTable:
|
||||||
"""TableReport Generate a Table based on TestResult."""
|
"""TableReport Generate a Table based on TestResult."""
|
||||||
|
|
||||||
def _split_list_to_txt_list(self, usr_list: list[str], delimiter: Optional[str] = None) -> str:
|
def _split_list_to_txt_list(self, usr_list: list[str], delimiter: str | None = None) -> str:
|
||||||
"""
|
"""Split list to multi-lines string.
|
||||||
Split list to multi-lines string
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
usr_list (list[str]): List of string to concatenate
|
usr_list (list[str]): List of string to concatenate
|
||||||
delimiter (str, optional): A delimiter to use to start string. Defaults to None.
|
delimiter (str, optional): A delimiter to use to start string. Defaults to None.
|
||||||
|
|
||||||
Returns:
|
Returns
|
||||||
|
-------
|
||||||
str: Multi-lines string
|
str: Multi-lines string
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if delimiter is not None:
|
if delimiter is not None:
|
||||||
return "\n".join(f"{delimiter} {line}" for line in usr_list)
|
return "\n".join(f"{delimiter} {line}" for line in usr_list)
|
||||||
return "\n".join(f"{line}" for line in usr_list)
|
return "\n".join(f"{line}" for line in usr_list)
|
||||||
|
|
||||||
def _build_headers(self, headers: list[str], table: Table) -> Table:
|
def _build_headers(self, headers: list[str], table: Table) -> Table:
|
||||||
"""
|
"""Create headers for a table.
|
||||||
Create headers for a table.
|
|
||||||
|
|
||||||
First key is considered as header and is colored using RICH_COLOR_PALETTE.HEADER
|
First key is considered as header and is colored using RICH_COLOR_PALETTE.HEADER
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
headers (list[str]): List of headers
|
----
|
||||||
table (Table): A rich Table instance
|
headers: List of headers.
|
||||||
|
table: A rich Table instance.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
A rich `Table` instance with headers.
|
||||||
|
|
||||||
Returns:
|
|
||||||
Table: A rich Table instance with headers
|
|
||||||
"""
|
"""
|
||||||
for idx, header in enumerate(headers):
|
for idx, header in enumerate(headers):
|
||||||
if idx == 0:
|
if idx == 0:
|
||||||
|
@ -64,72 +70,69 @@ class ReportTable:
|
||||||
return table
|
return table
|
||||||
|
|
||||||
def _color_result(self, status: TestStatus) -> str:
|
def _color_result(self, status: TestStatus) -> str:
|
||||||
"""
|
"""Return a colored string based on the status value.
|
||||||
Return a colored string based on the status value.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
status (TestStatus): status value to color
|
----
|
||||||
|
status (TestStatus): status value to color.
|
||||||
|
|
||||||
Returns:
|
Returns
|
||||||
|
-------
|
||||||
str: the colored string
|
str: the colored string
|
||||||
|
|
||||||
"""
|
"""
|
||||||
color = RICH_COLOR_THEME.get(status, "")
|
color = RICH_COLOR_THEME.get(status, "")
|
||||||
return f"[{color}]{status}" if color != "" else str(status)
|
return f"[{color}]{status}" if color != "" else str(status)
|
||||||
|
|
||||||
def report_all(
|
def report_all(self, manager: ResultManager, title: str = "All tests results") -> Table:
|
||||||
self,
|
"""Create a table report with all tests for one or all devices.
|
||||||
result_manager: ResultManager,
|
|
||||||
host: Optional[str] = None,
|
|
||||||
testcase: Optional[str] = None,
|
|
||||||
title: str = "All tests results",
|
|
||||||
) -> Table:
|
|
||||||
"""
|
|
||||||
Create a table report with all tests for one or all devices.
|
|
||||||
|
|
||||||
Create table with full output: Host / Test / Status / Message
|
Create table with full output: Host / Test / Status / Message
|
||||||
|
|
||||||
Args:
|
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.
|
manager: A ResultManager instance.
|
||||||
testcase (str, optional): A test name to search for. Defaults to None.
|
title: Title for the report. Defaults to 'All tests results'.
|
||||||
title (str, optional): Title for the report. Defaults to 'All tests results'.
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
A fully populated rich `Table`
|
||||||
|
|
||||||
Returns:
|
|
||||||
Table: A fully populated rich Table
|
|
||||||
"""
|
"""
|
||||||
table = Table(title=title, show_lines=True)
|
table = Table(title=title, show_lines=True)
|
||||||
headers = ["Device", "Test Name", "Test Status", "Message(s)", "Test description", "Test category"]
|
headers = ["Device", "Test Name", "Test Status", "Message(s)", "Test description", "Test category"]
|
||||||
table = self._build_headers(headers=headers, table=table)
|
table = self._build_headers(headers=headers, table=table)
|
||||||
|
|
||||||
for result in result_manager.get_results():
|
def add_line(result: TestResult) -> None:
|
||||||
# pylint: disable=R0916
|
state = self._color_result(result.result)
|
||||||
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)):
|
message = self._split_list_to_txt_list(result.messages) if len(result.messages) > 0 else ""
|
||||||
state = self._color_result(result.result)
|
categories = ", ".join(result.categories)
|
||||||
message = self._split_list_to_txt_list(result.messages) if len(result.messages) > 0 else ""
|
table.add_row(str(result.name), result.test, state, message, result.description, categories)
|
||||||
categories = ", ".join(result.categories)
|
|
||||||
table.add_row(str(result.name), result.test, state, message, result.description, categories)
|
for result in manager.results:
|
||||||
|
add_line(result)
|
||||||
return table
|
return table
|
||||||
|
|
||||||
def report_summary_tests(
|
def report_summary_tests(
|
||||||
self,
|
self,
|
||||||
result_manager: ResultManager,
|
manager: ResultManager,
|
||||||
testcase: Optional[str] = None,
|
tests: list[str] | None = None,
|
||||||
title: str = "Summary per test case",
|
title: str = "Summary per test",
|
||||||
) -> Table:
|
) -> Table:
|
||||||
"""
|
"""Create a table report with result aggregated per test.
|
||||||
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
|
Create table with full output: Test | Number of success | Number of failure | Number of error | List of nodes in error or failure
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
result_manager (ResultManager): A manager with a list of tests.
|
----
|
||||||
testcase (str, optional): A test name to search for. Defaults to None.
|
manager: A ResultManager instance.
|
||||||
title (str, optional): Title for the report. Defaults to 'All tests results'.
|
tests: List of test names to include. None to select all tests.
|
||||||
|
title: Title of the report.
|
||||||
|
|
||||||
Returns:
|
Returns
|
||||||
Table: A fully populated rich Table
|
-------
|
||||||
|
A fully populated rich `Table`.
|
||||||
"""
|
"""
|
||||||
# sourcery skip: class-extract-method
|
|
||||||
table = Table(title=title, show_lines=True)
|
table = Table(title=title, show_lines=True)
|
||||||
headers = [
|
headers = [
|
||||||
"Test Case",
|
"Test Case",
|
||||||
|
@ -140,16 +143,16 @@ class ReportTable:
|
||||||
"List of failed or error nodes",
|
"List of failed or error nodes",
|
||||||
]
|
]
|
||||||
table = self._build_headers(headers=headers, table=table)
|
table = self._build_headers(headers=headers, table=table)
|
||||||
for testcase_read in result_manager.get_testcases():
|
for test in manager.get_tests():
|
||||||
if testcase is None or str(testcase_read) == testcase:
|
if tests is None or test in tests:
|
||||||
results = result_manager.get_result_by_test(testcase_read)
|
results = manager.filter_by_tests({test}).results
|
||||||
nb_failure = len([result for result in results if result.result == "failure"])
|
nb_failure = len([result for result in results if result.result == "failure"])
|
||||||
nb_error = len([result for result in results if result.result == "error"])
|
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"]]
|
list_failure = [result.name for result in results if result.result in ["failure", "error"]]
|
||||||
nb_success = len([result for result in results if result.result == "success"])
|
nb_success = len([result for result in results if result.result == "success"])
|
||||||
nb_skipped = len([result for result in results if result.result == "skipped"])
|
nb_skipped = len([result for result in results if result.result == "skipped"])
|
||||||
table.add_row(
|
table.add_row(
|
||||||
testcase_read,
|
test,
|
||||||
str(nb_success),
|
str(nb_success),
|
||||||
str(nb_skipped),
|
str(nb_skipped),
|
||||||
str(nb_failure),
|
str(nb_failure),
|
||||||
|
@ -158,24 +161,25 @@ class ReportTable:
|
||||||
)
|
)
|
||||||
return table
|
return table
|
||||||
|
|
||||||
def report_summary_hosts(
|
def report_summary_devices(
|
||||||
self,
|
self,
|
||||||
result_manager: ResultManager,
|
manager: ResultManager,
|
||||||
host: Optional[str] = None,
|
devices: list[str] | None = None,
|
||||||
title: str = "Summary per host",
|
title: str = "Summary per device",
|
||||||
) -> Table:
|
) -> Table:
|
||||||
"""
|
"""Create a table report with result aggregated per device.
|
||||||
Create a table report with result agregated per host.
|
|
||||||
|
|
||||||
Create table with full output: Host / Number of success / Number of failure / Number of error / List of nodes in error or failure
|
Create table with full output: Host | Number of success | Number of failure | Number of error | List of nodes in error or failure
|
||||||
|
|
||||||
Args:
|
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.
|
manager: A ResultManager instance.
|
||||||
title (str, optional): Title for the report. Defaults to 'All tests results'.
|
devices: List of device names to include. None to select all devices.
|
||||||
|
title: Title of the report.
|
||||||
|
|
||||||
Returns:
|
Returns
|
||||||
Table: A fully populated rich Table
|
-------
|
||||||
|
A fully populated rich `Table`.
|
||||||
"""
|
"""
|
||||||
table = Table(title=title, show_lines=True)
|
table = Table(title=title, show_lines=True)
|
||||||
headers = [
|
headers = [
|
||||||
|
@ -187,18 +191,16 @@ class ReportTable:
|
||||||
"List of failed or error test cases",
|
"List of failed or error test cases",
|
||||||
]
|
]
|
||||||
table = self._build_headers(headers=headers, table=table)
|
table = self._build_headers(headers=headers, table=table)
|
||||||
for host_read in result_manager.get_hosts():
|
for device in manager.get_devices():
|
||||||
if host is None or str(host_read) == host:
|
if devices is None or device in devices:
|
||||||
results = result_manager.get_result_by_host(host_read)
|
results = manager.filter_by_devices({device}).results
|
||||||
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_failure = len([result for result in results if result.result == "failure"])
|
||||||
nb_error = len([result for result in results if result.result == "error"])
|
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"]]
|
list_failure = [result.test for result in results if result.result in ["failure", "error"]]
|
||||||
nb_success = len([result for result in results if result.result == "success"])
|
nb_success = len([result for result in results if result.result == "success"])
|
||||||
nb_skipped = len([result for result in results if result.result == "skipped"])
|
nb_skipped = len([result for result in results if result.result == "skipped"])
|
||||||
table.add_row(
|
table.add_row(
|
||||||
str(host_read),
|
device,
|
||||||
str(nb_success),
|
str(nb_success),
|
||||||
str(nb_skipped),
|
str(nb_skipped),
|
||||||
str(nb_failure),
|
str(nb_failure),
|
||||||
|
@ -212,20 +214,20 @@ class ReportJinja:
|
||||||
"""Report builder based on a Jinja2 template."""
|
"""Report builder based on a Jinja2 template."""
|
||||||
|
|
||||||
def __init__(self, template_path: pathlib.Path) -> None:
|
def __init__(self, template_path: pathlib.Path) -> None:
|
||||||
if os.path.isfile(template_path):
|
"""Create a ReportJinja instance."""
|
||||||
|
if template_path.is_file():
|
||||||
self.tempalte_path = template_path
|
self.tempalte_path = template_path
|
||||||
else:
|
else:
|
||||||
raise FileNotFoundError(f"template file is not found: {template_path}")
|
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:
|
def render(self, data: list[dict[str, Any]], *, trim_blocks: bool = True, lstrip_blocks: bool = True) -> str:
|
||||||
"""
|
"""Build a report based on a Jinja2 template.
|
||||||
Build a report based on a Jinja2 template
|
|
||||||
|
|
||||||
Report is built based on a J2 template provided by user.
|
Report is built based on a J2 template provided by user.
|
||||||
Data structure sent to template is:
|
Data structure sent to template is:
|
||||||
|
|
||||||
>>> data = ResultManager.get_json_results()
|
>>> print(ResultManager.json)
|
||||||
>>> print(data)
|
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
name: ...,
|
name: ...,
|
||||||
|
@ -238,14 +240,17 @@ class ReportJinja:
|
||||||
]
|
]
|
||||||
|
|
||||||
Args:
|
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.
|
data: List of results from ResultManager.results
|
||||||
lstrip_blocks (bool, optional): enable lstrip_blocks for J2 rendering. Defaults to True.
|
trim_blocks: enable trim_blocks for J2 rendering.
|
||||||
|
lstrip_blocks: enable lstrip_blocks for J2 rendering.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Rendered template
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: rendered template
|
|
||||||
"""
|
"""
|
||||||
with open(self.tempalte_path, encoding="utf-8") as file_:
|
with self.tempalte_path.open(encoding="utf-8") as file_:
|
||||||
template = Template(file_.read(), trim_blocks=trim_blocks, lstrip_blocks=lstrip_blocks)
|
template = Template(file_.read(), trim_blocks=trim_blocks, lstrip_blocks=lstrip_blocks)
|
||||||
|
|
||||||
return template.render({"data": data})
|
return template.render({"data": data})
|
||||||
|
|
|
@ -1,35 +1,32 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Result Manager module for ANTA."""
|
||||||
Result Manager Module for ANTA.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from pydantic import TypeAdapter
|
from pydantic import TypeAdapter
|
||||||
|
|
||||||
from anta.custom_types import TestStatus
|
from anta.custom_types import TestStatus
|
||||||
from anta.result_manager.models import TestResult
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
if TYPE_CHECKING:
|
||||||
|
from anta.result_manager.models import TestResult
|
||||||
|
|
||||||
|
|
||||||
class ResultManager:
|
class ResultManager:
|
||||||
"""
|
"""Helper to manage Test Results and generate reports.
|
||||||
Helper to manage Test Results and generate reports.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
Create Inventory:
|
Create Inventory:
|
||||||
|
|
||||||
inventory_anta = AntaInventory.parse(
|
inventory_anta = AntaInventory.parse(
|
||||||
filename='examples/inventory.yml',
|
filename='examples/inventory.yml',
|
||||||
username='ansible',
|
username='ansible',
|
||||||
password='ansible',
|
password='ansible',
|
||||||
timeout=0.5
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Create Result Manager:
|
Create Result Manager:
|
||||||
|
@ -38,17 +35,17 @@ class ResultManager:
|
||||||
|
|
||||||
Run tests for all connected devices:
|
Run tests for all connected devices:
|
||||||
|
|
||||||
for device in inventory_anta.get_inventory():
|
for device in inventory_anta.get_inventory().devices:
|
||||||
manager.add_test_result(
|
manager.add(
|
||||||
VerifyNTP(device=device).test()
|
VerifyNTP(device=device).test()
|
||||||
)
|
)
|
||||||
manager.add_test_result(
|
manager.add(
|
||||||
VerifyEOSVersion(device=device).test(version='4.28.3M')
|
VerifyEOSVersion(device=device).test(version='4.28.3M')
|
||||||
)
|
)
|
||||||
|
|
||||||
Print result in native format:
|
Print result in native format:
|
||||||
|
|
||||||
manager.get_results()
|
manager.results
|
||||||
[
|
[
|
||||||
TestResult(
|
TestResult(
|
||||||
host=IPv4Address('192.168.0.10'),
|
host=IPv4Address('192.168.0.10'),
|
||||||
|
@ -63,11 +60,11 @@ class ResultManager:
|
||||||
message=None
|
message=None
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""
|
"""Class constructor.
|
||||||
Class constructor.
|
|
||||||
|
|
||||||
The status of the class is initialized to "unset"
|
The status of the class is initialized to "unset"
|
||||||
|
|
||||||
|
@ -88,124 +85,116 @@ class ResultManager:
|
||||||
error_status is set to True.
|
error_status is set to True.
|
||||||
"""
|
"""
|
||||||
self._result_entries: list[TestResult] = []
|
self._result_entries: list[TestResult] = []
|
||||||
# Initialize status
|
|
||||||
self.status: TestStatus = "unset"
|
self.status: TestStatus = "unset"
|
||||||
self.error_status = False
|
self.error_status = False
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
"""
|
"""Implement __len__ method to count number of results."""
|
||||||
Implement __len__ method to count number of results.
|
|
||||||
"""
|
|
||||||
return len(self._result_entries)
|
return len(self._result_entries)
|
||||||
|
|
||||||
def _update_status(self, test_status: TestStatus) -> None:
|
@property
|
||||||
"""
|
def results(self) -> list[TestResult]:
|
||||||
Update ResultManager status based on the table above.
|
"""Get the list of TestResult."""
|
||||||
"""
|
|
||||||
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"}:
|
|
||||||
self.status = test_status
|
|
||||||
elif self.status == "success" and test_status == "failure":
|
|
||||||
self.status = "failure"
|
|
||||||
|
|
||||||
def add_test_result(self, entry: TestResult) -> None:
|
|
||||||
"""Add a result to the list
|
|
||||||
|
|
||||||
Args:
|
|
||||||
entry (TestResult): TestResult data to add to the report
|
|
||||||
"""
|
|
||||||
logger.debug(entry)
|
|
||||||
self._result_entries.append(entry)
|
|
||||||
self._update_status(entry.result)
|
|
||||||
|
|
||||||
def add_test_results(self, entries: list[TestResult]) -> None:
|
|
||||||
"""Add a list of results to the list
|
|
||||||
|
|
||||||
Args:
|
|
||||||
entries (list[TestResult]): List of TestResult data to add to the report
|
|
||||||
"""
|
|
||||||
for e in entries:
|
|
||||||
self.add_test_result(e)
|
|
||||||
|
|
||||||
def get_status(self, ignore_error: bool = False) -> str:
|
|
||||||
"""
|
|
||||||
Returns 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]:
|
|
||||||
"""
|
|
||||||
Expose list of all test results in different format
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
any: List of results.
|
|
||||||
"""
|
|
||||||
return self._result_entries
|
return self._result_entries
|
||||||
|
|
||||||
def get_json_results(self) -> str:
|
@results.setter
|
||||||
"""
|
def results(self, value: list[TestResult]) -> None:
|
||||||
Expose list of all test results in JSON
|
self._result_entries = []
|
||||||
|
self.status = "unset"
|
||||||
|
self.error_status = False
|
||||||
|
for e in value:
|
||||||
|
self.add(e)
|
||||||
|
|
||||||
Returns:
|
@property
|
||||||
str: JSON dumps of the list of results
|
def json(self) -> str:
|
||||||
"""
|
"""Get a JSON representation of the results."""
|
||||||
result = [result.model_dump() for result in self._result_entries]
|
return json.dumps([result.model_dump() for result in self._result_entries], indent=4)
|
||||||
return json.dumps(result, indent=4)
|
|
||||||
|
|
||||||
def get_result_by_test(self, test_name: str) -> list[TestResult]:
|
def add(self, result: TestResult) -> None:
|
||||||
"""
|
"""Add a result to the ResultManager instance.
|
||||||
Get list of test result for a given test.
|
|
||||||
|
|
||||||
Args:
|
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'.
|
result: TestResult to add to the ResultManager instance.
|
||||||
|
|
||||||
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]:
|
def _update_status(test_status: TestStatus) -> None:
|
||||||
"""
|
result_validator = TypeAdapter(TestStatus)
|
||||||
Get list of test result for a given host.
|
result_validator.validate_python(test_status)
|
||||||
|
if test_status == "error":
|
||||||
|
self.error_status = True
|
||||||
|
return
|
||||||
|
if self.status == "unset" or self.status == "skipped" and test_status in {"success", "failure"}:
|
||||||
|
self.status = test_status
|
||||||
|
elif self.status == "success" and test_status == "failure":
|
||||||
|
self.status = "failure"
|
||||||
|
|
||||||
|
self._result_entries.append(result)
|
||||||
|
_update_status(result.result)
|
||||||
|
|
||||||
|
def get_status(self, *, ignore_error: bool = False) -> str:
|
||||||
|
"""Return the current status including error_status if ignore_error is False."""
|
||||||
|
return "error" if self.error_status and not ignore_error else self.status
|
||||||
|
|
||||||
|
def filter(self, hide: set[TestStatus]) -> ResultManager:
|
||||||
|
"""Get a filtered ResultManager based on test status.
|
||||||
|
|
||||||
Args:
|
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'.
|
hide: set of TestStatus literals to select tests to hide based on their status.
|
||||||
|
|
||||||
Returns:
|
Returns
|
||||||
list[TestResult]: List of results related to the host.
|
-------
|
||||||
|
A filtered `ResultManager`.
|
||||||
"""
|
"""
|
||||||
return [result for result in self._result_entries if str(result.name) == host_ip]
|
manager = ResultManager()
|
||||||
|
manager.results = [test for test in self._result_entries if test.result not in hide]
|
||||||
|
return manager
|
||||||
|
|
||||||
def get_testcases(self) -> list[str]:
|
def filter_by_tests(self, tests: set[str]) -> ResultManager:
|
||||||
"""
|
"""Get a filtered ResultManager that only contains specific tests.
|
||||||
Get list of name of all test cases in current manager.
|
|
||||||
|
|
||||||
Returns:
|
Args:
|
||||||
list[str]: List of names for all tests.
|
----
|
||||||
"""
|
tests: Set of test names to filter the results.
|
||||||
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]:
|
Returns
|
||||||
|
-------
|
||||||
|
A filtered `ResultManager`.
|
||||||
"""
|
"""
|
||||||
Get list of IP addresses in current manager.
|
manager = ResultManager()
|
||||||
|
manager.results = [result for result in self._result_entries if result.test in tests]
|
||||||
|
return manager
|
||||||
|
|
||||||
Returns:
|
def filter_by_devices(self, devices: set[str]) -> ResultManager:
|
||||||
list[str]: List of IP addresses.
|
"""Get a filtered ResultManager that only contains specific devices.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
----
|
||||||
|
devices: Set of device names to filter the results.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
A filtered `ResultManager`.
|
||||||
"""
|
"""
|
||||||
result_list = []
|
manager = ResultManager()
|
||||||
for testcase in self._result_entries:
|
manager.results = [result for result in self._result_entries if result.name in devices]
|
||||||
if str(testcase.name) not in result_list:
|
return manager
|
||||||
result_list.append(str(testcase.name))
|
|
||||||
return result_list
|
def get_tests(self) -> set[str]:
|
||||||
|
"""Get the set of all the test names.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Set of test names.
|
||||||
|
"""
|
||||||
|
return {str(result.test) for result in self._result_entries}
|
||||||
|
|
||||||
|
def get_devices(self) -> set[str]:
|
||||||
|
"""Get the set of all the device names.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Set of device names.
|
||||||
|
"""
|
||||||
|
return {str(result.name) for result in self._result_entries}
|
||||||
|
|
|
@ -2,10 +2,8 @@
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""Models related to anta.result_manager module."""
|
"""Models related to anta.result_manager module."""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
# Need to keep List for pydantic in 3.8
|
from __future__ import annotations
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
@ -13,10 +11,10 @@ from anta.custom_types import TestStatus
|
||||||
|
|
||||||
|
|
||||||
class TestResult(BaseModel):
|
class TestResult(BaseModel):
|
||||||
"""
|
"""Describe the result of a test from a single device.
|
||||||
Describe the result of a test from a single device.
|
|
||||||
|
|
||||||
Attributes:
|
Attributes
|
||||||
|
----------
|
||||||
name: Device name where the test has run.
|
name: Device name where the test has run.
|
||||||
test: Test name runs on the device.
|
test: Test name runs on the device.
|
||||||
categories: List of categories the TestResult belongs to, by default the AntaTest categories.
|
categories: List of categories the TestResult belongs to, by default the AntaTest categories.
|
||||||
|
@ -24,63 +22,70 @@ class TestResult(BaseModel):
|
||||||
result: Result of the test. Can be one of "unset", "success", "failure", "error" or "skipped".
|
result: Result of the test. Can be one of "unset", "success", "failure", "error" or "skipped".
|
||||||
messages: Message to report after the test if any.
|
messages: Message to report after the test if any.
|
||||||
custom_field: Custom field to store a string for flexibility in integrating with ANTA
|
custom_field: Custom field to store a string for flexibility in integrating with ANTA
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
test: str
|
test: str
|
||||||
categories: List[str]
|
categories: list[str]
|
||||||
description: str
|
description: str
|
||||||
result: TestStatus = "unset"
|
result: TestStatus = "unset"
|
||||||
messages: List[str] = []
|
messages: list[str] = []
|
||||||
custom_field: Optional[str] = None
|
custom_field: str | None = None
|
||||||
|
|
||||||
def is_success(self, message: str | None = None) -> None:
|
def is_success(self, message: str | None = None) -> None:
|
||||||
"""
|
"""Set status to success.
|
||||||
Helper to set status to success
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
message: Optional message related to the test
|
message: Optional message related to the test
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self._set_status("success", message)
|
self._set_status("success", message)
|
||||||
|
|
||||||
def is_failure(self, message: str | None = None) -> None:
|
def is_failure(self, message: str | None = None) -> None:
|
||||||
"""
|
"""Set status to failure.
|
||||||
Helper to set status to failure
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
message: Optional message related to the test
|
message: Optional message related to the test
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self._set_status("failure", message)
|
self._set_status("failure", message)
|
||||||
|
|
||||||
def is_skipped(self, message: str | None = None) -> None:
|
def is_skipped(self, message: str | None = None) -> None:
|
||||||
"""
|
"""Set status to skipped.
|
||||||
Helper to set status to skipped
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
message: Optional message related to the test
|
message: Optional message related to the test
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self._set_status("skipped", message)
|
self._set_status("skipped", message)
|
||||||
|
|
||||||
def is_error(self, message: str | None = None) -> None:
|
def is_error(self, message: str | None = None) -> None:
|
||||||
"""
|
"""Set status to error.
|
||||||
Helper to set status to error
|
|
||||||
|
Args:
|
||||||
|
----
|
||||||
|
message: Optional message related to the test
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self._set_status("error", message)
|
self._set_status("error", message)
|
||||||
|
|
||||||
def _set_status(self, status: TestStatus, message: str | None = None) -> None:
|
def _set_status(self, status: TestStatus, message: str | None = None) -> None:
|
||||||
"""
|
"""Set status and insert optional message.
|
||||||
Set status and insert optional message
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
status: status of the test
|
status: status of the test
|
||||||
message: optional message
|
message: optional message
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self.result = status
|
self.result = status
|
||||||
if message is not None:
|
if message is not None:
|
||||||
self.messages.append(message)
|
self.messages.append(message)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""
|
"""Return a human readable string of this TestResult."""
|
||||||
Returns a human readable string of this TestResult
|
|
||||||
"""
|
|
||||||
return f"Test '{self.test}' (on '{self.name}'): Result '{self.result}'\nMessages: {self.messages}"
|
return f"Test '{self.test}' (on '{self.name}'): Result '{self.result}'\nMessages: {self.messages}"
|
||||||
|
|
162
anta/runner.py
162
anta/runner.py
|
@ -2,80 +2,156 @@
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
# pylint: disable=too-many-branches
|
# pylint: disable=too-many-branches
|
||||||
"""
|
"""ANTA runner function."""
|
||||||
ANTA runner function
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Tuple
|
import os
|
||||||
|
import resource
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from anta import GITHUB_SUGGESTION
|
from anta import GITHUB_SUGGESTION
|
||||||
from anta.catalog import AntaCatalog, AntaTestDefinition
|
from anta.catalog import AntaCatalog, AntaTestDefinition
|
||||||
from anta.device import AntaDevice
|
from anta.device import AntaDevice
|
||||||
from anta.inventory import AntaInventory
|
from anta.logger import anta_log_exception, exc_to_str
|
||||||
from anta.logger import anta_log_exception
|
|
||||||
from anta.models import AntaTest
|
from anta.models import AntaTest
|
||||||
from anta.result_manager import ResultManager
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.inventory import AntaInventory
|
||||||
|
from anta.result_manager import ResultManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
AntaTestRunner = Tuple[AntaTestDefinition, AntaDevice]
|
AntaTestRunner = tuple[AntaTestDefinition, AntaDevice]
|
||||||
|
|
||||||
|
# Environment variable to set ANTA's maximum number of open file descriptors.
|
||||||
|
# Maximum number of file descriptor the ANTA process will be able to open.
|
||||||
|
# This limit is independent from the system's hard limit, the lower will be used.
|
||||||
|
DEFAULT_NOFILE = 16384
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
async def main(manager: ResultManager, inventory: AntaInventory, catalog: AntaCatalog, tags: list[str] | None = None, established_only: bool = True) -> None:
|
def log_cache_statistics(devices: list[AntaDevice]) -> None:
|
||||||
"""
|
"""Log cache statistics for each device in the inventory.
|
||||||
Main coroutine to run ANTA.
|
|
||||||
Use this as an entrypoint to the test framwork in your script.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
|
devices: List of devices in the inventory.
|
||||||
|
"""
|
||||||
|
for device in devices:
|
||||||
|
if device.cache_statistics is not None:
|
||||||
|
msg = (
|
||||||
|
f"Cache statistics for '{device.name}': "
|
||||||
|
f"{device.cache_statistics['cache_hits']} hits / {device.cache_statistics['total_commands_sent']} "
|
||||||
|
f"command(s) ({device.cache_statistics['cache_hit_ratio']})"
|
||||||
|
)
|
||||||
|
logger.info(msg)
|
||||||
|
else:
|
||||||
|
logger.info("Caching is not enabled on %s", device.name)
|
||||||
|
|
||||||
|
|
||||||
|
async def main( # noqa: PLR0912 PLR0913 too-many-branches too-many-arguments - keep the main method readable
|
||||||
|
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,
|
||||||
|
) -> None:
|
||||||
|
# pylint: disable=too-many-arguments
|
||||||
|
"""Run ANTA.
|
||||||
|
|
||||||
|
Use this as an entrypoint to the test framework in your script.
|
||||||
|
ResultManager object gets updated with the test results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
----
|
||||||
manager: ResultManager object to populate with the test results.
|
manager: ResultManager object to populate with the test results.
|
||||||
inventory: AntaInventory object that includes the device(s).
|
inventory: AntaInventory object that includes the device(s).
|
||||||
catalog: AntaCatalog object that includes the list of tests.
|
catalog: AntaCatalog object that includes the list of tests.
|
||||||
tags: List of tags to filter devices from the inventory. Defaults to None.
|
devices: devices on which to run tests. None means all devices.
|
||||||
established_only: Include only established device(s). Defaults to True.
|
tests: tests to run against devices. None means all tests.
|
||||||
|
tags: Tags to filter devices from the inventory.
|
||||||
Returns:
|
established_only: Include only established device(s).
|
||||||
any: ResultManager object gets updated with the test results.
|
|
||||||
"""
|
"""
|
||||||
|
limits = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||||
|
logger.debug("Initial limit numbers for open file descriptors for the current ANTA process: Soft Limit: %s | Hard Limit: %s", limits[0], limits[1])
|
||||||
|
nofile = __NOFILE__ if limits[1] > __NOFILE__ else limits[1]
|
||||||
|
logger.debug("Setting soft limit for open file descriptors for the current ANTA process to %s", nofile)
|
||||||
|
resource.setrlimit(resource.RLIMIT_NOFILE, (nofile, limits[1]))
|
||||||
|
limits = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||||
|
|
||||||
if not catalog.tests:
|
if not catalog.tests:
|
||||||
logger.info("The list of tests is empty, exiting")
|
logger.info("The list of tests is empty, exiting")
|
||||||
return
|
return
|
||||||
if len(inventory) == 0:
|
if len(inventory) == 0:
|
||||||
logger.info("The inventory is empty, exiting")
|
logger.info("The inventory is empty, exiting")
|
||||||
return
|
return
|
||||||
await inventory.connect_inventory()
|
|
||||||
devices: list[AntaDevice] = list(inventory.get_inventory(established_only=established_only, tags=tags).values())
|
|
||||||
|
|
||||||
if not devices:
|
# Filter the inventory based on tags and devices parameters
|
||||||
logger.info(
|
selected_inventory = inventory.get_inventory(
|
||||||
f"No device in the established state '{established_only}' "
|
tags=tags,
|
||||||
f"{f'matching the tags {tags} ' if tags else ''}was found. There is no device to run tests against, exiting"
|
devices=devices,
|
||||||
)
|
)
|
||||||
|
await selected_inventory.connect_inventory()
|
||||||
|
|
||||||
|
# Remove devices that are unreachable
|
||||||
|
inventory = selected_inventory.get_inventory(established_only=established_only)
|
||||||
|
|
||||||
|
if not 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
|
return
|
||||||
coros = []
|
coros = []
|
||||||
|
|
||||||
|
# Select the tests from the catalog
|
||||||
|
if tests:
|
||||||
|
catalog = AntaCatalog(catalog.get_tests_by_names(tests))
|
||||||
|
|
||||||
# Using a set to avoid inserting duplicate tests
|
# Using a set to avoid inserting duplicate tests
|
||||||
tests_set: set[AntaTestRunner] = set()
|
selected_tests: set[AntaTestRunner] = set()
|
||||||
for device in devices:
|
|
||||||
|
# Create AntaTestRunner tuples from the tags
|
||||||
|
for device in inventory.devices:
|
||||||
if tags:
|
if tags:
|
||||||
# If there are CLI tags, only execute tests with matching tags
|
# If there are CLI tags, only execute tests with matching tags
|
||||||
tests_set.update((test, device) for test in catalog.get_tests_by_tags(tags))
|
selected_tests.update((test, device) for test in catalog.get_tests_by_tags(tags))
|
||||||
else:
|
else:
|
||||||
# If there is no CLI tags, execute all tests without filters
|
# If there is no CLI tags, execute all tests that do not have any filters
|
||||||
tests_set.update((t, device) for t in catalog.tests if t.inputs.filters is None or t.inputs.filters.tags is None)
|
selected_tests.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
|
# 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))
|
selected_tests.update((t, device) for t in catalog.get_tests_by_tags(device.tags))
|
||||||
|
|
||||||
tests: list[AntaTestRunner] = list(tests_set)
|
if not selected_tests:
|
||||||
|
msg = f"There is no tests{f' matching the tags {tags} ' if tags else ' '}to run in the current test catalog and device inventory, please verify your inputs."
|
||||||
if not tests:
|
logger.warning(msg)
|
||||||
logger.info(f"There is no tests{f' matching the tags {tags} ' if tags else ' '}to run on current inventory. " "Exiting...")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
for test_definition, device in tests:
|
run_info = (
|
||||||
|
"--- ANTA NRFU Run Information ---\n"
|
||||||
|
f"Number of devices: {len(selected_inventory)} ({len(inventory)} established)\n"
|
||||||
|
f"Total number of selected tests: {len(selected_tests)}\n"
|
||||||
|
f"Maximum number of open file descriptors for the current ANTA process: {limits[0]}\n"
|
||||||
|
"---------------------------------"
|
||||||
|
)
|
||||||
|
logger.info(run_info)
|
||||||
|
if len(selected_tests) > 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."
|
||||||
|
)
|
||||||
|
|
||||||
|
for test_definition, device in selected_tests:
|
||||||
try:
|
try:
|
||||||
test_instance = test_definition.test(device=device, inputs=test_definition.inputs)
|
test_instance = test_definition.test(device=device, inputs=test_definition.inputs)
|
||||||
|
|
||||||
|
@ -88,22 +164,16 @@ async def main(manager: ResultManager, inventory: AntaInventory, catalog: AntaCa
|
||||||
[
|
[
|
||||||
f"There is an error when creating test {test_definition.test.__module__}.{test_definition.test.__name__}.",
|
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}",
|
f"If this is not a custom test implementation: {GITHUB_SUGGESTION}",
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
anta_log_exception(e, message, logger)
|
anta_log_exception(e, message, logger)
|
||||||
|
|
||||||
if AntaTest.progress is not None:
|
if AntaTest.progress is not None:
|
||||||
AntaTest.nrfu_task = AntaTest.progress.add_task("Running NRFU Tests...", total=len(coros))
|
AntaTest.nrfu_task = AntaTest.progress.add_task("Running NRFU Tests...", total=len(coros))
|
||||||
|
|
||||||
logger.info("Running ANTA tests...")
|
logger.info("Running ANTA tests...")
|
||||||
test_results = await asyncio.gather(*coros)
|
test_results = await asyncio.gather(*coros)
|
||||||
for r in test_results:
|
for r in test_results:
|
||||||
manager.add_test_result(r)
|
manager.add(r)
|
||||||
for device in devices:
|
|
||||||
if device.cache_statistics is not None:
|
log_cache_statistics(inventory.devices)
|
||||||
logger.info(
|
|
||||||
f"Cache statistics for '{device.name}': "
|
|
||||||
f"{device.cache_statistics['cache_hits']} hits / {device.cache_statistics['total_commands_sent']} "
|
|
||||||
f"command(s) ({device.cache_statistics['cache_hit_ratio']})"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info(f"Caching is not enabled on {device.name}")
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
|
"""Module related to all ANTA tests."""
|
||||||
|
|
|
@ -1,44 +1,56 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to the EOS various AAA tests."""
|
||||||
Test functions related to the EOS various AAA settings
|
|
||||||
"""
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ipaddress import IPv4Address
|
from ipaddress import IPv4Address
|
||||||
|
from typing import TYPE_CHECKING, ClassVar, Literal
|
||||||
# Need to keep List and Set for pydantic in python 3.8
|
|
||||||
from typing import List, Literal, Set
|
|
||||||
|
|
||||||
from anta.custom_types import AAAAuthMethod
|
from anta.custom_types import AAAAuthMethod
|
||||||
from anta.models import AntaCommand, AntaTest
|
from anta.models import AntaCommand, AntaTest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.models import AntaTemplate
|
||||||
|
|
||||||
|
|
||||||
class VerifyTacacsSourceIntf(AntaTest):
|
class VerifyTacacsSourceIntf(AntaTest):
|
||||||
"""
|
"""Verifies TACACS source-interface for a specified VRF.
|
||||||
Verifies TACACS source-interface for a specified VRF.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the provided TACACS source-interface is configured in the specified VRF.
|
----------------
|
||||||
* failure: The test will fail if the provided TACACS source-interface is NOT configured in the specified VRF.
|
* Success: The test will pass if the provided TACACS source-interface is configured in the specified VRF.
|
||||||
|
* Failure: The test will fail if the provided TACACS source-interface is NOT configured in the specified VRF.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.aaa:
|
||||||
|
- VerifyTacacsSourceIntf:
|
||||||
|
intf: Management0
|
||||||
|
vrf: MGMT
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyTacacsSourceIntf"
|
name = "VerifyTacacsSourceIntf"
|
||||||
description = "Verifies TACACS source-interface for a specified VRF."
|
description = "Verifies TACACS source-interface for a specified VRF."
|
||||||
categories = ["aaa"]
|
categories: ClassVar[list[str]] = ["aaa"]
|
||||||
commands = [AntaCommand(command="show tacacs")]
|
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
|
intf: str
|
||||||
"""Source-interface to use as source IP of TACACS messages"""
|
"""Source-interface to use as source IP of TACACS messages."""
|
||||||
vrf: str = "default"
|
vrf: str = "default"
|
||||||
"""The name of the VRF to transport TACACS messages"""
|
"""The name of the VRF to transport TACACS messages. Defaults to `default`."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyTacacsSourceIntf."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
try:
|
try:
|
||||||
if command_output["srcIntf"][self.inputs.vrf] == self.inputs.intf:
|
if command_output["srcIntf"][self.inputs.vrf] == self.inputs.intf:
|
||||||
|
@ -50,27 +62,41 @@ class VerifyTacacsSourceIntf(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyTacacsServers(AntaTest):
|
class VerifyTacacsServers(AntaTest):
|
||||||
"""
|
"""Verifies TACACS servers are configured for a specified VRF.
|
||||||
Verifies TACACS servers are configured for a specified VRF.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the provided TACACS servers are configured in the specified VRF.
|
----------------
|
||||||
* failure: The test will fail if the provided TACACS servers are NOT configured in the specified VRF.
|
* Success: The test will pass if the provided TACACS servers are configured in the specified VRF.
|
||||||
|
* Failure: The test will fail if the provided TACACS servers are NOT configured in the specified VRF.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.aaa:
|
||||||
|
- VerifyTacacsServers:
|
||||||
|
servers:
|
||||||
|
- 10.10.10.21
|
||||||
|
- 10.10.10.22
|
||||||
|
vrf: MGMT
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyTacacsServers"
|
name = "VerifyTacacsServers"
|
||||||
description = "Verifies TACACS servers are configured for a specified VRF."
|
description = "Verifies TACACS servers are configured for a specified VRF."
|
||||||
categories = ["aaa"]
|
categories: ClassVar[list[str]] = ["aaa"]
|
||||||
commands = [AntaCommand(command="show tacacs")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
servers: List[IPv4Address]
|
"""Input model for the VerifyTacacsServers test."""
|
||||||
"""List of TACACS servers"""
|
|
||||||
|
servers: list[IPv4Address]
|
||||||
|
"""List of TACACS servers."""
|
||||||
vrf: str = "default"
|
vrf: str = "default"
|
||||||
"""The name of the VRF to transport TACACS messages"""
|
"""The name of the VRF to transport TACACS messages. Defaults to `default`."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyTacacsServers."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
tacacs_servers = command_output["tacacsServers"]
|
tacacs_servers = command_output["tacacsServers"]
|
||||||
if not tacacs_servers:
|
if not tacacs_servers:
|
||||||
|
@ -90,25 +116,38 @@ class VerifyTacacsServers(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyTacacsServerGroups(AntaTest):
|
class VerifyTacacsServerGroups(AntaTest):
|
||||||
"""
|
"""Verifies if the provided TACACS server group(s) are configured.
|
||||||
Verifies if the provided TACACS server group(s) are configured.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the provided TACACS server group(s) are configured.
|
----------------
|
||||||
* failure: The test will fail if one or all the provided TACACS server group(s) are NOT configured.
|
* Success: The test will pass if the provided TACACS server group(s) are configured.
|
||||||
|
* Failure: The test will fail if one or all the provided TACACS server group(s) are NOT configured.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.aaa:
|
||||||
|
- VerifyTacacsServerGroups:
|
||||||
|
groups:
|
||||||
|
- TACACS-GROUP1
|
||||||
|
- TACACS-GROUP2
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyTacacsServerGroups"
|
name = "VerifyTacacsServerGroups"
|
||||||
description = "Verifies if the provided TACACS server group(s) are configured."
|
description = "Verifies if the provided TACACS server group(s) are configured."
|
||||||
categories = ["aaa"]
|
categories: ClassVar[list[str]] = ["aaa"]
|
||||||
commands = [AntaCommand(command="show tacacs")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
groups: List[str]
|
"""Input model for the VerifyTacacsServerGroups test."""
|
||||||
"""List of TACACS server group"""
|
|
||||||
|
groups: list[str]
|
||||||
|
"""List of TACACS server groups."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyTacacsServerGroups."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
tacacs_groups = command_output["groups"]
|
tacacs_groups = command_output["groups"]
|
||||||
if not tacacs_groups:
|
if not tacacs_groups:
|
||||||
|
@ -122,29 +161,47 @@ class VerifyTacacsServerGroups(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyAuthenMethods(AntaTest):
|
class VerifyAuthenMethods(AntaTest):
|
||||||
"""
|
"""Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x).
|
||||||
Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x).
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the provided AAA authentication method list is matching in the configured authentication types.
|
----------------
|
||||||
* failure: The test will fail if the provided AAA authentication method list is NOT matching in the configured authentication types.
|
* Success: The test will pass if the provided AAA authentication method list is matching in the configured authentication types.
|
||||||
|
* Failure: The test will fail if the provided AAA authentication method list is NOT matching in the configured authentication types.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.aaa:
|
||||||
|
- VerifyAuthenMethods:
|
||||||
|
methods:
|
||||||
|
- local
|
||||||
|
- none
|
||||||
|
- logging
|
||||||
|
types:
|
||||||
|
- login
|
||||||
|
- enable
|
||||||
|
- dot1x
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyAuthenMethods"
|
name = "VerifyAuthenMethods"
|
||||||
description = "Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x)."
|
description = "Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x)."
|
||||||
categories = ["aaa"]
|
categories: ClassVar[list[str]] = ["aaa"]
|
||||||
commands = [AntaCommand(command="show aaa methods authentication")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authentication", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
methods: List[AAAAuthMethod]
|
"""Input model for the VerifyAuthenMethods test."""
|
||||||
"""List of AAA authentication methods. Methods should be in the right order"""
|
|
||||||
types: Set[Literal["login", "enable", "dot1x"]]
|
methods: list[AAAAuthMethod]
|
||||||
"""List of authentication types to verify"""
|
"""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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyAuthenMethods."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
not_matching = []
|
not_matching: list[str] = []
|
||||||
for k, v in command_output.items():
|
for k, v in command_output.items():
|
||||||
auth_type = k.replace("AuthenMethods", "")
|
auth_type = k.replace("AuthenMethods", "")
|
||||||
if auth_type not in self.inputs.types:
|
if auth_type not in self.inputs.types:
|
||||||
|
@ -157,9 +214,8 @@ class VerifyAuthenMethods(AntaTest):
|
||||||
if v["login"]["methods"] != self.inputs.methods:
|
if v["login"]["methods"] != self.inputs.methods:
|
||||||
self.result.is_failure(f"AAA authentication methods {self.inputs.methods} are not matching for login console")
|
self.result.is_failure(f"AAA authentication methods {self.inputs.methods} are not matching for login console")
|
||||||
return
|
return
|
||||||
for methods in v.values():
|
not_matching.extend(auth_type for methods in v.values() if methods["methods"] != self.inputs.methods)
|
||||||
if methods["methods"] != self.inputs.methods:
|
|
||||||
not_matching.append(auth_type)
|
|
||||||
if not not_matching:
|
if not not_matching:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
|
@ -167,37 +223,53 @@ class VerifyAuthenMethods(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyAuthzMethods(AntaTest):
|
class VerifyAuthzMethods(AntaTest):
|
||||||
"""
|
"""Verifies the AAA authorization method lists for different authorization types (commands, exec).
|
||||||
Verifies the AAA authorization method lists for different authorization types (commands, exec).
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the provided AAA authorization method list is matching in the configured authorization types.
|
----------------
|
||||||
* failure: The test will fail if the provided AAA authorization method list is NOT matching in the configured authorization types.
|
* Success: The test will pass if the provided AAA authorization method list is matching in the configured authorization types.
|
||||||
|
* Failure: The test will fail if the provided AAA authorization method list is NOT matching in the configured authorization types.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.aaa:
|
||||||
|
- VerifyAuthzMethods:
|
||||||
|
methods:
|
||||||
|
- local
|
||||||
|
- none
|
||||||
|
- logging
|
||||||
|
types:
|
||||||
|
- commands
|
||||||
|
- exec
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyAuthzMethods"
|
name = "VerifyAuthzMethods"
|
||||||
description = "Verifies the AAA authorization method lists for different authorization types (commands, exec)."
|
description = "Verifies the AAA authorization method lists for different authorization types (commands, exec)."
|
||||||
categories = ["aaa"]
|
categories: ClassVar[list[str]] = ["aaa"]
|
||||||
commands = [AntaCommand(command="show aaa methods authorization")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authorization", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
methods: List[AAAAuthMethod]
|
"""Input model for the VerifyAuthzMethods test."""
|
||||||
"""List of AAA authorization methods. Methods should be in the right order"""
|
|
||||||
types: Set[Literal["commands", "exec"]]
|
methods: list[AAAAuthMethod]
|
||||||
"""List of authorization types to verify"""
|
"""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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyAuthzMethods."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
not_matching = []
|
not_matching: list[str] = []
|
||||||
for k, v in command_output.items():
|
for k, v in command_output.items():
|
||||||
authz_type = k.replace("AuthzMethods", "")
|
authz_type = k.replace("AuthzMethods", "")
|
||||||
if authz_type not in self.inputs.types:
|
if authz_type not in self.inputs.types:
|
||||||
# We do not need to verify this accounting type
|
# We do not need to verify this accounting type
|
||||||
continue
|
continue
|
||||||
for methods in v.values():
|
not_matching.extend(authz_type for methods in v.values() if methods["methods"] != self.inputs.methods)
|
||||||
if methods["methods"] != self.inputs.methods:
|
|
||||||
not_matching.append(authz_type)
|
|
||||||
if not not_matching:
|
if not not_matching:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
|
@ -205,27 +277,46 @@ class VerifyAuthzMethods(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyAcctDefaultMethods(AntaTest):
|
class VerifyAcctDefaultMethods(AntaTest):
|
||||||
"""
|
"""Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x).
|
||||||
Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x).
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the provided AAA accounting default method list is matching in the configured accounting types.
|
----------------
|
||||||
* failure: The test will fail if the provided AAA accounting default method list is NOT matching in the configured accounting types.
|
* Success: The test will pass if the provided AAA accounting default method list is matching in the configured accounting types.
|
||||||
|
* Failure: The test will fail if the provided AAA accounting default method list is NOT matching in the configured accounting types.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.aaa:
|
||||||
|
- VerifyAcctDefaultMethods:
|
||||||
|
methods:
|
||||||
|
- local
|
||||||
|
- none
|
||||||
|
- logging
|
||||||
|
types:
|
||||||
|
- system
|
||||||
|
- exec
|
||||||
|
- commands
|
||||||
|
- dot1x
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyAcctDefaultMethods"
|
name = "VerifyAcctDefaultMethods"
|
||||||
description = "Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x)."
|
description = "Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x)."
|
||||||
categories = ["aaa"]
|
categories: ClassVar[list[str]] = ["aaa"]
|
||||||
commands = [AntaCommand(command="show aaa methods accounting")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
methods: List[AAAAuthMethod]
|
"""Input model for the VerifyAcctDefaultMethods test."""
|
||||||
"""List of AAA accounting methods. Methods should be in the right order"""
|
|
||||||
types: Set[Literal["commands", "exec", "system", "dot1x"]]
|
methods: list[AAAAuthMethod]
|
||||||
"""List of accounting types to verify"""
|
"""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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyAcctDefaultMethods."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
not_matching = []
|
not_matching = []
|
||||||
not_configured = []
|
not_configured = []
|
||||||
|
@ -249,27 +340,46 @@ class VerifyAcctDefaultMethods(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyAcctConsoleMethods(AntaTest):
|
class VerifyAcctConsoleMethods(AntaTest):
|
||||||
"""
|
"""Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x).
|
||||||
Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x).
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the provided AAA accounting console method list is matching in the configured accounting types.
|
----------------
|
||||||
* failure: The test will fail if the provided AAA accounting console method list is NOT matching in the configured accounting types.
|
* Success: The test will pass if the provided AAA accounting console method list is matching in the configured accounting types.
|
||||||
|
* Failure: The test will fail if the provided AAA accounting console method list is NOT matching in the configured accounting types.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.aaa:
|
||||||
|
- VerifyAcctConsoleMethods:
|
||||||
|
methods:
|
||||||
|
- local
|
||||||
|
- none
|
||||||
|
- logging
|
||||||
|
types:
|
||||||
|
- system
|
||||||
|
- exec
|
||||||
|
- commands
|
||||||
|
- dot1x
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyAcctConsoleMethods"
|
name = "VerifyAcctConsoleMethods"
|
||||||
description = "Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x)."
|
description = "Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x)."
|
||||||
categories = ["aaa"]
|
categories: ClassVar[list[str]] = ["aaa"]
|
||||||
commands = [AntaCommand(command="show aaa methods accounting")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
methods: List[AAAAuthMethod]
|
"""Input model for the VerifyAcctConsoleMethods test."""
|
||||||
"""List of AAA accounting console methods. Methods should be in the right order"""
|
|
||||||
types: Set[Literal["commands", "exec", "system", "dot1x"]]
|
methods: list[AAAAuthMethod]
|
||||||
"""List of accounting console types to verify"""
|
"""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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyAcctConsoleMethods."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
not_matching = []
|
not_matching = []
|
||||||
not_configured = []
|
not_configured = []
|
||||||
|
|
|
@ -1,65 +1,80 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to BFD tests."""
|
||||||
BFD test functions
|
|
||||||
"""
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from ipaddress import IPv4Address
|
from ipaddress import IPv4Address
|
||||||
from typing import Any, List, Optional
|
from typing import TYPE_CHECKING, Any, ClassVar
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from anta.custom_types import BfdInterval, BfdMultiplier
|
from anta.custom_types import BfdInterval, BfdMultiplier
|
||||||
from anta.models import AntaCommand, AntaTest
|
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):
|
class VerifyBFDSpecificPeers(AntaTest):
|
||||||
"""
|
"""Verifies if the IPv4 BFD peer's sessions are UP and remote disc is non-zero in the specified VRF.
|
||||||
This class verifies if the IPv4 BFD peer's sessions are UP and remote disc is non-zero in the specified VRF.
|
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* success: The test will pass if IPv4 BFD peers are up and remote disc is non-zero in the specified VRF.
|
----------------
|
||||||
* 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.
|
* 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.
|
||||||
|
|
||||||
|
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"
|
name = "VerifyBFDSpecificPeers"
|
||||||
description = "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF."
|
description = "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF."
|
||||||
categories = ["bfd"]
|
categories: ClassVar[list[str]] = ["bfd"]
|
||||||
commands = [AntaCommand(command="show bfd peers")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers", revision=4)]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
class Input(AntaTest.Input):
|
||||||
"""
|
"""Input model for the VerifyBFDSpecificPeers test."""
|
||||||
This class defines the input parameters of the test case.
|
|
||||||
"""
|
|
||||||
|
|
||||||
bfd_peers: List[BFDPeers]
|
bfd_peers: list[BFDPeer]
|
||||||
"""List of IPv4 BFD peers"""
|
"""List of IPv4 BFD peers."""
|
||||||
|
|
||||||
class BFDPeers(BaseModel):
|
class BFDPeer(BaseModel):
|
||||||
"""
|
"""Model for an IPv4 BFD peer."""
|
||||||
This class defines the details of an IPv4 BFD peer.
|
|
||||||
"""
|
|
||||||
|
|
||||||
peer_address: IPv4Address
|
peer_address: IPv4Address
|
||||||
"""IPv4 address of a BFD peer"""
|
"""IPv4 address of a BFD peer."""
|
||||||
vrf: str = "default"
|
vrf: str = "default"
|
||||||
"""Optional VRF for BGP peer. If not provided, it defaults to `default`."""
|
"""Optional VRF for BFD peer. If not provided, it defaults to `default`."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyBFDSpecificPeers."""
|
||||||
failures: dict[Any, Any] = {}
|
failures: dict[Any, Any] = {}
|
||||||
|
|
||||||
# Iterating over BFD peers
|
# Iterating over BFD peers
|
||||||
for bfd_peer in self.inputs.bfd_peers:
|
for bfd_peer in self.inputs.bfd_peers:
|
||||||
peer = str(bfd_peer.peer_address)
|
peer = str(bfd_peer.peer_address)
|
||||||
vrf = bfd_peer.vrf
|
vrf = bfd_peer.vrf
|
||||||
bfd_output = get_value(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
|
# Check if BFD peer configured
|
||||||
if not bfd_output:
|
if not bfd_output:
|
||||||
|
@ -68,7 +83,12 @@ class VerifyBFDSpecificPeers(AntaTest):
|
||||||
|
|
||||||
# Check BFD peer status and remote disc
|
# Check BFD peer status and remote disc
|
||||||
if not (bfd_output.get("status") == "up" and bfd_output.get("remoteDisc") != 0):
|
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")}}
|
failures[peer] = {
|
||||||
|
vrf: {
|
||||||
|
"status": bfd_output.get("status"),
|
||||||
|
"remote_disc": bfd_output.get("remoteDisc"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if not failures:
|
if not failures:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
@ -77,45 +97,60 @@ class VerifyBFDSpecificPeers(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyBFDPeersIntervals(AntaTest):
|
class VerifyBFDPeersIntervals(AntaTest):
|
||||||
"""
|
"""Verifies the timers of the IPv4 BFD peers in the specified VRF.
|
||||||
This class verifies the timers of the IPv4 BFD peers in the specified VRF.
|
|
||||||
|
|
||||||
Expected results:
|
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.
|
* 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.
|
||||||
|
|
||||||
|
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"
|
name = "VerifyBFDPeersIntervals"
|
||||||
description = "Verifies the timers of the IPv4 BFD peers in the specified VRF."
|
description = "Verifies the timers of the IPv4 BFD peers in the specified VRF."
|
||||||
categories = ["bfd"]
|
categories: ClassVar[list[str]] = ["bfd"]
|
||||||
commands = [AntaCommand(command="show bfd peers detail")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=4)]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
class Input(AntaTest.Input):
|
||||||
"""
|
"""Input model for the VerifyBFDPeersIntervals test."""
|
||||||
This class defines the input parameters of the test case.
|
|
||||||
"""
|
|
||||||
|
|
||||||
bfd_peers: List[BFDPeers]
|
bfd_peers: list[BFDPeer]
|
||||||
"""List of BFD peers"""
|
"""List of BFD peers."""
|
||||||
|
|
||||||
class BFDPeers(BaseModel):
|
class BFDPeer(BaseModel):
|
||||||
"""
|
"""Model for an IPv4 BFD peer."""
|
||||||
This class defines the details of an IPv4 BFD peer.
|
|
||||||
"""
|
|
||||||
|
|
||||||
peer_address: IPv4Address
|
peer_address: IPv4Address
|
||||||
"""IPv4 address of a BFD peer"""
|
"""IPv4 address of a BFD peer."""
|
||||||
vrf: str = "default"
|
vrf: str = "default"
|
||||||
"""Optional VRF for BGP peer. If not provided, it defaults to `default`."""
|
"""Optional VRF for BFD peer. If not provided, it defaults to `default`."""
|
||||||
tx_interval: BfdInterval
|
tx_interval: BfdInterval
|
||||||
"""Tx interval of BFD peer in milliseconds"""
|
"""Tx interval of BFD peer in milliseconds."""
|
||||||
rx_interval: BfdInterval
|
rx_interval: BfdInterval
|
||||||
"""Rx interval of BFD peer in milliseconds"""
|
"""Rx interval of BFD peer in milliseconds."""
|
||||||
multiplier: BfdMultiplier
|
multiplier: BfdMultiplier
|
||||||
"""Multiplier of BFD peer"""
|
"""Multiplier of BFD peer."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyBFDPeersIntervals."""
|
||||||
failures: dict[Any, Any] = {}
|
failures: dict[Any, Any] = {}
|
||||||
|
|
||||||
# Iterating over BFD peers
|
# Iterating over BFD peers
|
||||||
|
@ -127,7 +162,11 @@ class VerifyBFDPeersIntervals(AntaTest):
|
||||||
tx_interval = bfd_peers.tx_interval * 1000
|
tx_interval = bfd_peers.tx_interval * 1000
|
||||||
rx_interval = bfd_peers.rx_interval * 1000
|
rx_interval = bfd_peers.rx_interval * 1000
|
||||||
multiplier = bfd_peers.multiplier
|
multiplier = bfd_peers.multiplier
|
||||||
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
|
# Check if BFD peer configured
|
||||||
if not bfd_output:
|
if not bfd_output:
|
||||||
|
@ -157,35 +196,46 @@ class VerifyBFDPeersIntervals(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyBFDPeersHealth(AntaTest):
|
class VerifyBFDPeersHealth(AntaTest):
|
||||||
"""
|
"""Verifies the health of IPv4 BFD peers across all VRFs.
|
||||||
This class verifies the health of IPv4 BFD peers across all VRFs.
|
|
||||||
|
|
||||||
It checks that no BFD peer is in the down state and that the discriminator value of the remote system is not zero.
|
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.
|
Optionally, it can also verify that BFD peers have not been down before a specified threshold of hours.
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* Success: The test will pass if all IPv4 BFD peers are up, the discriminator value of each remote system is non-zero,
|
----------------
|
||||||
and the last downtime of each peer is above the defined threshold.
|
* Success: The test will pass if all IPv4 BFD peers are up, the discriminator value of each remote system is non-zero,
|
||||||
* Failure: The test will fail if any IPv4 BFD peer is down, the discriminator value of any remote system is zero,
|
and the last downtime of each peer is above the defined threshold.
|
||||||
or the last downtime of any peer is below 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.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.bfd:
|
||||||
|
- VerifyBFDPeersHealth:
|
||||||
|
down_threshold: 2
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyBFDPeersHealth"
|
name = "VerifyBFDPeersHealth"
|
||||||
description = "Verifies the health of all IPv4 BFD peers."
|
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
|
# 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):
|
class Input(AntaTest.Input):
|
||||||
"""
|
"""Input model for the VerifyBFDPeersHealth test."""
|
||||||
This class defines the input parameters of the test case.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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."""
|
"""Optional down threshold in hours to check if a BFD peer was down before those hours or not."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyBFDPeersHealth."""
|
||||||
# Initialize failure strings
|
# Initialize failure strings
|
||||||
down_failures = []
|
down_failures = []
|
||||||
up_failures = []
|
up_failures = []
|
||||||
|
@ -212,7 +262,9 @@ class VerifyBFDPeersHealth(AntaTest):
|
||||||
remote_disc = peer_data["remoteDisc"]
|
remote_disc = peer_data["remoteDisc"]
|
||||||
remote_disc_info = f" with remote disc {remote_disc}" if remote_disc == 0 else ""
|
remote_disc_info = f" with remote disc {remote_disc}" if remote_disc == 0 else ""
|
||||||
last_down = peer_data["lastDown"]
|
last_down = peer_data["lastDown"]
|
||||||
hours_difference = (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
|
# Check if peer status is not up
|
||||||
if peer_status != "up":
|
if peer_status != "up":
|
||||||
|
|
|
@ -1,30 +1,45 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to the device configuration tests."""
|
||||||
Test functions related to the device configuration
|
|
||||||
"""
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
from anta.models import AntaCommand, AntaTest
|
from anta.models import AntaCommand, AntaTest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.models import AntaTemplate
|
||||||
|
|
||||||
|
|
||||||
class VerifyZeroTouch(AntaTest):
|
class VerifyZeroTouch(AntaTest):
|
||||||
"""
|
"""Verifies ZeroTouch is disabled.
|
||||||
Verifies ZeroTouch is disabled
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if ZeroTouch is disabled.
|
||||||
|
* Failure: The test will fail if ZeroTouch is enabled.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.configuration:
|
||||||
|
- VerifyZeroTouch:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyZeroTouch"
|
name = "VerifyZeroTouch"
|
||||||
description = "Verifies ZeroTouch is disabled"
|
description = "Verifies ZeroTouch is disabled"
|
||||||
categories = ["configuration"]
|
categories: ClassVar[list[str]] = ["configuration"]
|
||||||
commands = [AntaCommand(command="show zerotouch")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show zerotouch", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
command_output = self.instance_commands[0].output
|
"""Main test function for VerifyZeroTouch."""
|
||||||
assert isinstance(command_output, dict)
|
command_output = self.instance_commands[0].json_output
|
||||||
if command_output["mode"] == "disabled":
|
if command_output["mode"] == "disabled":
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
|
@ -32,20 +47,31 @@ class VerifyZeroTouch(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyRunningConfigDiffs(AntaTest):
|
class VerifyRunningConfigDiffs(AntaTest):
|
||||||
"""
|
"""Verifies there is no difference between the running-config and the startup-config.
|
||||||
Verifies there is no difference between the running-config and the startup-config
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if there is no difference between the running-config and the startup-config.
|
||||||
|
* Failure: The test will fail if there is a difference between the running-config and the startup-config.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.configuration:
|
||||||
|
- VerifyRunningConfigDiffs:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyRunningConfigDiffs"
|
name = "VerifyRunningConfigDiffs"
|
||||||
description = "Verifies there is no difference between the running-config and the startup-config"
|
description = "Verifies there is no difference between the running-config and the startup-config"
|
||||||
categories = ["configuration"]
|
categories: ClassVar[list[str]] = ["configuration"]
|
||||||
commands = [AntaCommand(command="show running-config diffs", ofmt="text")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config diffs", ofmt="text")]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
command_output = self.instance_commands[0].output
|
"""Main test function for VerifyRunningConfigDiffs."""
|
||||||
if command_output is None or command_output == "":
|
command_output = self.instance_commands[0].text_output
|
||||||
|
if command_output == "":
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
self.result.is_failure()
|
self.result.is_failure(command_output)
|
||||||
self.result.is_failure(str(command_output))
|
|
||||||
|
|
|
@ -1,67 +1,79 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to various connectivity tests."""
|
||||||
Test functions related to various connectivity checks
|
|
||||||
"""
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ipaddress import IPv4Address
|
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 pydantic import BaseModel
|
||||||
|
|
||||||
from anta.custom_types import Interface
|
from anta.custom_types import Interface
|
||||||
from anta.models import AntaCommand, AntaMissingParamException, AntaTemplate, AntaTest
|
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||||
|
|
||||||
|
|
||||||
class VerifyReachability(AntaTest):
|
class VerifyReachability(AntaTest):
|
||||||
"""
|
"""Test network reachability to one or many destination IP(s).
|
||||||
Test network reachability to one or many destination IP(s).
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if all destination IP(s) are reachable.
|
----------------
|
||||||
* failure: The test will fail if one or many destination IP(s) are unreachable.
|
* 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
|
||||||
|
- source: Management0
|
||||||
|
destination: 8.8.8.8
|
||||||
|
vrf: MGMT
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyReachability"
|
name = "VerifyReachability"
|
||||||
description = "Test the network reachability to one or many destination IP(s)."
|
description = "Test the network reachability to one or many destination IP(s)."
|
||||||
categories = ["connectivity"]
|
categories: ClassVar[list[str]] = ["connectivity"]
|
||||||
commands = [AntaTemplate(template="ping vrf {vrf} {destination} source {source} repeat {repeat}")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="ping vrf {vrf} {destination} source {source} repeat {repeat}", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
hosts: List[Host]
|
"""Input model for the VerifyReachability test."""
|
||||||
"""List of hosts to ping"""
|
|
||||||
|
hosts: list[Host]
|
||||||
|
"""List of host to ping."""
|
||||||
|
|
||||||
class Host(BaseModel):
|
class Host(BaseModel):
|
||||||
"""Remote host to ping"""
|
"""Model for a remote host to ping."""
|
||||||
|
|
||||||
destination: IPv4Address
|
destination: IPv4Address
|
||||||
"""IPv4 address to ping"""
|
"""IPv4 address to ping."""
|
||||||
source: Union[IPv4Address, Interface]
|
source: IPv4Address | Interface
|
||||||
"""IPv4 address source IP or Egress interface to use"""
|
"""IPv4 address source IP or egress interface to use."""
|
||||||
vrf: str = "default"
|
vrf: str = "default"
|
||||||
"""VRF context"""
|
"""VRF context. Defaults to `default`."""
|
||||||
repeat: int = 2
|
repeat: int = 2
|
||||||
"""Number of ping repetition (default=2)"""
|
"""Number of ping repetition. Defaults to 2."""
|
||||||
|
|
||||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||||
|
"""Render the template for each host in the input list."""
|
||||||
return [template.render(destination=host.destination, source=host.source, vrf=host.vrf, repeat=host.repeat) for host in self.inputs.hosts]
|
return [template.render(destination=host.destination, source=host.source, vrf=host.vrf, repeat=host.repeat) for host in self.inputs.hosts]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyReachability."""
|
||||||
failures = []
|
failures = []
|
||||||
for command in self.instance_commands:
|
for command in self.instance_commands:
|
||||||
src = command.params.get("source")
|
src = command.params.source
|
||||||
dst = command.params.get("destination")
|
dst = command.params.destination
|
||||||
repeat = command.params.get("repeat")
|
repeat = command.params.repeat
|
||||||
|
|
||||||
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]:
|
if f"{repeat} received" not in command.json_output["messages"][0]:
|
||||||
failures.append((str(src), str(dst)))
|
failures.append((str(src), str(dst)))
|
||||||
|
@ -73,53 +85,84 @@ class VerifyReachability(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyLLDPNeighbors(AntaTest):
|
class VerifyLLDPNeighbors(AntaTest):
|
||||||
"""
|
"""Verifies that the provided LLDP neighbors are present and connected with the correct configuration.
|
||||||
This test verifies that the provided LLDP neighbors are present and connected with the correct configuration.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if each of the provided LLDP neighbors is present and connected to the specified port and device.
|
----------------
|
||||||
* failure: The test will fail if any of the following conditions are met:
|
* Success: The test will pass if each of the provided LLDP neighbors is present and connected to the specified port and device.
|
||||||
- The provided LLDP neighbor is not found.
|
* Failure: The test will fail if any of the following conditions are met:
|
||||||
- The system name or port of the LLDP neighbor does not match the provided information.
|
- The provided LLDP neighbor is not found.
|
||||||
|
- The system name or port of the LLDP neighbor does not match the provided 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"
|
name = "VerifyLLDPNeighbors"
|
||||||
description = "Verifies that the provided LLDP neighbors are connected properly."
|
description = "Verifies that the provided LLDP neighbors are connected properly."
|
||||||
categories = ["connectivity"]
|
categories: ClassVar[list[str]] = ["connectivity"]
|
||||||
commands = [AntaCommand(command="show lldp neighbors detail")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lldp neighbors detail", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
neighbors: List[Neighbor]
|
"""Input model for the VerifyLLDPNeighbors test."""
|
||||||
"""List of LLDP neighbors"""
|
|
||||||
|
neighbors: list[Neighbor]
|
||||||
|
"""List of LLDP neighbors."""
|
||||||
|
|
||||||
class Neighbor(BaseModel):
|
class Neighbor(BaseModel):
|
||||||
"""LLDP neighbor"""
|
"""Model for an LLDP neighbor."""
|
||||||
|
|
||||||
port: Interface
|
port: Interface
|
||||||
"""LLDP port"""
|
"""LLDP port."""
|
||||||
neighbor_device: str
|
neighbor_device: str
|
||||||
"""LLDP neighbor device"""
|
"""LLDP neighbor device."""
|
||||||
neighbor_port: Interface
|
neighbor_port: Interface
|
||||||
"""LLDP neighbor port"""
|
"""LLDP neighbor port."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
command_output = self.instance_commands[0].json_output
|
"""Main test function for VerifyLLDPNeighbors."""
|
||||||
|
|
||||||
failures: dict[str, list[str]] = {}
|
failures: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
output = self.instance_commands[0].json_output["lldpNeighbors"]
|
||||||
|
|
||||||
for neighbor in self.inputs.neighbors:
|
for neighbor in self.inputs.neighbors:
|
||||||
if neighbor.port not in command_output["lldpNeighbors"]:
|
if neighbor.port not in output:
|
||||||
failures.setdefault("port_not_configured", []).append(neighbor.port)
|
failures.setdefault("Port(s) not configured", []).append(neighbor.port)
|
||||||
elif len(lldp_neighbor_info := command_output["lldpNeighbors"][neighbor.port]["lldpNeighborInfo"]) == 0:
|
continue
|
||||||
failures.setdefault("no_lldp_neighbor", []).append(neighbor.port)
|
|
||||||
elif (
|
if len(lldp_neighbor_info := output[neighbor.port]["lldpNeighborInfo"]) == 0:
|
||||||
lldp_neighbor_info[0]["systemName"] != neighbor.neighbor_device
|
failures.setdefault("No LLDP neighbor(s) on port(s)", []).append(neighbor.port)
|
||||||
or lldp_neighbor_info[0]["neighborInterfaceInfo"]["interfaceId_v2"] != neighbor.neighbor_port
|
continue
|
||||||
|
|
||||||
|
if not any(
|
||||||
|
info["systemName"] == neighbor.neighbor_device and info["neighborInterfaceInfo"]["interfaceId_v2"] == neighbor.neighbor_port
|
||||||
|
for info in lldp_neighbor_info
|
||||||
):
|
):
|
||||||
failures.setdefault("wrong_lldp_neighbor", []).append(neighbor.port)
|
neighbors = "\n ".join(
|
||||||
|
[
|
||||||
|
f"{neighbor[0]}_{neighbor[1]}"
|
||||||
|
for neighbor in [(info["systemName"], info["neighborInterfaceInfo"]["interfaceId_v2"]) for info in lldp_neighbor_info]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
failures.setdefault("Wrong LLDP neighbor(s) on port(s)", []).append(f"{neighbor.port}\n {neighbors}")
|
||||||
|
|
||||||
if not failures:
|
if not failures:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
self.result.is_failure(f"The following port(s) have issues: {failures}")
|
failure_messages = []
|
||||||
|
for failure_type, ports in failures.items():
|
||||||
|
ports_str = "\n ".join(ports)
|
||||||
|
failure_messages.append(f"{failure_type}:\n {ports_str}")
|
||||||
|
self.result.is_failure("\n".join(failure_messages))
|
||||||
|
|
|
@ -1,33 +1,48 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to field notices tests."""
|
||||||
Test functions to flag field notices
|
|
||||||
"""
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
from anta.decorators import skip_on_platforms
|
from anta.decorators import skip_on_platforms
|
||||||
from anta.models import AntaCommand, AntaTest
|
from anta.models import AntaCommand, AntaTest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.models import AntaTemplate
|
||||||
|
|
||||||
|
|
||||||
class VerifyFieldNotice44Resolution(AntaTest):
|
class VerifyFieldNotice44Resolution(AntaTest):
|
||||||
"""
|
"""Verifies if the device is using an Aboot version that fixes the bug discussed in the Field Notice 44.
|
||||||
Verifies the device is using an Aboot version that fix the bug discussed
|
|
||||||
in the field notice 44 (Aboot manages system settings prior to EOS initialization).
|
|
||||||
|
|
||||||
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"
|
name = "VerifyFieldNotice44Resolution"
|
||||||
description = (
|
description = "Verifies that the device is using the correct Aboot version per FN0044."
|
||||||
"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: ClassVar[list[str]] = ["field notices"]
|
||||||
)
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)]
|
||||||
categories = ["field notices", "software"]
|
|
||||||
commands = [AntaCommand(command="show version detail")]
|
|
||||||
|
|
||||||
# TODO maybe implement ONLY ON PLATFORMS instead
|
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyFieldNotice44Resolution."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
|
|
||||||
devices = [
|
devices = [
|
||||||
|
@ -79,7 +94,6 @@ class VerifyFieldNotice44Resolution(AntaTest):
|
||||||
variants = ["-SSD-F", "-SSD-R", "-M-F", "-M-R", "-F", "-R"]
|
variants = ["-SSD-F", "-SSD-R", "-M-F", "-M-R", "-F", "-R"]
|
||||||
|
|
||||||
model = command_output["modelName"]
|
model = command_output["modelName"]
|
||||||
# TODO this list could be a regex
|
|
||||||
for variant in variants:
|
for variant in variants:
|
||||||
model = model.replace(variant, "")
|
model = model.replace(variant, "")
|
||||||
if model not in devices:
|
if model not in devices:
|
||||||
|
@ -90,32 +104,49 @@ class VerifyFieldNotice44Resolution(AntaTest):
|
||||||
if component["name"] == "Aboot":
|
if component["name"] == "Aboot":
|
||||||
aboot_version = component["version"].split("-")[2]
|
aboot_version = component["version"].split("-")[2]
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
if aboot_version.startswith("4.0.") and int(aboot_version.split(".")[2]) < 7:
|
incorrect_aboot_version = (
|
||||||
self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
|
aboot_version.startswith("4.0.")
|
||||||
elif aboot_version.startswith("4.1.") and int(aboot_version.split(".")[2]) < 1:
|
and int(aboot_version.split(".")[2]) < 7
|
||||||
self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
|
or aboot_version.startswith("4.1.")
|
||||||
elif aboot_version.startswith("6.0.") and int(aboot_version.split(".")[2]) < 9:
|
and int(aboot_version.split(".")[2]) < 1
|
||||||
self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
|
or (
|
||||||
elif aboot_version.startswith("6.1.") and int(aboot_version.split(".")[2]) < 7:
|
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})")
|
self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
|
||||||
|
|
||||||
|
|
||||||
class VerifyFieldNotice72Resolution(AntaTest):
|
class VerifyFieldNotice72Resolution(AntaTest):
|
||||||
"""
|
"""Verifies if the device is potentially exposed to Field Notice 72, and if the issue has been mitigated.
|
||||||
Checks if the device is potentially exposed to Field Notice 72, and if the issue has been mitigated.
|
|
||||||
|
|
||||||
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"
|
name = "VerifyFieldNotice72Resolution"
|
||||||
description = "Verifies if the device has exposeure to FN72, and if the issue has been mitigated"
|
description = "Verifies if the device is exposed to FN0072, and if the issue has been mitigated."
|
||||||
categories = ["field notices", "software"]
|
categories: ClassVar[list[str]] = ["field notices"]
|
||||||
commands = [AntaCommand(command="show version detail")]
|
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", "cEOSCloudLab"])
|
||||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyFieldNotice72Resolution."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
|
|
||||||
devices = ["DCS-7280SR3-48YC8", "DCS-7280SR3K-48YC8"]
|
devices = ["DCS-7280SR3-48YC8", "DCS-7280SR3K-48YC8"]
|
||||||
|
@ -151,8 +182,7 @@ class VerifyFieldNotice72Resolution(AntaTest):
|
||||||
self.result.is_skipped("Device not exposed")
|
self.result.is_skipped("Device not exposed")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Because each of the if checks above will return if taken, we only run the long
|
# Because each of the if checks above will return if taken, we only run the long check if we get this far
|
||||||
# check if we get this far
|
|
||||||
for entry in command_output["details"]["components"]:
|
for entry in command_output["details"]["components"]:
|
||||||
if entry["name"] == "FixedSystemvrm1":
|
if entry["name"] == "FixedSystemvrm1":
|
||||||
if int(entry["version"]) < 7:
|
if int(entry["version"]) < 7:
|
||||||
|
@ -161,5 +191,5 @@ class VerifyFieldNotice72Resolution(AntaTest):
|
||||||
self.result.is_success("FN72 is mitigated")
|
self.result.is_success("FN72 is mitigated")
|
||||||
return
|
return
|
||||||
# We should never hit this point
|
# We should never hit this point
|
||||||
self.result.is_error(message="Error in running test - FixedSystemvrm1 not found")
|
self.result.is_error("Error in running test - FixedSystemvrm1 not found")
|
||||||
return
|
return
|
||||||
|
|
|
@ -1,60 +1,79 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to GreenT (Postcard Telemetry) tests."""
|
||||||
Test functions related to GreenT (Postcard Telemetry) in EOS
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
from anta.models import AntaCommand, AntaTest
|
from anta.models import AntaCommand, AntaTest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.models import AntaTemplate
|
||||||
|
|
||||||
|
|
||||||
class VerifyGreenTCounters(AntaTest):
|
class VerifyGreenTCounters(AntaTest):
|
||||||
"""
|
"""Verifies if the GreenT (GRE Encapsulated Telemetry) counters are incremented.
|
||||||
Verifies whether GRE packets are sent.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: if >0 gre packets are sent
|
----------------
|
||||||
* failure: if no gre packets are sent
|
* 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:
|
||||||
|
- VerifyGreenT:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyGreenTCounters"
|
name = "VerifyGreenTCounters"
|
||||||
description = "Verifies if the greent counters are incremented."
|
description = "Verifies if the GreenT counters are incremented."
|
||||||
categories = ["greent"]
|
categories: ClassVar[list[str]] = ["greent"]
|
||||||
commands = [AntaCommand(command="show monitor telemetry postcard counters")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard counters", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyGreenTCounters."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
|
|
||||||
if command_output["grePktSent"] > 0:
|
if command_output["grePktSent"] > 0:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
self.result.is_failure("GRE packets are not sent")
|
self.result.is_failure("GreenT counters are not incremented")
|
||||||
|
|
||||||
|
|
||||||
class VerifyGreenT(AntaTest):
|
class VerifyGreenT(AntaTest):
|
||||||
"""
|
"""Verifies if a GreenT (GRE Encapsulated Telemetry) policy other than the default is created.
|
||||||
Verifies whether GreenT policy is created.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: if there exists any policy other than "default" policy.
|
----------------
|
||||||
* failure: if no policy is created.
|
* 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:
|
||||||
|
- VerifyGreenTCounters:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyGreenT"
|
name = "VerifyGreenT"
|
||||||
description = "Verifies whether greent policy is created."
|
description = "Verifies if a GreenT policy is created."
|
||||||
categories = ["greent"]
|
categories: ClassVar[list[str]] = ["greent"]
|
||||||
commands = [AntaCommand(command="show monitor telemetry postcard policy profile")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard policy profile", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyGreenT."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
|
|
||||||
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:
|
if profiles:
|
||||||
for i in out:
|
self.result.is_success()
|
||||||
self.result.is_success(f"{i} policy is created")
|
|
||||||
else:
|
else:
|
||||||
self.result.is_failure("policy is not created")
|
self.result.is_failure("No GreenT policy is created")
|
||||||
|
|
|
@ -1,41 +1,56 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to the hardware or environment tests."""
|
||||||
Test functions related to the hardware or environment
|
|
||||||
"""
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# Need to keep List for pydantic in python 3.8
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from anta.decorators import skip_on_platforms
|
from anta.decorators import skip_on_platforms
|
||||||
from anta.models import AntaCommand, AntaTest
|
from anta.models import AntaCommand, AntaTest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.models import AntaTemplate
|
||||||
|
|
||||||
|
|
||||||
class VerifyTransceiversManufacturers(AntaTest):
|
class VerifyTransceiversManufacturers(AntaTest):
|
||||||
"""
|
"""Verifies if all the transceivers come from approved manufacturers.
|
||||||
This test verifies if all the transceivers come from approved manufacturers.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if all transceivers are from approved manufacturers.
|
----------------
|
||||||
* failure: The test will fail if some transceivers are from unapproved manufacturers.
|
* Success: The test will pass if all transceivers are from approved manufacturers.
|
||||||
|
* Failure: The test will fail if some transceivers are from unapproved manufacturers.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.hardware:
|
||||||
|
- VerifyTransceiversManufacturers:
|
||||||
|
manufacturers:
|
||||||
|
- Not Present
|
||||||
|
- Arista Networks
|
||||||
|
- Arastra, Inc.
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyTransceiversManufacturers"
|
name = "VerifyTransceiversManufacturers"
|
||||||
description = "Verifies if all transceivers come from approved manufacturers."
|
description = "Verifies if all transceivers come from approved manufacturers."
|
||||||
categories = ["hardware"]
|
categories: ClassVar[list[str]] = ["hardware"]
|
||||||
commands = [AntaCommand(command="show inventory", ofmt="json")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show inventory", revision=2)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
manufacturers: List[str]
|
"""Input model for the VerifyTransceiversManufacturers test."""
|
||||||
"""List of approved transceivers manufacturers"""
|
|
||||||
|
|
||||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
manufacturers: list[str]
|
||||||
|
"""List of approved transceivers manufacturers."""
|
||||||
|
|
||||||
|
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyTransceiversManufacturers."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
wrong_manufacturers = {
|
wrong_manufacturers = {
|
||||||
interface: value["mfgName"] for interface, value in command_output["xcvrSlots"].items() if value["mfgName"] not in self.inputs.manufacturers
|
interface: value["mfgName"] for interface, value in command_output["xcvrSlots"].items() if value["mfgName"] not in self.inputs.manufacturers
|
||||||
|
@ -47,24 +62,32 @@ class VerifyTransceiversManufacturers(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyTemperature(AntaTest):
|
class VerifyTemperature(AntaTest):
|
||||||
"""
|
"""Verifies if the device temperature is within acceptable limits.
|
||||||
This test verifies if the device temperature is within acceptable limits.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the device temperature is currently OK: 'temperatureOk'.
|
----------------
|
||||||
* failure: The test will fail if the device temperature is NOT OK.
|
* 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"
|
name = "VerifyTemperature"
|
||||||
description = "Verifies the device temperature."
|
description = "Verifies the device temperature."
|
||||||
categories = ["hardware"]
|
categories: ClassVar[list[str]] = ["hardware"]
|
||||||
commands = [AntaCommand(command="show system environment temperature", ofmt="json")]
|
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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyTemperature."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
temperature_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else ""
|
temperature_status = command_output.get("systemStatus", "")
|
||||||
if temperature_status == "temperatureOk":
|
if temperature_status == "temperatureOk":
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
|
@ -72,24 +95,32 @@ class VerifyTemperature(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyTransceiversTemperature(AntaTest):
|
class VerifyTransceiversTemperature(AntaTest):
|
||||||
"""
|
"""Verifies if all the transceivers are operating at an acceptable temperature.
|
||||||
This test verifies if all the transceivers are operating at an acceptable temperature.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if all transceivers status are OK: 'ok'.
|
----------------
|
||||||
* failure: The test will fail if some transceivers are NOT OK.
|
* 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"
|
name = "VerifyTransceiversTemperature"
|
||||||
description = "Verifies the transceivers temperature."
|
description = "Verifies the transceivers temperature."
|
||||||
categories = ["hardware"]
|
categories: ClassVar[list[str]] = ["hardware"]
|
||||||
commands = [AntaCommand(command="show system environment temperature transceiver", ofmt="json")]
|
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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyTransceiversTemperature."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
sensors = command_output["tempSensors"] if "tempSensors" in command_output.keys() else ""
|
sensors = command_output.get("tempSensors", "")
|
||||||
wrong_sensors = {
|
wrong_sensors = {
|
||||||
sensor["name"]: {
|
sensor["name"]: {
|
||||||
"hwStatus": sensor["hwStatus"],
|
"hwStatus": sensor["hwStatus"],
|
||||||
|
@ -105,50 +136,70 @@ class VerifyTransceiversTemperature(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyEnvironmentSystemCooling(AntaTest):
|
class VerifyEnvironmentSystemCooling(AntaTest):
|
||||||
"""
|
"""Verifies the device's system cooling status.
|
||||||
This test verifies the device's system cooling.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the system cooling status is OK: 'coolingOk'.
|
----------------
|
||||||
* failure: The test will fail if the system cooling status is NOT OK.
|
* 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"
|
name = "VerifyEnvironmentSystemCooling"
|
||||||
description = "Verifies the system cooling status."
|
description = "Verifies the system cooling status."
|
||||||
categories = ["hardware"]
|
categories: ClassVar[list[str]] = ["hardware"]
|
||||||
commands = [AntaCommand(command="show system environment cooling", ofmt="json")]
|
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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyEnvironmentSystemCooling."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
sys_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else ""
|
sys_status = command_output.get("systemStatus", "")
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
if sys_status != "coolingOk":
|
if sys_status != "coolingOk":
|
||||||
self.result.is_failure(f"Device system cooling is not OK: '{sys_status}'")
|
self.result.is_failure(f"Device system cooling is not OK: '{sys_status}'")
|
||||||
|
|
||||||
|
|
||||||
class VerifyEnvironmentCooling(AntaTest):
|
class VerifyEnvironmentCooling(AntaTest):
|
||||||
"""
|
"""Verifies the status of power supply fans and all fan trays.
|
||||||
This test verifies the fans status.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the fans status are within the accepted states list.
|
----------------
|
||||||
* failure: The test will fail if some fans status is not within the accepted states list.
|
* Success: The test will pass if the fans status are within the accepted states list.
|
||||||
|
* Failure: The test will fail if some fans status is not within the accepted states list.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.hardware:
|
||||||
|
- VerifyEnvironmentCooling:
|
||||||
|
states:
|
||||||
|
- ok
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyEnvironmentCooling"
|
name = "VerifyEnvironmentCooling"
|
||||||
description = "Verifies the status of power supply fans and all fan trays."
|
description = "Verifies the status of power supply fans and all fan trays."
|
||||||
categories = ["hardware"]
|
categories: ClassVar[list[str]] = ["hardware"]
|
||||||
commands = [AntaCommand(command="show system environment cooling", ofmt="json")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
states: List[str]
|
"""Input model for the VerifyEnvironmentCooling test."""
|
||||||
"""Accepted states list for fan status"""
|
|
||||||
|
|
||||||
@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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyEnvironmentCooling."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
# First go through power supplies fans
|
# First go through power supplies fans
|
||||||
|
@ -164,28 +215,40 @@ class VerifyEnvironmentCooling(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyEnvironmentPower(AntaTest):
|
class VerifyEnvironmentPower(AntaTest):
|
||||||
"""
|
"""Verifies the power supplies status.
|
||||||
This test verifies the power supplies status.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the power supplies status are within the accepted states list.
|
----------------
|
||||||
* failure: The test will fail if some power supplies status is not within the accepted states list.
|
* Success: The test will pass if the power supplies status are within the accepted states list.
|
||||||
|
* Failure: The test will fail if some power supplies status is not within the accepted states list.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.hardware:
|
||||||
|
- VerifyEnvironmentPower:
|
||||||
|
states:
|
||||||
|
- ok
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyEnvironmentPower"
|
name = "VerifyEnvironmentPower"
|
||||||
description = "Verifies the power supplies status."
|
description = "Verifies the power supplies status."
|
||||||
categories = ["hardware"]
|
categories: ClassVar[list[str]] = ["hardware"]
|
||||||
commands = [AntaCommand(command="show system environment power", ofmt="json")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment power", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
states: List[str]
|
"""Input model for the VerifyEnvironmentPower test."""
|
||||||
"""Accepted states list for power supplies status"""
|
|
||||||
|
|
||||||
@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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyEnvironmentPower."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
power_supplies = command_output["powerSupplies"] if "powerSupplies" in command_output.keys() else "{}"
|
power_supplies = command_output.get("powerSupplies", "{}")
|
||||||
wrong_power_supplies = {
|
wrong_power_supplies = {
|
||||||
powersupply: {"state": value["state"]} for powersupply, value in dict(power_supplies).items() if value["state"] not in self.inputs.states
|
powersupply: {"state": value["state"]} for powersupply, value in dict(power_supplies).items() if value["state"] not in self.inputs.states
|
||||||
}
|
}
|
||||||
|
@ -196,24 +259,32 @@ class VerifyEnvironmentPower(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyAdverseDrops(AntaTest):
|
class VerifyAdverseDrops(AntaTest):
|
||||||
"""
|
"""Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches (Arad/Jericho chips).
|
||||||
This test verifies if there are no adverse drops on DCS7280E and DCS7500E.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if there are no adverse drops.
|
----------------
|
||||||
* failure: The test will fail if there are adverse drops.
|
* 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"
|
name = "VerifyAdverseDrops"
|
||||||
description = "Verifies there are no adverse drops on DCS7280E and DCS7500E"
|
description = "Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches."
|
||||||
categories = ["hardware"]
|
categories: ClassVar[list[str]] = ["hardware"]
|
||||||
commands = [AntaCommand(command="show hardware counter drop", ofmt="json")]
|
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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyAdverseDrops."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
total_adverse_drop = command_output["totalAdverseDrops"] if "totalAdverseDrops" in command_output.keys() else ""
|
total_adverse_drop = command_output.get("totalAdverseDrops", "")
|
||||||
if total_adverse_drop == 0:
|
if total_adverse_drop == 0:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -1,78 +1,115 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to the device interfaces tests."""
|
||||||
Test functions related to the device interfaces
|
|
||||||
"""
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from ipaddress import IPv4Network
|
from ipaddress import IPv4Network
|
||||||
|
from typing import Any, ClassVar, Literal
|
||||||
|
|
||||||
# Need to keep Dict and List for pydantic in python 3.8
|
from pydantic import BaseModel, Field
|
||||||
from typing import Any, Dict, List, Literal, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, conint
|
|
||||||
from pydantic_extra_types.mac_address import MacAddress
|
from pydantic_extra_types.mac_address import MacAddress
|
||||||
|
|
||||||
from anta.custom_types import Interface
|
from anta.custom_types import Interface, Percent, PositiveInteger
|
||||||
from anta.decorators import skip_on_platforms
|
from anta.decorators import skip_on_platforms
|
||||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||||
from anta.tools.get_item import get_item
|
from anta.tools import get_item, get_value
|
||||||
from anta.tools.get_value import get_value
|
|
||||||
|
|
||||||
|
|
||||||
class VerifyInterfaceUtilization(AntaTest):
|
class VerifyInterfaceUtilization(AntaTest):
|
||||||
"""
|
"""Verifies that the utilization of interfaces is below a certain threshold.
|
||||||
Verifies interfaces utilization is below 75%.
|
|
||||||
|
|
||||||
Expected Results:
|
Load interval (default to 5 minutes) is defined in device configuration.
|
||||||
* success: The test will pass if all interfaces have a usage below 75%.
|
This test has been implemented for full-duplex interfaces only.
|
||||||
* failure: The test will fail if one or more interfaces have a usage above 75%.
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if all interfaces have a usage below the threshold.
|
||||||
|
* Failure: The test will fail if one or more interfaces have a usage above the threshold.
|
||||||
|
* Error: The test will error out if the device has at least one non full-duplex interface.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.interfaces:
|
||||||
|
- VerifyInterfaceUtilization:
|
||||||
|
threshold: 70.0
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyInterfaceUtilization"
|
name = "VerifyInterfaceUtilization"
|
||||||
description = "Verifies that all interfaces have a usage below 75%."
|
description = "Verifies that the utilization of interfaces is below a certain threshold."
|
||||||
categories = ["interfaces"]
|
categories: ClassVar[list[str]] = ["interfaces"]
|
||||||
# TODO - move from text to json if possible
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||||
commands = [AntaCommand(command="show interfaces counters rates", ofmt="text")]
|
AntaCommand(command="show interfaces counters rates", revision=1),
|
||||||
|
AntaCommand(command="show interfaces", revision=1),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Input(AntaTest.Input):
|
||||||
|
"""Input model for the VerifyInterfaceUtilization test."""
|
||||||
|
|
||||||
|
threshold: Percent = 75.0
|
||||||
|
"""Interface utilization threshold above which the test will fail. Defaults to 75%."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
command_output = self.instance_commands[0].text_output
|
"""Main test function for VerifyInterfaceUtilization."""
|
||||||
wrong_interfaces = {}
|
duplex_full = "duplexFull"
|
||||||
for line in command_output.split("\n")[1:]:
|
failed_interfaces: dict[str, dict[str, float]] = {}
|
||||||
if len(line) > 0:
|
rates = self.instance_commands[0].json_output
|
||||||
if line.split()[-5] == "-" or line.split()[-2] == "-":
|
interfaces = self.instance_commands[1].json_output
|
||||||
pass
|
|
||||||
elif float(line.split()[-5].replace("%", "")) > 75.0:
|
for intf, rate in rates["interfaces"].items():
|
||||||
wrong_interfaces[line.split()[0]] = line.split()[-5]
|
# The utilization logic has been implemented for full-duplex interfaces only
|
||||||
elif float(line.split()[-2].replace("%", "")) > 75.0:
|
if ((duplex := (interface := interfaces["interfaces"][intf]).get("duplex", None)) is not None and duplex != duplex_full) or (
|
||||||
wrong_interfaces[line.split()[0]] = line.split()[-2]
|
(members := interface.get("memberInterfaces", None)) is not None and any(stats["duplex"] != duplex_full for stats in members.values())
|
||||||
if not wrong_interfaces:
|
):
|
||||||
|
self.result.is_error(f"Interface {intf} or one of its member interfaces is not Full-Duplex. VerifyInterfaceUtilization has not been implemented.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if (bandwidth := interfaces["interfaces"][intf]["bandwidth"]) == 0:
|
||||||
|
self.logger.debug("Interface %s has been ignored due to null bandwidth value", intf)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for bps_rate in ("inBpsRate", "outBpsRate"):
|
||||||
|
usage = rate[bps_rate] / bandwidth * 100
|
||||||
|
if usage > self.inputs.threshold:
|
||||||
|
failed_interfaces.setdefault(intf, {})[bps_rate] = usage
|
||||||
|
|
||||||
|
if not failed_interfaces:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
self.result.is_failure(f"The following interfaces have a usage > 75%: {wrong_interfaces}")
|
self.result.is_failure(f"The following interfaces have a usage > {self.inputs.threshold}%: {failed_interfaces}")
|
||||||
|
|
||||||
|
|
||||||
class VerifyInterfaceErrors(AntaTest):
|
class VerifyInterfaceErrors(AntaTest):
|
||||||
"""
|
"""Verifies that the interfaces error counters are equal to zero.
|
||||||
This test verifies that interfaces error counters are equal to zero.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if all interfaces have error counters equal to zero.
|
----------------
|
||||||
* failure: The test will fail if one or more interfaces have non-zero error counters.
|
* Success: The test will pass if all interfaces have error counters equal to zero.
|
||||||
|
* Failure: The test will fail if one or more interfaces have non-zero error counters.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.interfaces:
|
||||||
|
- VerifyInterfaceErrors:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyInterfaceErrors"
|
name = "VerifyInterfaceErrors"
|
||||||
description = "Verifies there are no interface error counters."
|
description = "Verifies there are no interface error counters."
|
||||||
categories = ["interfaces"]
|
categories: ClassVar[list[str]] = ["interfaces"]
|
||||||
commands = [AntaCommand(command="show interfaces counters errors")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces counters errors", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyInterfaceErrors."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
wrong_interfaces: list[dict[str, dict[str, int]]] = []
|
wrong_interfaces: list[dict[str, dict[str, int]]] = []
|
||||||
for interface, counters in command_output["interfaceErrorCounters"].items():
|
for interface, counters in command_output["interfaceErrorCounters"].items():
|
||||||
|
@ -85,25 +122,33 @@ class VerifyInterfaceErrors(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyInterfaceDiscards(AntaTest):
|
class VerifyInterfaceDiscards(AntaTest):
|
||||||
"""
|
"""Verifies that the interfaces packet discard counters are equal to zero.
|
||||||
Verifies interfaces packet discard counters are equal to zero.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if all interfaces have discard counters equal to zero.
|
----------------
|
||||||
* failure: The test will fail if one or more interfaces have non-zero discard counters.
|
* Success: The test will pass if all interfaces have discard counters equal to zero.
|
||||||
|
* Failure: The test will fail if one or more interfaces have non-zero discard counters.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.interfaces:
|
||||||
|
- VerifyInterfaceDiscards:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyInterfaceDiscards"
|
name = "VerifyInterfaceDiscards"
|
||||||
description = "Verifies there are no interface discard counters."
|
description = "Verifies there are no interface discard counters."
|
||||||
categories = ["interfaces"]
|
categories: ClassVar[list[str]] = ["interfaces"]
|
||||||
commands = [AntaCommand(command="show interfaces counters discards")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces counters discards", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyInterfaceDiscards."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
wrong_interfaces: list[dict[str, dict[str, int]]] = []
|
wrong_interfaces: list[dict[str, dict[str, int]]] = []
|
||||||
for interface, outer_v in command_output["interfaces"].items():
|
for interface, outer_v in command_output["interfaces"].items():
|
||||||
wrong_interfaces.extend({interface: outer_v} for counter, value in outer_v.items() if value > 0)
|
wrong_interfaces.extend({interface: outer_v} for value in outer_v.values() if value > 0)
|
||||||
if not wrong_interfaces:
|
if not wrong_interfaces:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
|
@ -111,21 +156,29 @@ class VerifyInterfaceDiscards(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyInterfaceErrDisabled(AntaTest):
|
class VerifyInterfaceErrDisabled(AntaTest):
|
||||||
"""
|
"""Verifies there are no interfaces in the errdisabled state.
|
||||||
Verifies there are no interfaces in errdisabled state.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if there are no interfaces in errdisabled state.
|
----------------
|
||||||
* failure: The test will fail if there is at least one interface in errdisabled state.
|
* Success: The test will pass if there are no interfaces in the errdisabled state.
|
||||||
|
* Failure: The test will fail if there is at least one interface in the errdisabled state.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.interfaces:
|
||||||
|
- VerifyInterfaceErrDisabled:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyInterfaceErrDisabled"
|
name = "VerifyInterfaceErrDisabled"
|
||||||
description = "Verifies there are no interfaces in the errdisabled state."
|
description = "Verifies there are no interfaces in the errdisabled state."
|
||||||
categories = ["interfaces"]
|
categories: ClassVar[list[str]] = ["interfaces"]
|
||||||
commands = [AntaCommand(command="show interfaces status")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces status", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyInterfaceErrDisabled."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
errdisabled_interfaces = [interface for interface, value in command_output["interfaceStatuses"].items() if value["linkStatus"] == "errdisabled"]
|
errdisabled_interfaces = [interface for interface, value in command_output["interfaceStatuses"].items() if value["linkStatus"] == "errdisabled"]
|
||||||
if errdisabled_interfaces:
|
if errdisabled_interfaces:
|
||||||
|
@ -135,41 +188,58 @@ class VerifyInterfaceErrDisabled(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyInterfacesStatus(AntaTest):
|
class VerifyInterfacesStatus(AntaTest):
|
||||||
"""
|
"""Verifies if the provided list of interfaces are all in the expected state.
|
||||||
This test verifies if the provided list of interfaces are all in the expected state.
|
|
||||||
|
|
||||||
- If line protocol status is provided, prioritize checking against both status and line protocol status
|
- If line protocol status is provided, prioritize checking against both status and line protocol status
|
||||||
- If line protocol status is not provided and interface status is "up", expect both status and line protocol to be "up"
|
- If line protocol status is not provided and interface status is "up", expect both status and line protocol to be "up"
|
||||||
- If interface status is not "up", check only the interface status without considering line protocol status
|
- If interface status is not "up", check only the interface status without considering line protocol status
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the provided interfaces are all in the expected state.
|
----------------
|
||||||
* failure: The test will fail if any interface is not in the expected state.
|
* Success: The test will pass if the provided interfaces are all in the expected state.
|
||||||
|
* Failure: The test will fail if any interface is not in the expected state.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.interfaces:
|
||||||
|
- VerifyInterfacesStatus:
|
||||||
|
interfaces:
|
||||||
|
- name: Ethernet1
|
||||||
|
status: up
|
||||||
|
- name: Port-Channel100
|
||||||
|
status: down
|
||||||
|
line_protocol_status: lowerLayerDown
|
||||||
|
- name: Ethernet49/1
|
||||||
|
status: adminDown
|
||||||
|
line_protocol_status: notPresent
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyInterfacesStatus"
|
name = "VerifyInterfacesStatus"
|
||||||
description = "Verifies the status of the provided interfaces."
|
description = "Verifies the status of the provided interfaces."
|
||||||
categories = ["interfaces"]
|
categories: ClassVar[list[str]] = ["interfaces"]
|
||||||
commands = [AntaCommand(command="show interfaces description")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces description", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
class Input(AntaTest.Input):
|
||||||
"""Input for the VerifyInterfacesStatus test."""
|
"""Input model for the VerifyInterfacesStatus test."""
|
||||||
|
|
||||||
interfaces: List[InterfaceState]
|
interfaces: list[InterfaceState]
|
||||||
"""List of interfaces to validate with the expected state."""
|
"""List of interfaces with their expected state."""
|
||||||
|
|
||||||
class InterfaceState(BaseModel):
|
class InterfaceState(BaseModel):
|
||||||
"""Model for the interface state input."""
|
"""Model for an interface state."""
|
||||||
|
|
||||||
name: Interface
|
name: Interface
|
||||||
"""Interface to validate."""
|
"""Interface to validate."""
|
||||||
status: Literal["up", "down", "adminDown"]
|
status: Literal["up", "down", "adminDown"]
|
||||||
"""Expected status of the interface."""
|
"""Expected status of the interface."""
|
||||||
line_protocol_status: Optional[Literal["up", "down", "testing", "unknown", "dormant", "notPresent", "lowerLayerDown"]] = None
|
line_protocol_status: Literal["up", "down", "testing", "unknown", "dormant", "notPresent", "lowerLayerDown"] | None = None
|
||||||
"""Expected line protocol status of the interface."""
|
"""Expected line protocol status of the interface."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyInterfacesStatus."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
|
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
@ -203,22 +273,30 @@ class VerifyInterfacesStatus(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyStormControlDrops(AntaTest):
|
class VerifyStormControlDrops(AntaTest):
|
||||||
"""
|
"""Verifies there are no interface storm-control drop counters.
|
||||||
Verifies the device did not drop packets due its to storm-control configuration.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if there are no storm-control drop counters.
|
----------------
|
||||||
* failure: The test will fail if there is at least one storm-control drop counter.
|
* Success: The test will pass if there are no storm-control drop counters.
|
||||||
|
* Failure: The test will fail if there is at least one storm-control drop counter.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.interfaces:
|
||||||
|
- VerifyStormControlDrops:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyStormControlDrops"
|
name = "VerifyStormControlDrops"
|
||||||
description = "Verifies there are no interface storm-control drop counters."
|
description = "Verifies there are no interface storm-control drop counters."
|
||||||
categories = ["interfaces"]
|
categories: ClassVar[list[str]] = ["interfaces"]
|
||||||
commands = [AntaCommand(command="show storm-control")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show storm-control", revision=1)]
|
||||||
|
|
||||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyStormControlDrops."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
storm_controlled_interfaces: dict[str, dict[str, Any]] = {}
|
storm_controlled_interfaces: dict[str, dict[str, Any]] = {}
|
||||||
for interface, interface_dict in command_output["interfaces"].items():
|
for interface, interface_dict in command_output["interfaces"].items():
|
||||||
|
@ -233,49 +311,64 @@ class VerifyStormControlDrops(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyPortChannels(AntaTest):
|
class VerifyPortChannels(AntaTest):
|
||||||
"""
|
"""Verifies there are no inactive ports in all port channels.
|
||||||
Verifies there are no inactive ports in all port channels.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if there are no inactive ports in all port channels.
|
----------------
|
||||||
* failure: The test will fail if there is at least one inactive port in a port channel.
|
* Success: The test will pass if there are no inactive ports in all port channels.
|
||||||
|
* Failure: The test will fail if there is at least one inactive port in a port channel.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.interfaces:
|
||||||
|
- VerifyPortChannels:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyPortChannels"
|
name = "VerifyPortChannels"
|
||||||
description = "Verifies there are no inactive ports in all port channels."
|
description = "Verifies there are no inactive ports in all port channels."
|
||||||
categories = ["interfaces"]
|
categories: ClassVar[list[str]] = ["interfaces"]
|
||||||
commands = [AntaCommand(command="show port-channel")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show port-channel", revision=1)]
|
||||||
|
|
||||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyPortChannels."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
po_with_invactive_ports: list[dict[str, str]] = []
|
po_with_inactive_ports: list[dict[str, str]] = []
|
||||||
for portchannel, portchannel_dict in command_output["portChannels"].items():
|
for portchannel, portchannel_dict in command_output["portChannels"].items():
|
||||||
if len(portchannel_dict["inactivePorts"]) != 0:
|
if len(portchannel_dict["inactivePorts"]) != 0:
|
||||||
po_with_invactive_ports.extend({portchannel: portchannel_dict["inactivePorts"]})
|
po_with_inactive_ports.extend({portchannel: portchannel_dict["inactivePorts"]})
|
||||||
if not po_with_invactive_ports:
|
if not po_with_inactive_ports:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
self.result.is_failure(f"The following port-channels have inactive port(s): {po_with_invactive_ports}")
|
self.result.is_failure(f"The following port-channels have inactive port(s): {po_with_inactive_ports}")
|
||||||
|
|
||||||
|
|
||||||
class VerifyIllegalLACP(AntaTest):
|
class VerifyIllegalLACP(AntaTest):
|
||||||
"""
|
"""Verifies there are no illegal LACP packets in all port channels.
|
||||||
Verifies there are no illegal LACP packets received.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if there are no illegal LACP packets received.
|
----------------
|
||||||
* failure: The test will fail if there is at least one illegal LACP packet received.
|
* Success: The test will pass if there are no illegal LACP packets received.
|
||||||
|
* Failure: The test will fail if there is at least one illegal LACP packet received.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.interfaces:
|
||||||
|
- VerifyIllegalLACP:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyIllegalLACP"
|
name = "VerifyIllegalLACP"
|
||||||
description = "Verifies there are no illegal LACP packets in all port channels."
|
description = "Verifies there are no illegal LACP packets in all port channels."
|
||||||
categories = ["interfaces"]
|
categories: ClassVar[list[str]] = ["interfaces"]
|
||||||
commands = [AntaCommand(command="show lacp counters all-ports")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lacp counters all-ports", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyIllegalLACP."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
po_with_illegal_lacp: list[dict[str, dict[str, int]]] = []
|
po_with_illegal_lacp: list[dict[str, dict[str, int]]] = []
|
||||||
for portchannel, portchannel_dict in command_output["portChannels"].items():
|
for portchannel, portchannel_dict in command_output["portChannels"].items():
|
||||||
|
@ -285,29 +378,40 @@ class VerifyIllegalLACP(AntaTest):
|
||||||
if not po_with_illegal_lacp:
|
if not po_with_illegal_lacp:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
self.result.is_failure("The following port-channels have recieved illegal lacp packets on the " f"following ports: {po_with_illegal_lacp}")
|
self.result.is_failure(f"The following port-channels have received illegal LACP packets on the following ports: {po_with_illegal_lacp}")
|
||||||
|
|
||||||
|
|
||||||
class VerifyLoopbackCount(AntaTest):
|
class VerifyLoopbackCount(AntaTest):
|
||||||
"""
|
"""Verifies that the device has the expected number of loopback interfaces and all are operational.
|
||||||
Verifies that the device has the expected number of loopback interfaces and all are operational.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the device has the correct number of loopback interfaces and none are down.
|
----------------
|
||||||
* failure: The test will fail if the loopback interface count is incorrect or any are non-operational.
|
* Success: The test will pass if the device has the correct number of loopback interfaces and none are down.
|
||||||
|
* Failure: The test will fail if the loopback interface count is incorrect or any are non-operational.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.interfaces:
|
||||||
|
- VerifyLoopbackCount:
|
||||||
|
number: 3
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyLoopbackCount"
|
name = "VerifyLoopbackCount"
|
||||||
description = "Verifies the number of loopback interfaces and their status."
|
description = "Verifies the number of loopback interfaces and their status."
|
||||||
categories = ["interfaces"]
|
categories: ClassVar[list[str]] = ["interfaces"]
|
||||||
commands = [AntaCommand(command="show ip interface brief")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface brief", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
number: conint(ge=0) # type: ignore
|
"""Input model for the VerifyLoopbackCount test."""
|
||||||
"""Number of loopback interfaces expected to be present"""
|
|
||||||
|
number: PositiveInteger
|
||||||
|
"""Number of loopback interfaces expected to be present."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyLoopbackCount."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
loopback_count = 0
|
loopback_count = 0
|
||||||
down_loopback_interfaces = []
|
down_loopback_interfaces = []
|
||||||
|
@ -328,28 +432,35 @@ class VerifyLoopbackCount(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifySVI(AntaTest):
|
class VerifySVI(AntaTest):
|
||||||
"""
|
"""Verifies the status of all SVIs.
|
||||||
Verifies the status of all SVIs.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if all SVIs are up.
|
----------------
|
||||||
* failure: The test will fail if one or many SVIs are not up.
|
* Success: The test will pass if all SVIs are up.
|
||||||
|
* Failure: The test will fail if one or many SVIs are not up.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.interfaces:
|
||||||
|
- VerifySVI:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifySVI"
|
name = "VerifySVI"
|
||||||
description = "Verifies the status of all SVIs."
|
description = "Verifies the status of all SVIs."
|
||||||
categories = ["interfaces"]
|
categories: ClassVar[list[str]] = ["interfaces"]
|
||||||
commands = [AntaCommand(command="show ip interface brief")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface brief", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifySVI."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
down_svis = []
|
down_svis = []
|
||||||
for interface in command_output["interfaces"]:
|
for interface in command_output["interfaces"]:
|
||||||
interface_dict = command_output["interfaces"][interface]
|
interface_dict = command_output["interfaces"][interface]
|
||||||
if "Vlan" in interface:
|
if "Vlan" in interface and not (interface_dict["lineProtocolStatus"] == "up" and interface_dict["interfaceStatus"] == "connected"):
|
||||||
if not (interface_dict["lineProtocolStatus"] == "up" and interface_dict["interfaceStatus"] == "connected"):
|
down_svis.append(interface)
|
||||||
down_svis.append(interface)
|
|
||||||
if len(down_svis) == 0:
|
if len(down_svis) == 0:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
|
@ -357,32 +468,48 @@ class VerifySVI(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyL3MTU(AntaTest):
|
class VerifyL3MTU(AntaTest):
|
||||||
"""
|
"""Verifies the global layer 3 Maximum Transfer Unit (MTU) for all L3 interfaces.
|
||||||
Verifies the global layer 3 Maximum Transfer Unit (MTU) for all L3 interfaces.
|
|
||||||
|
|
||||||
Test that L3 interfaces are configured with the correct MTU. It supports Ethernet, Port Channel and VLAN interfaces.
|
Test that L3 interfaces are configured with the correct MTU. It supports Ethernet, Port Channel and VLAN interfaces.
|
||||||
You can define a global MTU to check and also an MTU per interface and also ignored some interfaces.
|
|
||||||
|
|
||||||
Expected Results:
|
You can define a global MTU to check, or an MTU per interface and you can also ignored some interfaces.
|
||||||
* success: The test will pass if all layer 3 interfaces have the proper MTU configured.
|
|
||||||
* failure: The test will fail if one or many layer 3 interfaces have the wrong MTU configured.
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if all layer 3 interfaces have the proper MTU configured.
|
||||||
|
* Failure: The test will fail if one or many layer 3 interfaces have the wrong MTU configured.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.interfaces:
|
||||||
|
- VerifyL3MTU:
|
||||||
|
mtu: 1500
|
||||||
|
ignored_interfaces:
|
||||||
|
- Vxlan1
|
||||||
|
specific_mtu:
|
||||||
|
- Ethernet1: 2500
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyL3MTU"
|
name = "VerifyL3MTU"
|
||||||
description = "Verifies the global L3 MTU of all L3 interfaces."
|
description = "Verifies the global L3 MTU of all L3 interfaces."
|
||||||
categories = ["interfaces"]
|
categories: ClassVar[list[str]] = ["interfaces"]
|
||||||
commands = [AntaCommand(command="show interfaces")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces", revision=1)]
|
||||||
|
|
||||||
|
class Input(AntaTest.Input):
|
||||||
|
"""Input model for the VerifyL3MTU test."""
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
|
||||||
mtu: int = 1500
|
mtu: int = 1500
|
||||||
"""Default MTU we should have configured on all non-excluded interfaces"""
|
"""Default MTU we should have configured on all non-excluded interfaces. Defaults to 1500."""
|
||||||
ignored_interfaces: List[str] = ["Management", "Loopback", "Vxlan", "Tunnel"]
|
ignored_interfaces: list[str] = Field(default=["Management", "Loopback", "Vxlan", "Tunnel"])
|
||||||
"""A list of L3 interfaces to ignore"""
|
"""A list of L3 interfaces to ignore"""
|
||||||
specific_mtu: List[Dict[str, int]] = []
|
specific_mtu: list[dict[str, int]] = Field(default=[])
|
||||||
"""A list of dictionary of L3 interfaces with their specific MTU configured"""
|
"""A list of dictionary of L3 interfaces with their specific MTU configured"""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyL3MTU."""
|
||||||
# Parameter to save incorrect interface settings
|
# Parameter to save incorrect interface settings
|
||||||
wrong_l3mtu_intf: list[dict[str, int]] = []
|
wrong_l3mtu_intf: list[dict[str, int]] = []
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
|
@ -405,32 +532,45 @@ class VerifyL3MTU(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyIPProxyARP(AntaTest):
|
class VerifyIPProxyARP(AntaTest):
|
||||||
"""
|
"""Verifies if Proxy-ARP is enabled for the provided list of interface(s).
|
||||||
Verifies if Proxy-ARP is enabled for the provided list of interface(s).
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if Proxy-ARP is enabled on the specified interface(s).
|
----------------
|
||||||
* failure: The test will fail if Proxy-ARP is disabled on the specified interface(s).
|
* Success: The test will pass if Proxy-ARP is enabled on the specified interface(s).
|
||||||
|
* Failure: The test will fail if Proxy-ARP is disabled on the specified interface(s).
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.interfaces:
|
||||||
|
- VerifyIPProxyARP:
|
||||||
|
interfaces:
|
||||||
|
- Ethernet1
|
||||||
|
- Ethernet2
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyIPProxyARP"
|
name = "VerifyIPProxyARP"
|
||||||
description = "Verifies if Proxy ARP is enabled."
|
description = "Verifies if Proxy ARP is enabled."
|
||||||
categories = ["interfaces"]
|
categories: ClassVar[list[str]] = ["interfaces"]
|
||||||
commands = [AntaTemplate(template="show ip interface {intf}")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {intf}", revision=2)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
interfaces: List[str]
|
"""Input model for the VerifyIPProxyARP test."""
|
||||||
"""list of interfaces to be tested"""
|
|
||||||
|
interfaces: list[str]
|
||||||
|
"""List of interfaces to be tested."""
|
||||||
|
|
||||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||||
|
"""Render the template for each interface in the input list."""
|
||||||
return [template.render(intf=intf) for intf in self.inputs.interfaces]
|
return [template.render(intf=intf) for intf in self.inputs.interfaces]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyIPProxyARP."""
|
||||||
disabled_intf = []
|
disabled_intf = []
|
||||||
for command in self.instance_commands:
|
for command in self.instance_commands:
|
||||||
if "intf" in command.params:
|
intf = command.params.intf
|
||||||
intf = command.params["intf"]
|
|
||||||
if not command.json_output["interfaces"][intf]["proxyArp"]:
|
if not command.json_output["interfaces"][intf]["proxyArp"]:
|
||||||
disabled_intf.append(intf)
|
disabled_intf.append(intf)
|
||||||
if disabled_intf:
|
if disabled_intf:
|
||||||
|
@ -440,32 +580,48 @@ class VerifyIPProxyARP(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyL2MTU(AntaTest):
|
class VerifyL2MTU(AntaTest):
|
||||||
"""
|
"""Verifies the global layer 2 Maximum Transfer Unit (MTU) for all L2 interfaces.
|
||||||
Verifies the global layer 2 Maximum Transfer Unit (MTU) for all L2 interfaces.
|
|
||||||
|
|
||||||
Test that L2 interfaces are configured with the correct MTU. It supports Ethernet, Port Channel and VLAN interfaces.
|
Test that L2 interfaces are configured with the correct MTU. It supports Ethernet, Port Channel and VLAN interfaces.
|
||||||
You can define a global MTU to check and also an MTU per interface and also ignored some interfaces.
|
You can define a global MTU to check and also an MTU per interface and also ignored some interfaces.
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if all layer 2 interfaces have the proper MTU configured.
|
----------------
|
||||||
* failure: The test will fail if one or many layer 2 interfaces have the wrong MTU configured.
|
* Success: The test will pass if all layer 2 interfaces have the proper MTU configured.
|
||||||
|
* Failure: The test will fail if one or many layer 2 interfaces have the wrong MTU configured.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.interfaces:
|
||||||
|
- VerifyL2MTU:
|
||||||
|
mtu: 1500
|
||||||
|
ignored_interfaces:
|
||||||
|
- Management1
|
||||||
|
- Vxlan1
|
||||||
|
specific_mtu:
|
||||||
|
- Ethernet1/1: 1500
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyL2MTU"
|
name = "VerifyL2MTU"
|
||||||
description = "Verifies the global L2 MTU of all L2 interfaces."
|
description = "Verifies the global L2 MTU of all L2 interfaces."
|
||||||
categories = ["interfaces"]
|
categories: ClassVar[list[str]] = ["interfaces"]
|
||||||
commands = [AntaCommand(command="show interfaces")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces", revision=1)]
|
||||||
|
|
||||||
|
class Input(AntaTest.Input):
|
||||||
|
"""Input model for the VerifyL2MTU test."""
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
|
||||||
mtu: int = 9214
|
mtu: int = 9214
|
||||||
"""Default MTU we should have configured on all non-excluded interfaces"""
|
"""Default MTU we should have configured on all non-excluded interfaces. Defaults to 9214."""
|
||||||
ignored_interfaces: List[str] = ["Management", "Loopback", "Vxlan", "Tunnel"]
|
ignored_interfaces: list[str] = Field(default=["Management", "Loopback", "Vxlan", "Tunnel"])
|
||||||
"""A list of L2 interfaces to ignore"""
|
"""A list of L2 interfaces to ignore. Defaults to ["Management", "Loopback", "Vxlan", "Tunnel"]"""
|
||||||
specific_mtu: List[Dict[str, int]] = []
|
specific_mtu: list[dict[str, int]] = Field(default=[])
|
||||||
"""A list of dictionary of L2 interfaces with their specific MTU configured"""
|
"""A list of dictionary of L2 interfaces with their specific MTU configured"""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyL2MTU."""
|
||||||
# Parameter to save incorrect interface settings
|
# Parameter to save incorrect interface settings
|
||||||
wrong_l2mtu_intf: list[dict[str, int]] = []
|
wrong_l2mtu_intf: list[dict[str, int]] = []
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
|
@ -475,7 +631,8 @@ class VerifyL2MTU(AntaTest):
|
||||||
for d in self.inputs.specific_mtu:
|
for d in self.inputs.specific_mtu:
|
||||||
specific_interfaces.extend(d)
|
specific_interfaces.extend(d)
|
||||||
for interface, values in command_output["interfaces"].items():
|
for interface, values in command_output["interfaces"].items():
|
||||||
if re.findall(r"[a-z]+", interface, re.IGNORECASE)[0] not in self.inputs.ignored_interfaces and values["forwardingModel"] == "bridged":
|
catch_interface = re.findall(r"^[e,p][a-zA-Z]+[-,a-zA-Z]*\d+\/*\d*", interface, re.IGNORECASE)
|
||||||
|
if len(catch_interface) and catch_interface[0] not in self.inputs.ignored_interfaces and values["forwardingModel"] == "bridged":
|
||||||
if interface in specific_interfaces:
|
if interface in specific_interfaces:
|
||||||
wrong_l2mtu_intf.extend({interface: values["mtu"]} for custom_data in self.inputs.specific_mtu if values["mtu"] != custom_data[interface])
|
wrong_l2mtu_intf.extend({interface: values["mtu"]} for custom_data in self.inputs.specific_mtu if values["mtu"] != custom_data[interface])
|
||||||
# Comparison with generic setting
|
# Comparison with generic setting
|
||||||
|
@ -488,47 +645,62 @@ class VerifyL2MTU(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyInterfaceIPv4(AntaTest):
|
class VerifyInterfaceIPv4(AntaTest):
|
||||||
"""
|
"""Verifies if an interface is configured with a correct primary and list of optional secondary IPv4 addresses.
|
||||||
Verifies if an interface is configured with a correct primary and list of optional secondary IPv4 addresses.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if an interface is configured with a correct primary and secondary IPv4 address.
|
----------------
|
||||||
* failure: The test will fail if an interface is not found or the primary and secondary IPv4 addresses do not match with the input.
|
* Success: The test will pass if an interface is configured with a correct primary and secondary IPv4 address.
|
||||||
|
* Failure: The test will fail if an interface is not found or the primary and secondary IPv4 addresses do not match with the input.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.interfaces:
|
||||||
|
- VerifyInterfaceIPv4:
|
||||||
|
interfaces:
|
||||||
|
- name: Ethernet2
|
||||||
|
primary_ip: 172.30.11.0/31
|
||||||
|
secondary_ips:
|
||||||
|
- 10.10.10.0/31
|
||||||
|
- 10.10.10.10/31
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyInterfaceIPv4"
|
name = "VerifyInterfaceIPv4"
|
||||||
description = "Verifies the interface IPv4 addresses."
|
description = "Verifies the interface IPv4 addresses."
|
||||||
categories = ["interfaces"]
|
categories: ClassVar[list[str]] = ["interfaces"]
|
||||||
commands = [AntaTemplate(template="show ip interface {interface}")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {interface}", revision=2)]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
class Input(AntaTest.Input):
|
||||||
"""Inputs for the VerifyInterfaceIPv4 test."""
|
"""Input model for the VerifyInterfaceIPv4 test."""
|
||||||
|
|
||||||
interfaces: List[InterfaceDetail]
|
interfaces: list[InterfaceDetail]
|
||||||
"""list of interfaces to be tested"""
|
"""List of interfaces with their details."""
|
||||||
|
|
||||||
class InterfaceDetail(BaseModel):
|
class InterfaceDetail(BaseModel):
|
||||||
"""Detail of an interface"""
|
"""Model for an interface detail."""
|
||||||
|
|
||||||
name: Interface
|
name: Interface
|
||||||
"""Name of the interface"""
|
"""Name of the interface."""
|
||||||
primary_ip: IPv4Network
|
primary_ip: IPv4Network
|
||||||
"""Primary IPv4 address with subnet on interface"""
|
"""Primary IPv4 address in CIDR notation."""
|
||||||
secondary_ips: Optional[List[IPv4Network]] = None
|
secondary_ips: list[IPv4Network] | None = None
|
||||||
"""Optional list of secondary IPv4 addresses with subnet on interface"""
|
"""Optional list of secondary IPv4 addresses in CIDR notation."""
|
||||||
|
|
||||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||||
# Render the template for each interface
|
"""Render the template for each interface in the input list."""
|
||||||
return [
|
return [template.render(interface=interface.name) for interface in self.inputs.interfaces]
|
||||||
template.render(interface=interface.name, primary_ip=interface.primary_ip, secondary_ips=interface.secondary_ips) for interface in self.inputs.interfaces
|
|
||||||
]
|
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyInterfaceIPv4."""
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
for command in self.instance_commands:
|
for command in self.instance_commands:
|
||||||
intf = command.params["interface"]
|
intf = command.params.interface
|
||||||
input_primary_ip = str(command.params["primary_ip"])
|
for interface in self.inputs.interfaces:
|
||||||
|
if interface.name == intf:
|
||||||
|
input_interface_detail = interface
|
||||||
|
input_primary_ip = str(input_interface_detail.primary_ip)
|
||||||
failed_messages = []
|
failed_messages = []
|
||||||
|
|
||||||
# Check if the interface has an IP address configured
|
# Check if the interface has an IP address configured
|
||||||
|
@ -545,8 +717,8 @@ class VerifyInterfaceIPv4(AntaTest):
|
||||||
if actual_primary_ip != input_primary_ip:
|
if actual_primary_ip != input_primary_ip:
|
||||||
failed_messages.append(f"The expected primary IP address is `{input_primary_ip}`, but the actual primary IP address is `{actual_primary_ip}`.")
|
failed_messages.append(f"The expected primary IP address is `{input_primary_ip}`, but the actual primary IP address is `{actual_primary_ip}`.")
|
||||||
|
|
||||||
if command.params["secondary_ips"] is not None:
|
if (param_secondary_ips := input_interface_detail.secondary_ips) is not None:
|
||||||
input_secondary_ips = sorted([str(network) for network in command.params["secondary_ips"]])
|
input_secondary_ips = sorted([str(network) for network in param_secondary_ips])
|
||||||
secondary_ips = get_value(interface_output, "secondaryIpsOrderedList")
|
secondary_ips = get_value(interface_output, "secondaryIpsOrderedList")
|
||||||
|
|
||||||
# Combine IP address and subnet for secondary IPs
|
# Combine IP address and subnet for secondary IPs
|
||||||
|
@ -569,27 +741,36 @@ class VerifyInterfaceIPv4(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyIpVirtualRouterMac(AntaTest):
|
class VerifyIpVirtualRouterMac(AntaTest):
|
||||||
"""
|
"""Verifies the IP virtual router MAC address.
|
||||||
Verifies the IP virtual router MAC address.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the IP virtual router MAC address matches the input.
|
----------------
|
||||||
* failure: The test will fail if the IP virtual router MAC address does not match the input.
|
* Success: The test will pass if the IP virtual router MAC address matches the input.
|
||||||
|
* Failure: The test will fail if the IP virtual router MAC address does not match the input.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.interfaces:
|
||||||
|
- VerifyIpVirtualRouterMac:
|
||||||
|
mac_address: 00:1c:73:00:dc:01
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyIpVirtualRouterMac"
|
name = "VerifyIpVirtualRouterMac"
|
||||||
description = "Verifies the IP virtual router MAC address."
|
description = "Verifies the IP virtual router MAC address."
|
||||||
categories = ["interfaces"]
|
categories: ClassVar[list[str]] = ["interfaces"]
|
||||||
commands = [AntaCommand(command="show ip virtual-router")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip virtual-router", revision=2)]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
class Input(AntaTest.Input):
|
||||||
"""Inputs for the VerifyIpVirtualRouterMac test."""
|
"""Input model for the VerifyIpVirtualRouterMac test."""
|
||||||
|
|
||||||
mac_address: MacAddress
|
mac_address: MacAddress
|
||||||
"""IP virtual router MAC address"""
|
"""IP virtual router MAC address."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyIpVirtualRouterMac."""
|
||||||
command_output = self.instance_commands[0].json_output["virtualMacs"]
|
command_output = self.instance_commands[0].json_output["virtualMacs"]
|
||||||
mac_address_found = get_item(command_output, "macAddress", self.inputs.mac_address)
|
mac_address_found = get_item(command_output, "macAddress", self.inputs.mac_address)
|
||||||
|
|
||||||
|
|
|
@ -1,34 +1,47 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to LANZ tests."""
|
||||||
Test functions related to LANZ
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
|
from anta.decorators import skip_on_platforms
|
||||||
from anta.models import AntaCommand, AntaTest
|
from anta.models import AntaCommand, AntaTest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.models import AntaTemplate
|
||||||
|
|
||||||
|
|
||||||
class VerifyLANZ(AntaTest):
|
class VerifyLANZ(AntaTest):
|
||||||
"""
|
"""Verifies if LANZ (Latency Analyzer) is enabled.
|
||||||
Verifies if LANZ is enabled
|
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* success: the test will pass if lanz is enabled
|
----------------
|
||||||
* failure: the test will fail if lanz is disabled
|
* 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"
|
name = "VerifyLANZ"
|
||||||
description = "Verifies if LANZ is enabled."
|
description = "Verifies if LANZ is enabled."
|
||||||
categories = ["lanz"]
|
categories: ClassVar[list[str]] = ["lanz"]
|
||||||
commands = [AntaCommand(command="show queue-monitor length status")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show queue-monitor length status", revision=1)]
|
||||||
|
|
||||||
|
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyLANZ."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
|
|
||||||
if command_output["lanzEnabled"] is not True:
|
if command_output["lanzEnabled"] is not True:
|
||||||
self.result.is_failure("LANZ is not enabled")
|
self.result.is_failure("LANZ is not enabled")
|
||||||
else:
|
else:
|
||||||
self.result.is_success("LANZ is enabled")
|
self.result.is_success()
|
||||||
|
|
|
@ -1,57 +1,72 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to the EOS various logging tests.
|
||||||
Test functions related to the EOS various logging settings
|
|
||||||
|
|
||||||
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 does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
import re
|
||||||
from ipaddress import IPv4Address
|
from ipaddress import IPv4Address
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
# Need to keep List for pydantic in python 3.8
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from anta.models import AntaCommand, AntaTest
|
from anta.models import AntaCommand, AntaTest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from anta.models import AntaTemplate
|
||||||
|
|
||||||
|
|
||||||
def _get_logging_states(logger: logging.Logger, command_output: str) -> str:
|
def _get_logging_states(logger: logging.Logger, command_output: str) -> str:
|
||||||
"""
|
"""Parse `show logging` output and gets operational logging states used in the tests in this module.
|
||||||
Parse "show logging" output and gets operational logging states used
|
|
||||||
in the tests in this module.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
command_output: The 'show logging' output
|
----
|
||||||
|
logger: The logger object.
|
||||||
|
command_output: The `show logging` output.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str: The operational logging states.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
log_states = command_output.partition("\n\nExternal configuration:")[0]
|
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
|
return log_states
|
||||||
|
|
||||||
|
|
||||||
class VerifyLoggingPersistent(AntaTest):
|
class VerifyLoggingPersistent(AntaTest):
|
||||||
"""
|
"""Verifies if logging persistent is enabled and logs are saved in flash.
|
||||||
Verifies if logging persistent is enabled and logs are saved in flash.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if logging persistent is enabled and logs are in flash.
|
----------------
|
||||||
* failure: The test will fail if logging persistent is disabled or no logs are saved in flash.
|
* Success: The test will pass if logging persistent is enabled and logs are in flash.
|
||||||
|
* Failure: The test will fail if logging persistent is disabled or no logs are saved in flash.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.logging:
|
||||||
|
- VerifyLoggingPersistent:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyLoggingPersistent"
|
name = "VerifyLoggingPersistent"
|
||||||
description = "Verifies if logging persistent is enabled and logs are saved in flash."
|
description = "Verifies if logging persistent is enabled and logs are saved in flash."
|
||||||
categories = ["logging"]
|
categories: ClassVar[list[str]] = ["logging"]
|
||||||
commands = [
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||||
AntaCommand(command="show logging", ofmt="text"),
|
AntaCommand(command="show logging", ofmt="text"),
|
||||||
AntaCommand(command="dir flash:/persist/messages", ofmt="text"),
|
AntaCommand(command="dir flash:/persist/messages", ofmt="text"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyLoggingPersistent."""
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
log_output = self.instance_commands[0].text_output
|
log_output = self.instance_commands[0].text_output
|
||||||
dir_flash_output = self.instance_commands[1].text_output
|
dir_flash_output = self.instance_commands[1].text_output
|
||||||
|
@ -65,27 +80,39 @@ class VerifyLoggingPersistent(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyLoggingSourceIntf(AntaTest):
|
class VerifyLoggingSourceIntf(AntaTest):
|
||||||
"""
|
"""Verifies logging source-interface for a specified VRF.
|
||||||
Verifies logging source-interface for a specified VRF.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the provided logging source-interface is configured in the specified VRF.
|
----------------
|
||||||
* failure: The test will fail if the provided logging source-interface is NOT configured in the specified VRF.
|
* Success: The test will pass if the provided logging source-interface is configured in the specified VRF.
|
||||||
|
* Failure: The test will fail if the provided logging source-interface is NOT configured in the specified VRF.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.logging:
|
||||||
|
- VerifyLoggingSourceIntf:
|
||||||
|
interface: Management0
|
||||||
|
vrf: default
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyLoggingSourceInt"
|
name = "VerifyLoggingSourceInt"
|
||||||
description = "Verifies logging source-interface for a specified VRF."
|
description = "Verifies logging source-interface for a specified VRF."
|
||||||
categories = ["logging"]
|
categories: ClassVar[list[str]] = ["logging"]
|
||||||
commands = [AntaCommand(command="show logging", ofmt="text")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")]
|
||||||
|
|
||||||
|
class Input(AntaTest.Input):
|
||||||
|
"""Input model for the VerifyLoggingSourceInt test."""
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
|
||||||
interface: str
|
interface: str
|
||||||
"""Source-interface to use as source IP of log messages"""
|
"""Source-interface to use as source IP of log messages."""
|
||||||
vrf: str = "default"
|
vrf: str = "default"
|
||||||
"""The name of the VRF to transport log messages"""
|
"""The name of the VRF to transport log messages. Defaults to `default`."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyLoggingSourceInt."""
|
||||||
output = self.instance_commands[0].text_output
|
output = self.instance_commands[0].text_output
|
||||||
pattern = rf"Logging source-interface '{self.inputs.interface}'.*VRF {self.inputs.vrf}"
|
pattern = rf"Logging source-interface '{self.inputs.interface}'.*VRF {self.inputs.vrf}"
|
||||||
if re.search(pattern, _get_logging_states(self.logger, output)):
|
if re.search(pattern, _get_logging_states(self.logger, output)):
|
||||||
|
@ -95,31 +122,45 @@ class VerifyLoggingSourceIntf(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyLoggingHosts(AntaTest):
|
class VerifyLoggingHosts(AntaTest):
|
||||||
"""
|
"""Verifies logging hosts (syslog servers) for a specified VRF.
|
||||||
Verifies logging hosts (syslog servers) for a specified VRF.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the provided syslog servers are configured in the specified VRF.
|
----------------
|
||||||
* failure: The test will fail if the provided syslog servers are NOT configured in the specified VRF.
|
* Success: The test will pass if the provided syslog servers are configured in the specified VRF.
|
||||||
|
* Failure: The test will fail if the provided syslog servers are NOT configured in the specified VRF.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.logging:
|
||||||
|
- VerifyLoggingHosts:
|
||||||
|
hosts:
|
||||||
|
- 1.1.1.1
|
||||||
|
- 2.2.2.2
|
||||||
|
vrf: default
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyLoggingHosts"
|
name = "VerifyLoggingHosts"
|
||||||
description = "Verifies logging hosts (syslog servers) for a specified VRF."
|
description = "Verifies logging hosts (syslog servers) for a specified VRF."
|
||||||
categories = ["logging"]
|
categories: ClassVar[list[str]] = ["logging"]
|
||||||
commands = [AntaCommand(command="show logging", ofmt="text")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
hosts: List[IPv4Address]
|
"""Input model for the VerifyLoggingHosts test."""
|
||||||
"""List of hosts (syslog servers) IP addresses"""
|
|
||||||
|
hosts: list[IPv4Address]
|
||||||
|
"""List of hosts (syslog servers) IP addresses."""
|
||||||
vrf: str = "default"
|
vrf: str = "default"
|
||||||
"""The name of the VRF to transport log messages"""
|
"""The name of the VRF to transport log messages. Defaults to `default`."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyLoggingHosts."""
|
||||||
output = self.instance_commands[0].text_output
|
output = self.instance_commands[0].text_output
|
||||||
not_configured = []
|
not_configured = []
|
||||||
for host in self.inputs.hosts:
|
for host in self.inputs.hosts:
|
||||||
pattern = rf"Logging to '{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)):
|
if not re.search(pattern, _get_logging_states(self.logger, output)):
|
||||||
not_configured.append(str(host))
|
not_configured.append(str(host))
|
||||||
|
|
||||||
|
@ -130,24 +171,32 @@ class VerifyLoggingHosts(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyLoggingLogsGeneration(AntaTest):
|
class VerifyLoggingLogsGeneration(AntaTest):
|
||||||
"""
|
"""Verifies if logs are generated.
|
||||||
Verifies if logs are generated.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if logs are generated.
|
----------------
|
||||||
* failure: The test will fail if logs are NOT generated.
|
* Success: The test will pass if logs are generated.
|
||||||
|
* Failure: The test will fail if logs are NOT generated.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.logging:
|
||||||
|
- VerifyLoggingLogsGeneration:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyLoggingLogsGeneration"
|
name = "VerifyLoggingLogsGeneration"
|
||||||
description = "Verifies if logs are generated."
|
description = "Verifies if logs are generated."
|
||||||
categories = ["logging"]
|
categories: ClassVar[list[str]] = ["logging"]
|
||||||
commands = [
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||||
AntaCommand(command="send log level informational message ANTA VerifyLoggingLogsGeneration validation"),
|
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),
|
AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
|
||||||
]
|
]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyLoggingLogsGeneration."""
|
||||||
log_pattern = r"ANTA VerifyLoggingLogsGeneration validation"
|
log_pattern = r"ANTA VerifyLoggingLogsGeneration validation"
|
||||||
output = self.instance_commands[1].text_output
|
output = self.instance_commands[1].text_output
|
||||||
lines = output.strip().split("\n")[::-1]
|
lines = output.strip().split("\n")[::-1]
|
||||||
|
@ -159,25 +208,33 @@ class VerifyLoggingLogsGeneration(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyLoggingHostname(AntaTest):
|
class VerifyLoggingHostname(AntaTest):
|
||||||
"""
|
"""Verifies if logs are generated with the device FQDN.
|
||||||
Verifies if logs are generated with the device FQDN.
|
|
||||||
|
|
||||||
Expected Results:
|
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.
|
* 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.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.logging:
|
||||||
|
- VerifyLoggingHostname:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyLoggingHostname"
|
name = "VerifyLoggingHostname"
|
||||||
description = "Verifies if logs are generated with the device FQDN."
|
description = "Verifies if logs are generated with the device FQDN."
|
||||||
categories = ["logging"]
|
categories: ClassVar[list[str]] = ["logging"]
|
||||||
commands = [
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||||
AntaCommand(command="show hostname"),
|
AntaCommand(command="show hostname", revision=1),
|
||||||
AntaCommand(command="send log level informational message ANTA VerifyLoggingHostname validation"),
|
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),
|
AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
|
||||||
]
|
]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyLoggingHostname."""
|
||||||
output_hostname = self.instance_commands[0].json_output
|
output_hostname = self.instance_commands[0].json_output
|
||||||
output_logging = self.instance_commands[2].text_output
|
output_logging = self.instance_commands[2].text_output
|
||||||
fqdn = output_hostname["fqdn"]
|
fqdn = output_hostname["fqdn"]
|
||||||
|
@ -195,24 +252,32 @@ class VerifyLoggingHostname(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyLoggingTimestamp(AntaTest):
|
class VerifyLoggingTimestamp(AntaTest):
|
||||||
"""
|
"""Verifies if logs are generated with the appropriate timestamp.
|
||||||
Verifies if logs are generated with the approprate timestamp.
|
|
||||||
|
|
||||||
Expected Results:
|
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.
|
* Success: The test will pass if logs are generated with the appropriate timestamp.
|
||||||
|
* Failure: The test will fail if logs are NOT generated with the appropriate timestamp.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.logging:
|
||||||
|
- VerifyLoggingTimestamp:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyLoggingTimestamp"
|
name = "VerifyLoggingTimestamp"
|
||||||
description = "Verifies if logs are generated with the appropriate timestamp."
|
description = "Verifies if logs are generated with the riate timestamp."
|
||||||
categories = ["logging"]
|
categories: ClassVar[list[str]] = ["logging"]
|
||||||
commands = [
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||||
AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation"),
|
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),
|
AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
|
||||||
]
|
]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyLoggingTimestamp."""
|
||||||
log_pattern = r"ANTA VerifyLoggingTimestamp validation"
|
log_pattern = r"ANTA VerifyLoggingTimestamp validation"
|
||||||
timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}-\d{2}:\d{2}"
|
timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}-\d{2}:\d{2}"
|
||||||
output = self.instance_commands[1].text_output
|
output = self.instance_commands[1].text_output
|
||||||
|
@ -229,21 +294,29 @@ class VerifyLoggingTimestamp(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyLoggingAccounting(AntaTest):
|
class VerifyLoggingAccounting(AntaTest):
|
||||||
"""
|
"""Verifies if AAA accounting logs are generated.
|
||||||
Verifies if AAA accounting logs are generated.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if AAA accounting logs are generated.
|
----------------
|
||||||
* failure: The test will fail if AAA accounting logs are NOT generated.
|
* 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"
|
name = "VerifyLoggingAccounting"
|
||||||
description = "Verifies if AAA accounting logs are generated."
|
description = "Verifies if AAA accounting logs are generated."
|
||||||
categories = ["logging"]
|
categories: ClassVar[list[str]] = ["logging"]
|
||||||
commands = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyLoggingAccounting."""
|
||||||
pattern = r"cmd=show aaa accounting logs"
|
pattern = r"cmd=show aaa accounting logs"
|
||||||
output = self.instance_commands[0].text_output
|
output = self.instance_commands[0].text_output
|
||||||
if re.search(pattern, output):
|
if re.search(pattern, output):
|
||||||
|
@ -253,24 +326,29 @@ class VerifyLoggingAccounting(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyLoggingErrors(AntaTest):
|
class VerifyLoggingErrors(AntaTest):
|
||||||
"""
|
"""Verifies there are no syslog messages with a severity of ERRORS or higher.
|
||||||
This test verifies there are no syslog messages with a severity of ERRORS or higher.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if there are NO syslog messages with a severity of ERRORS or higher.
|
----------------
|
||||||
* failure: The test will fail if ERRORS or higher syslog messages are present.
|
* 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"
|
name = "VerifyLoggingErrors"
|
||||||
description = "This test verifies there are no syslog messages with a severity of ERRORS or higher."
|
description = "Verifies there are no syslog messages with a severity of ERRORS or higher."
|
||||||
categories = ["logging"]
|
categories: ClassVar[list[str]] = ["logging"]
|
||||||
commands = [AntaCommand(command="show logging threshold errors", ofmt="text")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging threshold errors", ofmt="text")]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
"""
|
"""Main test function for VerifyLoggingErrors."""
|
||||||
Run VerifyLoggingWarning validation
|
|
||||||
"""
|
|
||||||
command_output = self.instance_commands[0].text_output
|
command_output = self.instance_commands[0].text_output
|
||||||
|
|
||||||
if len(command_output) == 0:
|
if len(command_output) == 0:
|
||||||
|
|
|
@ -1,39 +1,49 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to Multi-chassis Link Aggregation (MLAG) tests."""
|
||||||
Test functions related to Multi-chassis Link Aggregation (MLAG)
|
|
||||||
"""
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from 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.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):
|
class VerifyMlagStatus(AntaTest):
|
||||||
"""
|
"""Verifies the health status of the MLAG configuration.
|
||||||
This test verifies the health status of the MLAG configuration.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the MLAG state is 'active', negotiation status is 'connected',
|
----------------
|
||||||
|
* Success: The test will pass if the MLAG state is 'active', negotiation status is 'connected',
|
||||||
peer-link status and local interface status are 'up'.
|
peer-link status and local interface status are 'up'.
|
||||||
* failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected',
|
* Failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected',
|
||||||
peer-link status or local interface status are not 'up'.
|
peer-link status or local interface status are not 'up'.
|
||||||
* skipped: The test will be skipped if MLAG is 'disabled'.
|
* Skipped: The test will be skipped if MLAG is 'disabled'.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.mlag:
|
||||||
|
- VerifyMlagStatus:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyMlagStatus"
|
name = "VerifyMlagStatus"
|
||||||
description = "Verifies the health status of the MLAG configuration."
|
description = "Verifies the health status of the MLAG configuration."
|
||||||
categories = ["mlag"]
|
categories: ClassVar[list[str]] = ["mlag"]
|
||||||
commands = [AntaCommand(command="show mlag", ofmt="json")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyMlagStatus."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if command_output["state"] == "disabled":
|
if command_output["state"] == "disabled":
|
||||||
self.result.is_skipped("MLAG is disabled")
|
self.result.is_skipped("MLAG is disabled")
|
||||||
|
@ -52,22 +62,30 @@ class VerifyMlagStatus(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyMlagInterfaces(AntaTest):
|
class VerifyMlagInterfaces(AntaTest):
|
||||||
"""
|
"""Verifies there are no inactive or active-partial MLAG ports.
|
||||||
This test verifies there are no inactive or active-partial MLAG ports.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if there are NO inactive or active-partial MLAG ports.
|
----------------
|
||||||
* failure: The test will fail if there are inactive or active-partial MLAG ports.
|
* Success: The test will pass if there are NO inactive or active-partial MLAG ports.
|
||||||
* skipped: The test will be skipped if MLAG is 'disabled'.
|
* 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"
|
name = "VerifyMlagInterfaces"
|
||||||
description = "Verifies there are no inactive or active-partial MLAG ports."
|
description = "Verifies there are no inactive or active-partial MLAG ports."
|
||||||
categories = ["mlag"]
|
categories: ClassVar[list[str]] = ["mlag"]
|
||||||
commands = [AntaCommand(command="show mlag", ofmt="json")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyMlagInterfaces."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if command_output["state"] == "disabled":
|
if command_output["state"] == "disabled":
|
||||||
self.result.is_skipped("MLAG is disabled")
|
self.result.is_skipped("MLAG is disabled")
|
||||||
|
@ -79,23 +97,31 @@ class VerifyMlagInterfaces(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyMlagConfigSanity(AntaTest):
|
class VerifyMlagConfigSanity(AntaTest):
|
||||||
"""
|
"""Verifies there are no MLAG config-sanity inconsistencies.
|
||||||
This test verifies there are no MLAG config-sanity inconsistencies.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if there are NO MLAG config-sanity inconsistencies.
|
----------------
|
||||||
* failure: The test will fail if there are MLAG config-sanity inconsistencies.
|
* Success: The test will pass if there are NO MLAG config-sanity inconsistencies.
|
||||||
* skipped: The test will be skipped if MLAG is 'disabled'.
|
* Failure: The test will fail if there are MLAG config-sanity inconsistencies.
|
||||||
* error: The test will give an error if 'mlagActive' is not found in the JSON response.
|
* 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"
|
name = "VerifyMlagConfigSanity"
|
||||||
description = "Verifies there are no MLAG config-sanity inconsistencies."
|
description = "Verifies there are no MLAG config-sanity inconsistencies."
|
||||||
categories = ["mlag"]
|
categories: ClassVar[list[str]] = ["mlag"]
|
||||||
commands = [AntaCommand(command="show mlag config-sanity", ofmt="json")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag config-sanity", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyMlagConfigSanity."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if (mlag_status := get_value(command_output, "mlagActive")) is None:
|
if (mlag_status := get_value(command_output, "mlagActive")) is None:
|
||||||
self.result.is_error(message="Incorrect JSON response - 'mlagActive' state was not found")
|
self.result.is_error(message="Incorrect JSON response - 'mlagActive' state was not found")
|
||||||
|
@ -112,28 +138,40 @@ class VerifyMlagConfigSanity(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyMlagReloadDelay(AntaTest):
|
class VerifyMlagReloadDelay(AntaTest):
|
||||||
"""
|
"""Verifies the reload-delay parameters of the MLAG configuration.
|
||||||
This test verifies the reload-delay parameters of the MLAG configuration.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the reload-delay parameters are configured properly.
|
----------------
|
||||||
* failure: The test will fail if the reload-delay parameters are NOT configured properly.
|
* Success: The test will pass if the reload-delay parameters are configured properly.
|
||||||
* skipped: The test will be skipped if MLAG is 'disabled'.
|
* 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"
|
name = "VerifyMlagReloadDelay"
|
||||||
description = "Verifies the MLAG reload-delay parameters."
|
description = "Verifies the MLAG reload-delay parameters."
|
||||||
categories = ["mlag"]
|
categories: ClassVar[list[str]] = ["mlag"]
|
||||||
commands = [AntaCommand(command="show mlag", ofmt="json")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
reload_delay: conint(ge=0) # type: ignore
|
"""Input model for the VerifyMlagReloadDelay test."""
|
||||||
"""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
|
reload_delay: PositiveInteger
|
||||||
"""Delay (seconds) after reboot until ports that are not part of an MLAG are enabled"""
|
"""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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyMlagReloadDelay."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if command_output["state"] == "disabled":
|
if command_output["state"] == "disabled":
|
||||||
self.result.is_skipped("MLAG is disabled")
|
self.result.is_skipped("MLAG is disabled")
|
||||||
|
@ -148,32 +186,46 @@ class VerifyMlagReloadDelay(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyMlagDualPrimary(AntaTest):
|
class VerifyMlagDualPrimary(AntaTest):
|
||||||
"""
|
"""Verifies the dual-primary detection and its parameters of the MLAG configuration.
|
||||||
This test verifies the dual-primary detection and its parameters of the MLAG configuration.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the dual-primary detection is enabled and its parameters are configured properly.
|
----------------
|
||||||
* failure: The test will fail if the dual-primary detection is NOT enabled or its parameters are NOT configured properly.
|
* Success: The test will pass if the dual-primary detection is enabled and its parameters are configured properly.
|
||||||
* skipped: The test will be skipped if MLAG is 'disabled'.
|
* 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"
|
name = "VerifyMlagDualPrimary"
|
||||||
description = "Verifies the MLAG dual-primary detection parameters."
|
description = "Verifies the MLAG dual-primary detection parameters."
|
||||||
categories = ["mlag"]
|
categories: ClassVar[list[str]] = ["mlag"]
|
||||||
commands = [AntaCommand(command="show mlag detail", ofmt="json")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
detection_delay: conint(ge=0) # type: ignore
|
"""Input model for the VerifyMlagDualPrimary test."""
|
||||||
"""Delay detection (seconds)"""
|
|
||||||
|
detection_delay: PositiveInteger
|
||||||
|
"""Delay detection (seconds)."""
|
||||||
errdisabled: bool = False
|
errdisabled: bool = False
|
||||||
"""Errdisabled all interfaces when dual-primary is detected"""
|
"""Errdisabled all interfaces when dual-primary is detected."""
|
||||||
recovery_delay: conint(ge=0) # type: ignore
|
recovery_delay: PositiveInteger
|
||||||
"""Delay (seconds) after dual-primary detection resolves until non peer-link ports that are part of an MLAG are enabled"""
|
"""Delay (seconds) after dual-primary detection resolves until non peer-link ports that are part of an MLAG are enabled."""
|
||||||
recovery_delay_non_mlag: conint(ge=0) # type: ignore
|
recovery_delay_non_mlag: PositiveInteger
|
||||||
"""Delay (seconds) after dual-primary detection resolves until ports that are not part of an MLAG are enabled"""
|
"""Delay (seconds) after dual-primary detection resolves until ports that are not part of an MLAG are enabled."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyMlagDualPrimary."""
|
||||||
errdisabled_action = "errdisableAllInterfaces" if self.inputs.errdisabled else "none"
|
errdisabled_action = "errdisableAllInterfaces" if self.inputs.errdisabled else "none"
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if command_output["state"] == "disabled":
|
if command_output["state"] == "disabled":
|
||||||
|
@ -196,28 +248,37 @@ class VerifyMlagDualPrimary(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyMlagPrimaryPriority(AntaTest):
|
class VerifyMlagPrimaryPriority(AntaTest):
|
||||||
"""
|
"""Verify the MLAG (Multi-Chassis Link Aggregation) primary priority.
|
||||||
Test class to verify the MLAG (Multi-Chassis Link Aggregation) primary priority.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* Success: The test will pass if the MLAG state is set as 'primary' and the priority matches the input.
|
----------------
|
||||||
* Failure: The test will fail if the MLAG state is not 'primary' or the priority doesn't match the input.
|
* Success: The test will pass if the MLAG state is set as 'primary' and the priority matches the input.
|
||||||
* Skipped: The test will be skipped if MLAG is 'disabled'.
|
* 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"
|
name = "VerifyMlagPrimaryPriority"
|
||||||
description = "Verifies the configuration of the MLAG primary priority."
|
description = "Verifies the configuration of the MLAG primary priority."
|
||||||
categories = ["mlag"]
|
categories: ClassVar[list[str]] = ["mlag"]
|
||||||
commands = [AntaCommand(command="show mlag detail")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
class Input(AntaTest.Input):
|
||||||
"""Inputs for the VerifyMlagPrimaryPriority test."""
|
"""Input model for the VerifyMlagPrimaryPriority test."""
|
||||||
|
|
||||||
primary_priority: MlagPriority
|
primary_priority: MlagPriority
|
||||||
"""The expected MLAG primary priority."""
|
"""The expected MLAG primary priority."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyMlagPrimaryPriority."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
# Skip the test if MLAG is disabled
|
# Skip the test if MLAG is disabled
|
||||||
|
@ -235,5 +296,5 @@ class VerifyMlagPrimaryPriority(AntaTest):
|
||||||
# Check primary priority
|
# Check primary priority
|
||||||
if primary_priority != self.inputs.primary_priority:
|
if primary_priority != self.inputs.primary_priority:
|
||||||
self.result.is_failure(
|
self.result.is_failure(
|
||||||
f"The primary priority does not match expected. Expected `{self.inputs.primary_priority}`, but found `{primary_priority}` instead."
|
f"The primary priority does not match expected. Expected `{self.inputs.primary_priority}`, but found `{primary_priority}` instead.",
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,36 +1,54 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to multicast and IGMP tests."""
|
||||||
Test functions related to multicast
|
|
||||||
"""
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# Need to keep Dict for pydantic in python 3.8
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
from anta.custom_types import Vlan
|
from anta.custom_types import Vlan
|
||||||
from anta.models import AntaCommand, AntaTest
|
from anta.models import AntaCommand, AntaTest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.models import AntaTemplate
|
||||||
|
|
||||||
|
|
||||||
class VerifyIGMPSnoopingVlans(AntaTest):
|
class VerifyIGMPSnoopingVlans(AntaTest):
|
||||||
"""
|
"""Verifies the IGMP snooping status for the provided VLANs.
|
||||||
Verifies the IGMP snooping configuration for some VLANs.
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if the IGMP snooping status matches the expected status for the provided VLANs.
|
||||||
|
* Failure: The test will fail if the IGMP snooping status does not match the expected status for the provided VLANs.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.multicast:
|
||||||
|
- VerifyIGMPSnoopingVlans:
|
||||||
|
vlans:
|
||||||
|
10: False
|
||||||
|
12: False
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyIGMPSnoopingVlans"
|
name = "VerifyIGMPSnoopingVlans"
|
||||||
description = "Verifies the IGMP snooping configuration for some VLANs."
|
description = "Verifies the IGMP snooping status for the provided VLANs."
|
||||||
categories = ["multicast", "igmp"]
|
categories: ClassVar[list[str]] = ["multicast"]
|
||||||
commands = [AntaCommand(command="show ip igmp snooping")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
vlans: Dict[Vlan, bool]
|
"""Input model for the VerifyIGMPSnoopingVlans test."""
|
||||||
"""Dictionary of VLANs with associated IGMP configuration status (True=enabled, False=disabled)"""
|
|
||||||
|
vlans: dict[Vlan, bool]
|
||||||
|
"""Dictionary with VLAN ID and whether IGMP snooping must be enabled (True) or disabled (False)."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyIGMPSnoopingVlans."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
for vlan, enabled in self.inputs.vlans.items():
|
for vlan, enabled in self.inputs.vlans.items():
|
||||||
|
@ -44,21 +62,36 @@ class VerifyIGMPSnoopingVlans(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyIGMPSnoopingGlobal(AntaTest):
|
class VerifyIGMPSnoopingGlobal(AntaTest):
|
||||||
"""
|
"""Verifies the IGMP snooping global status.
|
||||||
Verifies the IGMP snooping global configuration.
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if the IGMP snooping global status matches the expected status.
|
||||||
|
* Failure: The test will fail if the IGMP snooping global status does not match the expected status.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.multicast:
|
||||||
|
- VerifyIGMPSnoopingGlobal:
|
||||||
|
enabled: True
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyIGMPSnoopingGlobal"
|
name = "VerifyIGMPSnoopingGlobal"
|
||||||
description = "Verifies the IGMP snooping global configuration."
|
description = "Verifies the IGMP snooping global configuration."
|
||||||
categories = ["multicast", "igmp"]
|
categories: ClassVar[list[str]] = ["multicast"]
|
||||||
commands = [AntaCommand(command="show ip igmp snooping")]
|
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
|
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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyIGMPSnoopingGlobal."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
igmp_state = command_output["igmpSnoopingState"]
|
igmp_state = command_output["igmpSnoopingState"]
|
||||||
|
|
|
@ -1,36 +1,53 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to ASIC profile tests."""
|
||||||
Test functions related to ASIC profiles
|
|
||||||
"""
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Literal
|
from typing import TYPE_CHECKING, ClassVar, Literal
|
||||||
|
|
||||||
from anta.decorators import skip_on_platforms
|
from anta.decorators import skip_on_platforms
|
||||||
from anta.models import AntaCommand, AntaTest
|
from anta.models import AntaCommand, AntaTest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.models import AntaTemplate
|
||||||
|
|
||||||
|
|
||||||
class VerifyUnifiedForwardingTableMode(AntaTest):
|
class VerifyUnifiedForwardingTableMode(AntaTest):
|
||||||
"""
|
"""Verifies the device is using the expected UFT (Unified Forwarding Table) mode.
|
||||||
Verifies the device is using the expected Unified Forwarding Table mode.
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if the device is using the expected UFT mode.
|
||||||
|
* Failure: The test will fail if the device is not using the expected UFT mode.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.profiles:
|
||||||
|
- VerifyUnifiedForwardingTableMode:
|
||||||
|
mode: 3
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyUnifiedForwardingTableMode"
|
name = "VerifyUnifiedForwardingTableMode"
|
||||||
description = ""
|
description = "Verifies the device is using the expected UFT mode."
|
||||||
categories = ["profiles"]
|
categories: ClassVar[list[str]] = ["profiles"]
|
||||||
commands = [AntaCommand(command="show platform trident forwarding-table partition", ofmt="json")]
|
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"]
|
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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyUnifiedForwardingTableMode."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if command_output["uftMode"] == str(self.inputs.mode):
|
if command_output["uftMode"] == str(self.inputs.mode):
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
@ -39,22 +56,37 @@ class VerifyUnifiedForwardingTableMode(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyTcamProfile(AntaTest):
|
class VerifyTcamProfile(AntaTest):
|
||||||
"""
|
"""Verifies that the device is using the provided Ternary Content-Addressable Memory (TCAM) profile.
|
||||||
Verifies the device is using the configured TCAM profile.
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if the provided TCAM profile is actually running on the device.
|
||||||
|
* Failure: The test will fail if the provided TCAM profile is not running on the device.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.profiles:
|
||||||
|
- VerifyTcamProfile:
|
||||||
|
profile: vxlan-routing
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyTcamProfile"
|
name = "VerifyTcamProfile"
|
||||||
description = "Verify that the assigned TCAM profile is actually running on the device"
|
description = "Verifies the device TCAM profile."
|
||||||
categories = ["profiles"]
|
categories: ClassVar[list[str]] = ["profiles"]
|
||||||
commands = [AntaCommand(command="show hardware tcam profile", ofmt="json")]
|
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
|
profile: str
|
||||||
"""Expected TCAM profile"""
|
"""Expected TCAM profile."""
|
||||||
|
|
||||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyTcamProfile."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if command_output["pmfProfiles"]["FixedSystem"]["status"] == command_output["pmfProfiles"]["FixedSystem"]["config"] == self.inputs.profile:
|
if command_output["pmfProfiles"]["FixedSystem"]["status"] == command_output["pmfProfiles"]["FixedSystem"]["config"] == self.inputs.profile:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
|
|
@ -1,33 +1,235 @@
|
||||||
# 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
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to PTP tests."""
|
||||||
Test functions related to PTP (Precision Time Protocol) in EOS
|
|
||||||
"""
|
# Mypy does not understand AntaTest.Input typing
|
||||||
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
|
from anta.decorators import skip_on_platforms
|
||||||
from anta.models import AntaCommand, AntaTest
|
from anta.models import AntaCommand, AntaTest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.models import AntaTemplate
|
||||||
|
|
||||||
class VerifyPtpStatus(AntaTest):
|
|
||||||
"""
|
|
||||||
Verifies whether the PTP agent is enabled globally.
|
|
||||||
|
|
||||||
Expected Results:
|
class VerifyPtpModeStatus(AntaTest):
|
||||||
* success: The test will pass if the PTP agent is enabled globally.
|
"""Verifies that the device is configured as a Precision Time Protocol (PTP) Boundary Clock (BC).
|
||||||
* failure: The test will fail if the PTP agent is enabled globally.
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if the device is a BC.
|
||||||
|
* Failure: The test will fail if the device is not a BC.
|
||||||
|
* Error: The test will error if the 'ptpMode' variable is not present in the command output.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.ptp:
|
||||||
|
- VerifyPtpModeStatus:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyPtpStatus"
|
name = "VerifyPtpModeStatus"
|
||||||
description = "Verifies if the PTP agent is enabled."
|
description = "Verifies that the device is configured as a PTP Boundary Clock."
|
||||||
categories = ["ptp"]
|
categories: ClassVar[list[str]] = ["ptp"]
|
||||||
commands = [AntaCommand(command="show ptp")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)]
|
||||||
|
|
||||||
|
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyPtpModeStatus."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
|
|
||||||
if "ptpMode" in command_output.keys():
|
if (ptp_mode := command_output.get("ptpMode")) is None:
|
||||||
|
self.result.is_error("'ptpMode' variable is not present in the command output")
|
||||||
|
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.
|
||||||
|
* Error: The test will error if the 'gmClockIdentity' variable is not present in the command output.
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
name = "VerifyPtpGMStatus"
|
||||||
|
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_error("'ptpClockSummary' variable is not present in the command output")
|
||||||
|
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.
|
||||||
|
* Error: The test will error if the 'lastSyncTime' variable is not present in the command output.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.ptp:
|
||||||
|
- VerifyPtpLockStatus:
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "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_error("'ptpClockSummary' variable is not present in the command output")
|
||||||
|
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.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.ptp:
|
||||||
|
- VerifyPtpOffset:
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "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:
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "VerifyPtpPortModeStatus"
|
||||||
|
description = "Verifies the PTP interfaces state."
|
||||||
|
categories: ClassVar[list[str]] = ["ptp"]
|
||||||
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)]
|
||||||
|
|
||||||
|
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||||
|
@AntaTest.anta_test
|
||||||
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyPtpPortModeStatus."""
|
||||||
|
valid_state = ("psMaster", "psSlave", "psPassive", "psDisabled")
|
||||||
|
command_output = self.instance_commands[0].json_output
|
||||||
|
|
||||||
|
if not command_output["ptpIntfSummaries"]:
|
||||||
|
self.result.is_failure("No interfaces are PTP enabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
invalid_interfaces = [
|
||||||
|
interface
|
||||||
|
for interface in command_output["ptpIntfSummaries"]
|
||||||
|
for vlan in command_output["ptpIntfSummaries"][interface]["ptpIntfVlanSummaries"]
|
||||||
|
if vlan["portState"] not in valid_state
|
||||||
|
]
|
||||||
|
|
||||||
|
if not invalid_interfaces:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
self.result.is_failure("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.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
|
"""Package related to routing tests."""
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,41 +1,52 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to generic routing tests."""
|
||||||
Generic routing test functions
|
|
||||||
"""
|
# Mypy does not understand AntaTest.Input typing
|
||||||
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ipaddress import IPv4Address, ip_interface
|
from ipaddress import IPv4Address, ip_interface
|
||||||
|
from typing import ClassVar, Literal
|
||||||
# Need to keep List for pydantic in python 3.8
|
|
||||||
from typing import List, Literal
|
|
||||||
|
|
||||||
from pydantic import model_validator
|
from pydantic import model_validator
|
||||||
|
|
||||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||||
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
|
||||||
# mypy: disable-error-code=attr-defined
|
|
||||||
|
|
||||||
|
|
||||||
class VerifyRoutingProtocolModel(AntaTest):
|
class VerifyRoutingProtocolModel(AntaTest):
|
||||||
"""
|
"""Verifies the configured routing protocol model is the one we expect.
|
||||||
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.
|
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"
|
name = "VerifyRoutingProtocolModel"
|
||||||
description = "Verifies the configured routing protocol model."
|
description = "Verifies the configured routing protocol model."
|
||||||
categories = ["routing"]
|
categories: ClassVar[list[str]] = ["routing"]
|
||||||
commands = [AntaCommand(command="show ip route summary", revision=3)]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)]
|
||||||
|
|
||||||
|
class Input(AntaTest.Input):
|
||||||
|
"""Input model for the VerifyRoutingProtocolModel test."""
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
|
||||||
model: Literal["multi-agent", "ribd"] = "multi-agent"
|
model: Literal["multi-agent", "ribd"] = "multi-agent"
|
||||||
"""Expected routing protocol model"""
|
"""Expected routing protocol model. Defaults to `multi-agent`."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyRoutingProtocolModel."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
configured_model = command_output["protoModelStatus"]["configuredProtoModel"]
|
configured_model = command_output["protoModelStatus"]["configuredProtoModel"]
|
||||||
operating_model = command_output["protoModelStatus"]["operatingProtoModel"]
|
operating_model = command_output["protoModelStatus"]["operatingProtoModel"]
|
||||||
|
@ -46,31 +57,48 @@ class VerifyRoutingProtocolModel(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyRoutingTableSize(AntaTest):
|
class VerifyRoutingTableSize(AntaTest):
|
||||||
"""
|
"""Verifies the size of the IP routing table of the default VRF.
|
||||||
Verifies the size of the IP routing table (default VRF).
|
|
||||||
Should be between the two provided thresholds.
|
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"
|
name = "VerifyRoutingTableSize"
|
||||||
description = "Verifies the size of the IP routing table (default VRF). Should be between the two provided thresholds."
|
description = "Verifies the size of the IP routing table of the default VRF."
|
||||||
categories = ["routing"]
|
categories: ClassVar[list[str]] = ["routing"]
|
||||||
commands = [AntaCommand(command="show ip route summary", revision=3)]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)]
|
||||||
|
|
||||||
|
class Input(AntaTest.Input):
|
||||||
|
"""Input model for the VerifyRoutingTableSize test."""
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
|
||||||
minimum: int
|
minimum: int
|
||||||
"""Expected minimum routing table (default VRF) size"""
|
"""Expected minimum routing table size."""
|
||||||
maximum: int
|
maximum: int
|
||||||
"""Expected maximum routing table (default VRF) size"""
|
"""Expected maximum routing table size."""
|
||||||
|
|
||||||
@model_validator(mode="after") # type: ignore
|
@model_validator(mode="after") # type: ignore[misc]
|
||||||
def check_min_max(self) -> AntaTest.Input:
|
def check_min_max(self) -> AntaTest.Input:
|
||||||
"""Validate that maximum is greater than minimum"""
|
"""Validate that maximum is greater than minimum."""
|
||||||
if self.minimum > self.maximum:
|
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
|
return self
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyRoutingTableSize."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
total_routes = int(command_output["vrfs"]["default"]["totalRoutes"])
|
total_routes = int(command_output["vrfs"]["default"]["totalRoutes"])
|
||||||
if self.inputs.minimum <= total_routes <= self.inputs.maximum:
|
if self.inputs.minimum <= total_routes <= self.inputs.maximum:
|
||||||
|
@ -80,37 +108,52 @@ class VerifyRoutingTableSize(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyRoutingTableEntry(AntaTest):
|
class VerifyRoutingTableEntry(AntaTest):
|
||||||
"""
|
"""Verifies that the provided routes are present in the routing table of a specified VRF.
|
||||||
This test verifies that the provided routes are present in the routing table of a specified VRF.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the provided routes are present in the routing table.
|
----------------
|
||||||
* failure: The test will fail if one or many provided routes are missing from the routing table.
|
* Success: The test will pass if the provided routes are present in the routing table.
|
||||||
|
* Failure: The test will fail if one or many provided routes are missing from the routing table.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.routing:
|
||||||
|
generic:
|
||||||
|
- VerifyRoutingTableEntry:
|
||||||
|
vrf: default
|
||||||
|
routes:
|
||||||
|
- 10.1.0.1
|
||||||
|
- 10.1.0.2
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyRoutingTableEntry"
|
name = "VerifyRoutingTableEntry"
|
||||||
description = "Verifies that the provided routes are present in the routing table of a specified VRF."
|
description = "Verifies that the provided routes are present in the routing table of a specified VRF."
|
||||||
categories = ["routing"]
|
categories: ClassVar[list[str]] = ["routing"]
|
||||||
commands = [AntaTemplate(template="show ip route vrf {vrf} {route}")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip route vrf {vrf} {route}", 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: str = "default"
|
||||||
"""VRF context"""
|
"""VRF context. Defaults to `default` VRF."""
|
||||||
routes: List[IPv4Address]
|
routes: list[IPv4Address]
|
||||||
"""Routes to verify"""
|
"""List of routes to verify."""
|
||||||
|
|
||||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||||
|
"""Render the template for each route in the input list."""
|
||||||
return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes]
|
return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyRoutingTableEntry."""
|
||||||
missing_routes = []
|
missing_routes = []
|
||||||
|
|
||||||
for command in self.instance_commands:
|
for command in self.instance_commands:
|
||||||
if "vrf" in command.params and "route" in command.params:
|
vrf, route = command.params.vrf, command.params.route
|
||||||
vrf, route = command.params["vrf"], command.params["route"]
|
if len(routes := command.json_output["vrfs"][vrf]["routes"]) == 0 or route != ip_interface(next(iter(routes))).ip:
|
||||||
if len(routes := command.json_output["vrfs"][vrf]["routes"]) == 0 or route != ip_interface(list(routes)[0]).ip:
|
missing_routes.append(str(route))
|
||||||
missing_routes.append(str(route))
|
|
||||||
|
|
||||||
if not missing_routes:
|
if not missing_routes:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
|
|
@ -1,61 +1,116 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to OSPF tests."""
|
||||||
OSPF test functions
|
|
||||||
"""
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any, ClassVar
|
||||||
|
|
||||||
from anta.models import AntaCommand, AntaTest
|
from anta.models import AntaCommand, AntaTest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.models import AntaTemplate
|
||||||
|
|
||||||
|
|
||||||
def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int:
|
def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int:
|
||||||
"""
|
"""Count the number of OSPF neighbors.
|
||||||
Count the number of OSPF neighbors
|
|
||||||
|
Args:
|
||||||
|
----
|
||||||
|
ospf_neighbor_json: The JSON output of the `show ip ospf neighbor` command.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
int: The number of OSPF neighbors.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
count = 0
|
count = 0
|
||||||
for _, vrf_data in ospf_neighbor_json["vrfs"].items():
|
for vrf_data in ospf_neighbor_json["vrfs"].values():
|
||||||
for _, instance_data in vrf_data["instList"].items():
|
for instance_data in vrf_data["instList"].values():
|
||||||
count += len(instance_data.get("ospfNeighborEntries", []))
|
count += len(instance_data.get("ospfNeighborEntries", []))
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]:
|
def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
"""Return the OSPF neighbors whose adjacency state is not `full`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
----
|
||||||
|
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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
----
|
||||||
|
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 = []
|
return [
|
||||||
for vrf, vrf_data in ospf_neighbor_json["vrfs"].items():
|
{
|
||||||
for instance, instance_data in vrf_data["instList"].items():
|
"vrf": vrf,
|
||||||
for neighbor_data in instance_data.get("ospfNeighborEntries", []):
|
"instance": instance,
|
||||||
if (state := neighbor_data["adjacencyState"]) != "full":
|
"maxLsa": instance_data.get("maxLsaInformation", {}).get("maxLsa"),
|
||||||
not_full_neighbors.append(
|
"maxLsaThreshold": instance_data.get("maxLsaInformation", {}).get("maxLsaThreshold"),
|
||||||
{
|
"numLsa": instance_data.get("lsaInformation", {}).get("numLsa"),
|
||||||
"vrf": vrf,
|
}
|
||||||
"instance": instance,
|
for vrf, vrf_data in ospf_process_json.get("vrfs", {}).items()
|
||||||
"neighbor": neighbor_data["routerId"],
|
for instance, instance_data in vrf_data.get("instList", {}).items()
|
||||||
"state": state,
|
]
|
||||||
}
|
|
||||||
)
|
|
||||||
return not_full_neighbors
|
|
||||||
|
|
||||||
|
|
||||||
class VerifyOSPFNeighborState(AntaTest):
|
class VerifyOSPFNeighborState(AntaTest):
|
||||||
"""
|
"""Verifies all OSPF neighbors are in FULL state.
|
||||||
Verifies all OSPF neighbors are in FULL state.
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if all OSPF neighbors are in FULL state.
|
||||||
|
* Failure: The test will fail if some OSPF neighbors are not in FULL state.
|
||||||
|
* Skipped: The test will be skipped if no OSPF neighbor is found.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.routing:
|
||||||
|
ospf:
|
||||||
|
- VerifyOSPFNeighborState:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyOSPFNeighborState"
|
name = "VerifyOSPFNeighborState"
|
||||||
description = "Verifies all OSPF neighbors are in FULL state."
|
description = "Verifies all OSPF neighbors are in FULL state."
|
||||||
categories = ["ospf"]
|
categories: ClassVar[list[str]] = ["ospf"]
|
||||||
commands = [AntaCommand(command="show ip ospf neighbor")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyOSPFNeighborState."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if _count_ospf_neighbor(command_output) == 0:
|
if _count_ospf_neighbor(command_output) == 0:
|
||||||
self.result.is_skipped("no OSPF neighbor found")
|
self.result.is_skipped("no OSPF neighbor found")
|
||||||
|
@ -67,21 +122,38 @@ class VerifyOSPFNeighborState(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyOSPFNeighborCount(AntaTest):
|
class VerifyOSPFNeighborCount(AntaTest):
|
||||||
"""
|
"""Verifies the number of OSPF neighbors in FULL state is the one we expect.
|
||||||
Verifies the number of OSPF neighbors in FULL state is the one we expect.
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if the number of OSPF neighbors in FULL state is the one we expect.
|
||||||
|
* Failure: The test will fail if the number of OSPF neighbors in FULL state is not the one we expect.
|
||||||
|
* Skipped: The test will be skipped if no OSPF neighbor is found.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.routing:
|
||||||
|
ospf:
|
||||||
|
- VerifyOSPFNeighborCount:
|
||||||
|
number: 3
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyOSPFNeighborCount"
|
name = "VerifyOSPFNeighborCount"
|
||||||
description = "Verifies the number of OSPF neighbors in FULL state is the one we expect."
|
description = "Verifies the number of OSPF neighbors in FULL state is the one we expect."
|
||||||
categories = ["ospf"]
|
categories: ClassVar[list[str]] = ["ospf"]
|
||||||
commands = [AntaCommand(command="show ip ospf neighbor")]
|
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
|
number: int
|
||||||
"""The expected number of OSPF neighbors in FULL state"""
|
"""The expected number of OSPF neighbors in FULL state."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyOSPFNeighborCount."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if (neighbor_count := _count_ospf_neighbor(command_output)) == 0:
|
if (neighbor_count := _count_ospf_neighbor(command_output)) == 0:
|
||||||
self.result.is_skipped("no OSPF neighbor found")
|
self.result.is_skipped("no OSPF neighbor found")
|
||||||
|
@ -90,6 +162,46 @@ class VerifyOSPFNeighborCount(AntaTest):
|
||||||
if neighbor_count != self.inputs.number:
|
if neighbor_count != self.inputs.number:
|
||||||
self.result.is_failure(f"device has {neighbor_count} neighbors (expected {self.inputs.number})")
|
self.result.is_failure(f"device has {neighbor_count} neighbors (expected {self.inputs.number})")
|
||||||
not_full_neighbors = _get_not_full_ospf_neighbors(command_output)
|
not_full_neighbors = _get_not_full_ospf_neighbors(command_output)
|
||||||
print(not_full_neighbors)
|
|
||||||
if not_full_neighbors:
|
if not_full_neighbors:
|
||||||
self.result.is_failure(f"Some neighbors are not correctly configured: {not_full_neighbors}.")
|
self.result.is_failure(f"Some neighbors are not correctly configured: {not_full_neighbors}.")
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyOSPFMaxLSA(AntaTest):
|
||||||
|
"""Verifies LSAs present in the OSPF link state database did not cross the maximum LSA Threshold.
|
||||||
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if all OSPF instances did not cross the maximum LSA Threshold.
|
||||||
|
* Failure: The test will fail if some OSPF instances crossed the maximum LSA Threshold.
|
||||||
|
* Skipped: The test will be skipped if no OSPF instance is found.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.routing:
|
||||||
|
ospf:
|
||||||
|
- VerifyOSPFMaxLSA:
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "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,44 +1,50 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to the EOS various security tests."""
|
||||||
Test functions related to the EOS various security settings
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import List, Union
|
from ipaddress import IPv4Address
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
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.models import AntaCommand, AntaTemplate, AntaTest
|
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||||
from anta.tools.get_item import get_item
|
from anta.tools import get_failed_logs, get_item, get_value
|
||||||
from anta.tools.get_value import get_value
|
|
||||||
from anta.tools.utils import get_failed_logs
|
|
||||||
|
|
||||||
|
|
||||||
class VerifySSHStatus(AntaTest):
|
class VerifySSHStatus(AntaTest):
|
||||||
"""
|
"""Verifies if the SSHD agent is disabled in the default VRF.
|
||||||
Verifies if the SSHD agent is disabled in the default VRF.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the SSHD agent is disabled in the default VRF.
|
----------------
|
||||||
* failure: The test will fail if the SSHD agent is NOT disabled in the default VRF.
|
* Success: The test will pass if the SSHD agent is disabled in the default VRF.
|
||||||
|
* Failure: The test will fail if the SSHD agent is NOT disabled in the default VRF.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.security:
|
||||||
|
- VerifySSHStatus:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifySSHStatus"
|
name = "VerifySSHStatus"
|
||||||
description = "Verifies if the SSHD agent is disabled in the default VRF."
|
description = "Verifies if the SSHD agent is disabled in the default VRF."
|
||||||
categories = ["security"]
|
categories: ClassVar[list[str]] = ["security"]
|
||||||
commands = [AntaCommand(command="show management ssh", ofmt="text")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh", ofmt="text")]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifySSHStatus."""
|
||||||
command_output = self.instance_commands[0].text_output
|
command_output = self.instance_commands[0].text_output
|
||||||
|
|
||||||
line = [line for line in command_output.split("\n") if line.startswith("SSHD status")][0]
|
line = next(line for line in command_output.split("\n") if line.startswith("SSHD status"))
|
||||||
status = line.split("is ")[1]
|
status = line.split("is ")[1]
|
||||||
|
|
||||||
if status == "disabled":
|
if status == "disabled":
|
||||||
|
@ -48,97 +54,127 @@ class VerifySSHStatus(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifySSHIPv4Acl(AntaTest):
|
class VerifySSHIPv4Acl(AntaTest):
|
||||||
"""
|
"""Verifies if the SSHD agent has the right number IPv4 ACL(s) configured for a specified VRF.
|
||||||
Verifies if the SSHD agent has the right number IPv4 ACL(s) configured for a specified VRF.
|
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* success: The test will pass if the SSHD agent has the provided number of IPv4 ACL(s) in the specified VRF.
|
----------------
|
||||||
* failure: The test will fail if the SSHD agent has not the right number of IPv4 ACL(s) in the specified VRF.
|
* Success: The test will pass if the SSHD agent has the provided number of IPv4 ACL(s) in the specified VRF.
|
||||||
|
* Failure: The test will fail if the SSHD agent has not the right number of IPv4 ACL(s) in the specified VRF.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.security:
|
||||||
|
- VerifySSHIPv4Acl:
|
||||||
|
number: 3
|
||||||
|
vrf: default
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifySSHIPv4Acl"
|
name = "VerifySSHIPv4Acl"
|
||||||
description = "Verifies if the SSHD agent has IPv4 ACL(s) configured."
|
description = "Verifies if the SSHD agent has IPv4 ACL(s) configured."
|
||||||
categories = ["security"]
|
categories: ClassVar[list[str]] = ["security"]
|
||||||
commands = [AntaCommand(command="show management ssh ip access-list summary")]
|
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
|
class Input(AntaTest.Input):
|
||||||
number: conint(ge=0) # type:ignore
|
"""Input model for the VerifySSHIPv4Acl test."""
|
||||||
"""The number of expected IPv4 ACL(s)"""
|
|
||||||
|
number: PositiveInteger
|
||||||
|
"""The number of expected IPv4 ACL(s)."""
|
||||||
vrf: str = "default"
|
vrf: str = "default"
|
||||||
"""The name of the VRF in which to check for the SSHD agent"""
|
"""The name of the VRF in which to check for the SSHD agent. Defaults to `default` VRF."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifySSHIPv4Acl."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
ipv4_acl_list = command_output["ipAclList"]["aclList"]
|
ipv4_acl_list = command_output["ipAclList"]["aclList"]
|
||||||
ipv4_acl_number = len(ipv4_acl_list)
|
ipv4_acl_number = len(ipv4_acl_list)
|
||||||
not_configured_acl_list = []
|
|
||||||
if ipv4_acl_number != self.inputs.number:
|
if ipv4_acl_number != self.inputs.number:
|
||||||
self.result.is_failure(f"Expected {self.inputs.number} SSH IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
|
self.result.is_failure(f"Expected {self.inputs.number} SSH IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
|
||||||
return
|
return
|
||||||
for ipv4_acl in ipv4_acl_list:
|
|
||||||
if self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]:
|
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"]]
|
||||||
not_configured_acl_list.append(ipv4_acl["name"])
|
|
||||||
if not_configured_acl_list:
|
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_list}")
|
self.result.is_failure(f"SSH IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
|
||||||
else:
|
else:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
|
||||||
|
|
||||||
class VerifySSHIPv6Acl(AntaTest):
|
class VerifySSHIPv6Acl(AntaTest):
|
||||||
"""
|
"""Verifies if the SSHD agent has the right number IPv6 ACL(s) configured for a specified VRF.
|
||||||
Verifies if the SSHD agent has the right number IPv6 ACL(s) configured for a specified VRF.
|
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* success: The test will pass if the SSHD agent has the provided number of IPv6 ACL(s) in the specified VRF.
|
----------------
|
||||||
* failure: The test will fail if the SSHD agent has not the right number of IPv6 ACL(s) in the specified VRF.
|
* Success: The test will pass if the SSHD agent has the provided number of IPv6 ACL(s) in the specified VRF.
|
||||||
|
* Failure: The test will fail if the SSHD agent has not the right number of IPv6 ACL(s) in the specified VRF.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.security:
|
||||||
|
- VerifySSHIPv6Acl:
|
||||||
|
number: 3
|
||||||
|
vrf: default
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifySSHIPv6Acl"
|
name = "VerifySSHIPv6Acl"
|
||||||
description = "Verifies if the SSHD agent has IPv6 ACL(s) configured."
|
description = "Verifies if the SSHD agent has IPv6 ACL(s) configured."
|
||||||
categories = ["security"]
|
categories: ClassVar[list[str]] = ["security"]
|
||||||
commands = [AntaCommand(command="show management ssh ipv6 access-list summary")]
|
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
|
class Input(AntaTest.Input):
|
||||||
number: conint(ge=0) # type:ignore
|
"""Input model for the VerifySSHIPv6Acl test."""
|
||||||
"""The number of expected IPv6 ACL(s)"""
|
|
||||||
|
number: PositiveInteger
|
||||||
|
"""The number of expected IPv6 ACL(s)."""
|
||||||
vrf: str = "default"
|
vrf: str = "default"
|
||||||
"""The name of the VRF in which to check for the SSHD agent"""
|
"""The name of the VRF in which to check for the SSHD agent. Defaults to `default` VRF."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifySSHIPv6Acl."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
|
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
|
||||||
ipv6_acl_number = len(ipv6_acl_list)
|
ipv6_acl_number = len(ipv6_acl_list)
|
||||||
not_configured_acl_list = []
|
|
||||||
if ipv6_acl_number != self.inputs.number:
|
if ipv6_acl_number != self.inputs.number:
|
||||||
self.result.is_failure(f"Expected {self.inputs.number} SSH IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
|
self.result.is_failure(f"Expected {self.inputs.number} SSH IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
|
||||||
return
|
return
|
||||||
for ipv6_acl in ipv6_acl_list:
|
|
||||||
if self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]:
|
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"]]
|
||||||
not_configured_acl_list.append(ipv6_acl["name"])
|
|
||||||
if not_configured_acl_list:
|
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_list}")
|
self.result.is_failure(f"SSH IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
|
||||||
else:
|
else:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
|
||||||
|
|
||||||
class VerifyTelnetStatus(AntaTest):
|
class VerifyTelnetStatus(AntaTest):
|
||||||
"""
|
"""Verifies if Telnet is disabled in the default VRF.
|
||||||
Verifies if Telnet is disabled in the default VRF.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if Telnet is disabled in the default VRF.
|
----------------
|
||||||
* failure: The test will fail if Telnet is NOT disabled in the default VRF.
|
* Success: The test will pass if Telnet is disabled in the default VRF.
|
||||||
|
* Failure: The test will fail if Telnet is NOT disabled in the default VRF.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.security:
|
||||||
|
- VerifyTelnetStatus:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyTelnetStatus"
|
name = "VerifyTelnetStatus"
|
||||||
description = "Verifies if Telnet is disabled in the default VRF."
|
description = "Verifies if Telnet is disabled in the default VRF."
|
||||||
categories = ["security"]
|
categories: ClassVar[list[str]] = ["security"]
|
||||||
commands = [AntaCommand(command="show management telnet")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management telnet", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyTelnetStatus."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if command_output["serverState"] == "disabled":
|
if command_output["serverState"] == "disabled":
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
@ -147,21 +183,29 @@ class VerifyTelnetStatus(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyAPIHttpStatus(AntaTest):
|
class VerifyAPIHttpStatus(AntaTest):
|
||||||
"""
|
"""Verifies if eAPI HTTP server is disabled globally.
|
||||||
Verifies if eAPI HTTP server is disabled globally.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if eAPI HTTP server is disabled globally.
|
----------------
|
||||||
* failure: The test will fail if eAPI HTTP server is NOT disabled globally.
|
* Success: The test will pass if eAPI HTTP server is disabled globally.
|
||||||
|
* Failure: The test will fail if eAPI HTTP server is NOT disabled globally.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.security:
|
||||||
|
- VerifyAPIHttpStatus:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyAPIHttpStatus"
|
name = "VerifyAPIHttpStatus"
|
||||||
description = "Verifies if eAPI HTTP server is disabled globally."
|
description = "Verifies if eAPI HTTP server is disabled globally."
|
||||||
categories = ["security"]
|
categories: ClassVar[list[str]] = ["security"]
|
||||||
commands = [AntaCommand(command="show management api http-commands")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyAPIHttpStatus."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if command_output["enabled"] and not command_output["httpServer"]["running"]:
|
if command_output["enabled"] and not command_output["httpServer"]["running"]:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
@ -170,25 +214,36 @@ class VerifyAPIHttpStatus(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyAPIHttpsSSL(AntaTest):
|
class VerifyAPIHttpsSSL(AntaTest):
|
||||||
"""
|
"""Verifies if eAPI HTTPS server SSL profile is configured and valid.
|
||||||
Verifies if eAPI HTTPS server SSL profile is configured and valid.
|
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* success: The test will pass if the eAPI HTTPS server SSL profile is configured and valid.
|
----------------
|
||||||
* failure: The test will fail if the eAPI HTTPS server SSL profile is NOT configured, misconfigured or invalid.
|
* 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"
|
name = "VerifyAPIHttpsSSL"
|
||||||
description = "Verifies if the eAPI has a valid SSL profile."
|
description = "Verifies if the eAPI has a valid SSL profile."
|
||||||
categories = ["security"]
|
categories: ClassVar[list[str]] = ["security"]
|
||||||
commands = [AntaCommand(command="show management api http-commands")]
|
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
|
profile: str
|
||||||
"""SSL profile to verify"""
|
"""SSL profile to verify."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyAPIHttpsSSL."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
try:
|
try:
|
||||||
if command_output["sslProfile"]["name"] == self.inputs.profile and command_output["sslProfile"]["state"] == "valid":
|
if command_output["sslProfile"]["name"] == self.inputs.profile and command_output["sslProfile"]["state"] == "valid":
|
||||||
|
@ -201,110 +256,149 @@ class VerifyAPIHttpsSSL(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyAPIIPv4Acl(AntaTest):
|
class VerifyAPIIPv4Acl(AntaTest):
|
||||||
"""
|
"""Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF.
|
||||||
Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF.
|
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* success: The test will pass if eAPI has the provided number of IPv4 ACL(s) in the specified VRF.
|
----------------
|
||||||
* failure: The test will fail if eAPI has not the right number of IPv4 ACL(s) in the specified VRF.
|
* Success: The test will pass if eAPI has the provided number of IPv4 ACL(s) in the specified VRF.
|
||||||
|
* Failure: The test will fail if eAPI has not the right number of IPv4 ACL(s) in the specified VRF.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.security:
|
||||||
|
- VerifyAPIIPv4Acl:
|
||||||
|
number: 3
|
||||||
|
vrf: default
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyAPIIPv4Acl"
|
name = "VerifyAPIIPv4Acl"
|
||||||
description = "Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF."
|
description = "Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF."
|
||||||
categories = ["security"]
|
categories: ClassVar[list[str]] = ["security"]
|
||||||
commands = [AntaCommand(command="show management api http-commands ip access-list summary")]
|
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
|
class Input(AntaTest.Input):
|
||||||
number: conint(ge=0) # type:ignore
|
"""Input parameters for the VerifyAPIIPv4Acl test."""
|
||||||
"""The number of expected IPv4 ACL(s)"""
|
|
||||||
|
number: PositiveInteger
|
||||||
|
"""The number of expected IPv4 ACL(s)."""
|
||||||
vrf: str = "default"
|
vrf: str = "default"
|
||||||
"""The name of the VRF in which to check for eAPI"""
|
"""The name of the VRF in which to check for eAPI. Defaults to `default` VRF."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyAPIIPv4Acl."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
ipv4_acl_list = command_output["ipAclList"]["aclList"]
|
ipv4_acl_list = command_output["ipAclList"]["aclList"]
|
||||||
ipv4_acl_number = len(ipv4_acl_list)
|
ipv4_acl_number = len(ipv4_acl_list)
|
||||||
not_configured_acl_list = []
|
|
||||||
if ipv4_acl_number != self.inputs.number:
|
if ipv4_acl_number != self.inputs.number:
|
||||||
self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
|
self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
|
||||||
return
|
return
|
||||||
for ipv4_acl in ipv4_acl_list:
|
|
||||||
if self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]:
|
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"]]
|
||||||
not_configured_acl_list.append(ipv4_acl["name"])
|
|
||||||
if not_configured_acl_list:
|
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_list}")
|
self.result.is_failure(f"eAPI IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
|
||||||
else:
|
else:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
|
||||||
|
|
||||||
class VerifyAPIIPv6Acl(AntaTest):
|
class VerifyAPIIPv6Acl(AntaTest):
|
||||||
"""
|
"""Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF.
|
||||||
Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF.
|
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* success: The test will pass if eAPI has the provided number of IPv6 ACL(s) in the specified VRF.
|
----------------
|
||||||
* failure: The test will fail if eAPI has not the right number of IPv6 ACL(s) in the specified VRF.
|
* Success: The test will pass if eAPI has the provided number of IPv6 ACL(s) in the specified VRF.
|
||||||
* skipped: The test will be skipped if the number of IPv6 ACL(s) or VRF parameter is not provided.
|
* 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"
|
name = "VerifyAPIIPv6Acl"
|
||||||
description = "Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF."
|
description = "Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF."
|
||||||
categories = ["security"]
|
categories: ClassVar[list[str]] = ["security"]
|
||||||
commands = [AntaCommand(command="show management api http-commands ipv6 access-list summary")]
|
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
|
class Input(AntaTest.Input):
|
||||||
number: conint(ge=0) # type:ignore
|
"""Input parameters for the VerifyAPIIPv6Acl test."""
|
||||||
"""The number of expected IPv6 ACL(s)"""
|
|
||||||
|
number: PositiveInteger
|
||||||
|
"""The number of expected IPv6 ACL(s)."""
|
||||||
vrf: str = "default"
|
vrf: str = "default"
|
||||||
"""The name of the VRF in which to check for eAPI"""
|
"""The name of the VRF in which to check for eAPI. Defaults to `default` VRF."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyAPIIPv6Acl."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
|
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
|
||||||
ipv6_acl_number = len(ipv6_acl_list)
|
ipv6_acl_number = len(ipv6_acl_list)
|
||||||
not_configured_acl_list = []
|
|
||||||
if ipv6_acl_number != self.inputs.number:
|
if ipv6_acl_number != self.inputs.number:
|
||||||
self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
|
self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
|
||||||
return
|
return
|
||||||
for ipv6_acl in ipv6_acl_list:
|
|
||||||
if self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]:
|
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"]]
|
||||||
not_configured_acl_list.append(ipv6_acl["name"])
|
|
||||||
if not_configured_acl_list:
|
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_list}")
|
self.result.is_failure(f"eAPI IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
|
||||||
else:
|
else:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
|
||||||
|
|
||||||
class VerifyAPISSLCertificate(AntaTest):
|
class VerifyAPISSLCertificate(AntaTest):
|
||||||
"""
|
"""Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size.
|
||||||
Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the certificate's expiry date is greater than the threshold,
|
----------------
|
||||||
|
* Success: The test will pass if the certificate's expiry date is greater than the threshold,
|
||||||
and the certificate has the correct name, encryption algorithm, and key size.
|
and the certificate has the correct name, encryption algorithm, and key size.
|
||||||
* failure: The test will fail if the certificate is expired or is going to expire,
|
* Failure: The test will fail if the certificate is expired or is going to expire,
|
||||||
or if the certificate has an incorrect name, encryption algorithm, or key size.
|
or if the certificate has an incorrect name, encryption algorithm, or key size.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.security:
|
||||||
|
- VerifyAPISSLCertificate:
|
||||||
|
certificates:
|
||||||
|
- certificate_name: ARISTA_SIGNING_CA.crt
|
||||||
|
expiry_threshold: 30
|
||||||
|
common_name: AristaIT-ICA ECDSA Issuing Cert Authority
|
||||||
|
encryption_algorithm: ECDSA
|
||||||
|
key_size: 256
|
||||||
|
- certificate_name: ARISTA_ROOT_CA.crt
|
||||||
|
expiry_threshold: 30
|
||||||
|
common_name: Arista Networks Internal IT Root Cert Authority
|
||||||
|
encryption_algorithm: RSA
|
||||||
|
key_size: 4096
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyAPISSLCertificate"
|
name = "VerifyAPISSLCertificate"
|
||||||
description = "Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size."
|
description = "Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size."
|
||||||
categories = ["security"]
|
categories: ClassVar[list[str]] = ["security"]
|
||||||
commands = [AntaCommand(command="show management security ssl certificate"), AntaCommand(command="show clock")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||||
|
AntaCommand(command="show management security ssl certificate", revision=1),
|
||||||
|
AntaCommand(command="show clock", revision=1),
|
||||||
|
]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
class Input(AntaTest.Input):
|
||||||
"""
|
"""Input parameters for the VerifyAPISSLCertificate test."""
|
||||||
Input parameters for the VerifyAPISSLCertificate test.
|
|
||||||
"""
|
|
||||||
|
|
||||||
certificates: List[APISSLCertificates]
|
certificates: list[APISSLCertificate]
|
||||||
"""List of API SSL certificates"""
|
"""List of API SSL certificates."""
|
||||||
|
|
||||||
class APISSLCertificates(BaseModel):
|
class APISSLCertificate(BaseModel):
|
||||||
"""
|
"""Model for an API SSL certificate."""
|
||||||
This class defines the details of an API SSL certificate.
|
|
||||||
"""
|
|
||||||
|
|
||||||
certificate_name: str
|
certificate_name: str
|
||||||
"""The name of the certificate to be verified."""
|
"""The name of the certificate to be verified."""
|
||||||
|
@ -314,31 +408,30 @@ class VerifyAPISSLCertificate(AntaTest):
|
||||||
"""The common subject name of the certificate."""
|
"""The common subject name of the certificate."""
|
||||||
encryption_algorithm: EncryptionAlgorithm
|
encryption_algorithm: EncryptionAlgorithm
|
||||||
"""The encryption algorithm of the certificate."""
|
"""The encryption algorithm of the certificate."""
|
||||||
key_size: Union[RsaKeySize, EcdsaKeySize]
|
key_size: RsaKeySize | EcdsaKeySize
|
||||||
"""The encryption algorithm key size of the certificate."""
|
"""The encryption algorithm key size of the certificate."""
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_inputs(self: BaseModel) -> BaseModel:
|
def validate_inputs(self: BaseModel) -> BaseModel:
|
||||||
"""
|
"""Validate the key size provided to the APISSLCertificates class.
|
||||||
Validate the key size provided to the APISSLCertificates class.
|
|
||||||
|
|
||||||
If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}.
|
If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}.
|
||||||
|
|
||||||
If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}.
|
If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.encryption_algorithm == "RSA" and self.key_size not in RsaKeySize.__args__:
|
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__}.")
|
msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {RsaKeySize.__args__}."
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
if self.encryption_algorithm == "ECDSA" and self.key_size not in EcdsaKeySize.__args__:
|
if self.encryption_algorithm == "ECDSA" and self.key_size not in EcdsaKeySize.__args__:
|
||||||
raise ValueError(
|
msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {EcdsaKeySize.__args__}."
|
||||||
f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {EcdsaKeySize.__args__}."
|
raise ValueError(msg)
|
||||||
)
|
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyAPISSLCertificate."""
|
||||||
# Mark the result as success by default
|
# Mark the result as success by default
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
|
||||||
|
@ -356,7 +449,7 @@ class VerifyAPISSLCertificate(AntaTest):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
expiry_time = certificate_data["notAfter"]
|
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
|
# Verify certificate expiry
|
||||||
if 0 < day_difference < certificate.expiry_threshold:
|
if 0 < day_difference < certificate.expiry_threshold:
|
||||||
|
@ -381,27 +474,39 @@ class VerifyAPISSLCertificate(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyBannerLogin(AntaTest):
|
class VerifyBannerLogin(AntaTest):
|
||||||
"""
|
"""Verifies the login banner of a device.
|
||||||
Verifies the login banner of a device.
|
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* success: The test will pass if the login banner matches the provided input.
|
----------------
|
||||||
* failure: The test will fail if the login banner does not match the provided input.
|
* Success: The test will pass if the login banner matches the provided input.
|
||||||
|
* Failure: The test will fail if the login banner does not match the provided input.
|
||||||
|
|
||||||
|
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"
|
name = "VerifyBannerLogin"
|
||||||
description = "Verifies the login banner of a device."
|
description = "Verifies the login banner of a device."
|
||||||
categories = ["security"]
|
categories: ClassVar[list[str]] = ["security"]
|
||||||
commands = [AntaCommand(command="show banner login")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner login", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
class Input(AntaTest.Input):
|
||||||
"""Defines the input parameters for this test case."""
|
"""Input model for the VerifyBannerLogin test."""
|
||||||
|
|
||||||
login_banner: str
|
login_banner: str
|
||||||
"""Expected login banner of the device."""
|
"""Expected login banner of the device."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyBannerLogin."""
|
||||||
login_banner = self.instance_commands[0].json_output["loginBanner"]
|
login_banner = self.instance_commands[0].json_output["loginBanner"]
|
||||||
|
|
||||||
# Remove leading and trailing whitespaces from each line
|
# Remove leading and trailing whitespaces from each line
|
||||||
|
@ -413,27 +518,39 @@ class VerifyBannerLogin(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyBannerMotd(AntaTest):
|
class VerifyBannerMotd(AntaTest):
|
||||||
"""
|
"""Verifies the motd banner of a device.
|
||||||
Verifies the motd banner of a device.
|
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* success: The test will pass if the motd banner matches the provided input.
|
----------------
|
||||||
* failure: The test will fail if the motd banner does not match the provided input.
|
* Success: The test will pass if the motd banner matches the provided input.
|
||||||
|
* Failure: The test will fail if the motd banner does not match the provided input.
|
||||||
|
|
||||||
|
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"
|
name = "VerifyBannerMotd"
|
||||||
description = "Verifies the motd banner of a device."
|
description = "Verifies the motd banner of a device."
|
||||||
categories = ["security"]
|
categories: ClassVar[list[str]] = ["security"]
|
||||||
commands = [AntaCommand(command="show banner motd")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner motd", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
class Input(AntaTest.Input):
|
||||||
"""Defines the input parameters for this test case."""
|
"""Input model for the VerifyBannerMotd test."""
|
||||||
|
|
||||||
motd_banner: str
|
motd_banner: str
|
||||||
"""Expected motd banner of the device."""
|
"""Expected motd banner of the device."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyBannerMotd."""
|
||||||
motd_banner = self.instance_commands[0].json_output["motd"]
|
motd_banner = self.instance_commands[0].json_output["motd"]
|
||||||
|
|
||||||
# Remove leading and trailing whitespaces from each line
|
# Remove leading and trailing whitespaces from each line
|
||||||
|
@ -445,52 +562,77 @@ class VerifyBannerMotd(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyIPv4ACL(AntaTest):
|
class VerifyIPv4ACL(AntaTest):
|
||||||
"""
|
"""Verifies the configuration of IPv4 ACLs.
|
||||||
Verifies the configuration of IPv4 ACLs.
|
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* success: The test will pass if an IPv4 ACL is configured with the correct sequence entries.
|
----------------
|
||||||
* failure: The test will fail if an IPv4 ACL is not configured or entries are not in sequence.
|
* 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"
|
name = "VerifyIPv4ACL"
|
||||||
description = "Verifies the configuration of IPv4 ACLs."
|
description = "Verifies the configuration of IPv4 ACLs."
|
||||||
categories = ["security"]
|
categories: ClassVar[list[str]] = ["security"]
|
||||||
commands = [AntaTemplate(template="show ip access-lists {acl}")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip access-lists {acl}", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
class Input(AntaTest.Input):
|
||||||
"""Inputs for the VerifyIPv4ACL test."""
|
"""Input model for the VerifyIPv4ACL test."""
|
||||||
|
|
||||||
ipv4_access_lists: List[IPv4ACL]
|
ipv4_access_lists: list[IPv4ACL]
|
||||||
"""List of IPv4 ACLs to verify"""
|
"""List of IPv4 ACLs to verify."""
|
||||||
|
|
||||||
class IPv4ACL(BaseModel):
|
class IPv4ACL(BaseModel):
|
||||||
"""Detail of IPv4 ACL"""
|
"""Model for an IPv4 ACL."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
"""Name of IPv4 ACL"""
|
"""Name of IPv4 ACL."""
|
||||||
|
|
||||||
entries: List[IPv4ACLEntries]
|
entries: list[IPv4ACLEntry]
|
||||||
"""List of IPv4 ACL entries"""
|
"""List of IPv4 ACL entries."""
|
||||||
|
|
||||||
class IPv4ACLEntries(BaseModel):
|
class IPv4ACLEntry(BaseModel):
|
||||||
"""IPv4 ACL entries details"""
|
"""Model for an IPv4 ACL entry."""
|
||||||
|
|
||||||
sequence: int = Field(ge=1, le=4294967295)
|
sequence: int = Field(ge=1, le=4294967295)
|
||||||
"""Sequence number of an ACL entry"""
|
"""Sequence number of an ACL entry."""
|
||||||
action: str
|
action: str
|
||||||
"""Action of an ACL entry"""
|
"""Action of an ACL entry."""
|
||||||
|
|
||||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||||
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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyIPv4ACL."""
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
for command_output in self.instance_commands:
|
for command_output, acl in zip(self.instance_commands, self.inputs.ipv4_access_lists):
|
||||||
# Collecting input ACL details
|
# Collecting input ACL details
|
||||||
acl_name = command_output.params["acl"]
|
acl_name = command_output.params.acl
|
||||||
acl_entries = command_output.params["entries"]
|
# Retrieve the expected entries from the inputs
|
||||||
|
acl_entries = acl.entries
|
||||||
|
|
||||||
# Check if ACL is configured
|
# Check if ACL is configured
|
||||||
ipv4_acl_list = command_output.json_output["aclList"]
|
ipv4_acl_list = command_output.json_output["aclList"]
|
||||||
|
@ -512,3 +654,165 @@ class VerifyIPv4ACL(AntaTest):
|
||||||
|
|
||||||
if failed_log != f"{acl_name}:\n":
|
if failed_log != f"{acl_name}:\n":
|
||||||
self.result.is_failure(f"{failed_log}")
|
self.result.is_failure(f"{failed_log}")
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyIPSecConnHealth(AntaTest):
|
||||||
|
"""
|
||||||
|
Verifies all IPv4 security connections.
|
||||||
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if all the IPv4 security connections are established in all vrf.
|
||||||
|
* Failure: The test will fail if IPv4 security is not configured or any of IPv4 security connections are not established in any vrf.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.security:
|
||||||
|
- VerifyIPSecConnHealth:
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "VerifyIPSecConnHealth"
|
||||||
|
description = "Verifies all IPv4 security connections."
|
||||||
|
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 state of IPv4 security connections for a specified peer.
|
||||||
|
|
||||||
|
It optionally allows for the verification of a specific path for a peer by providing source and destination addresses.
|
||||||
|
If these addresses are not provided, it will verify all paths for the specified peer.
|
||||||
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test passes if the IPv4 security connection for a peer is established in the specified VRF.
|
||||||
|
* Failure: The test fails if IPv4 security is not configured, a connection is not found for a peer, or the connection is not established in the specified VRF.
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "VerifySpecificIPSecConn"
|
||||||
|
description = "Verifies IPv4 security connections for a peer."
|
||||||
|
categories: ClassVar[list[str]] = ["security"]
|
||||||
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip security connection vrf {vrf} path peer {peer}")]
|
||||||
|
|
||||||
|
class Input(AntaTest.Input):
|
||||||
|
"""Input model for the VerifySpecificIPSecConn test."""
|
||||||
|
|
||||||
|
ip_security_connections: list[IPSecPeers]
|
||||||
|
"""List of IP4v security peers."""
|
||||||
|
|
||||||
|
class IPSecPeers(BaseModel):
|
||||||
|
"""Details of IPv4 security peers."""
|
||||||
|
|
||||||
|
peer: IPv4Address
|
||||||
|
"""IPv4 address of the peer."""
|
||||||
|
|
||||||
|
vrf: str = "default"
|
||||||
|
"""Optional VRF for the IP security peer."""
|
||||||
|
|
||||||
|
connections: list[IPSecConn] | None = None
|
||||||
|
"""Optional list of IPv4 security connections of a peer."""
|
||||||
|
|
||||||
|
class IPSecConn(BaseModel):
|
||||||
|
"""Details of IPv4 security connections for a peer."""
|
||||||
|
|
||||||
|
source_address: IPv4Address
|
||||||
|
"""Source IPv4 address of the connection."""
|
||||||
|
destination_address: IPv4Address
|
||||||
|
"""Destination IPv4 address of the connection."""
|
||||||
|
|
||||||
|
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||||
|
"""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"]
|
||||||
|
peer = command_output.params.peer
|
||||||
|
vrf = command_output.params.vrf
|
||||||
|
conn_input = input_peer.connections
|
||||||
|
|
||||||
|
# Check if IPv4 security connection is configured
|
||||||
|
if not conn_output:
|
||||||
|
self.result.is_failure(f"No IPv4 security connection configured for peer `{peer}`.")
|
||||||
|
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")
|
||||||
|
vrf = conn_data.get("tunnelNs")
|
||||||
|
self.result.is_failure(
|
||||||
|
f"Expected state of IPv4 security connection `source:{source} destination:{destination} vrf:{vrf}` for peer `{peer}` is `Established` "
|
||||||
|
f"but found `{state}` instead."
|
||||||
|
)
|
||||||
|
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":
|
||||||
|
self.result.is_failure(
|
||||||
|
f"Expected state of IPv4 security connection `source:{source_input} destination:{destination_input} vrf:{vrf}` "
|
||||||
|
f"for peer `{peer}` is `Established` but found `{existing_state}` instead."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.result.is_failure(
|
||||||
|
f"IPv4 security connection `source:{source_input} destination:{destination_input} vrf:{vrf}` for peer `{peer}` is not found."
|
||||||
|
)
|
||||||
|
|
|
@ -1,48 +1,53 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to the EOS various services tests."""
|
||||||
Test functions related to the EOS various services settings
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Mypy does not understand AntaTest.Input typing
|
||||||
|
# mypy: disable-error-code=attr-defined
|
||||||
from ipaddress import IPv4Address, IPv6Address
|
from ipaddress import IPv4Address, IPv6Address
|
||||||
from typing import List, Union
|
from typing import ClassVar
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from anta.custom_types import ErrDisableInterval, ErrDisableReasons
|
from anta.custom_types import ErrDisableInterval, ErrDisableReasons
|
||||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||||
from anta.tools.get_dict_superset import get_dict_superset
|
from anta.tools import get_dict_superset, get_failed_logs, get_item
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class VerifyHostname(AntaTest):
|
class VerifyHostname(AntaTest):
|
||||||
"""
|
"""Verifies the hostname of a device.
|
||||||
Verifies the hostname of a device.
|
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* success: The test will pass if the hostname matches the provided input.
|
----------------
|
||||||
* failure: The test will fail if the hostname does not match the provided input.
|
* Success: The test will pass if the hostname matches the provided input.
|
||||||
|
* Failure: The test will fail if the hostname does not match the provided input.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.services:
|
||||||
|
- VerifyHostname:
|
||||||
|
hostname: s1-spine1
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyHostname"
|
name = "VerifyHostname"
|
||||||
description = "Verifies the hostname of a device."
|
description = "Verifies the hostname of a device."
|
||||||
categories = ["services"]
|
categories: ClassVar[list[str]] = ["services"]
|
||||||
commands = [AntaCommand(command="show hostname")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hostname", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
class Input(AntaTest.Input):
|
||||||
"""Defines the input parameters for this test case."""
|
"""Input model for the VerifyHostname test."""
|
||||||
|
|
||||||
hostname: str
|
hostname: str
|
||||||
"""Expected hostname of the device."""
|
"""Expected hostname of the device."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyHostname."""
|
||||||
hostname = self.instance_commands[0].json_output["hostname"]
|
hostname = self.instance_commands[0].json_output["hostname"]
|
||||||
|
|
||||||
if hostname != self.inputs.hostname:
|
if hostname != self.inputs.hostname:
|
||||||
|
@ -52,35 +57,48 @@ class VerifyHostname(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyDNSLookup(AntaTest):
|
class VerifyDNSLookup(AntaTest):
|
||||||
"""
|
"""Verifies the DNS (Domain Name Service) name to IP address resolution.
|
||||||
This class verifies the DNS (Domain name service) name to IP address resolution.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if a domain name is resolved to an IP address.
|
----------------
|
||||||
* failure: The test will fail if a domain name does not resolve to an IP address.
|
* Success: The test will pass if a domain name is resolved to an IP address.
|
||||||
* error: This test will error out if a domain name is invalid.
|
* 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"
|
name = "VerifyDNSLookup"
|
||||||
description = "Verifies the DNS name to IP address resolution."
|
description = "Verifies the DNS name to IP address resolution."
|
||||||
categories = ["services"]
|
categories: ClassVar[list[str]] = ["services"]
|
||||||
commands = [AntaTemplate(template="bash timeout 10 nslookup {domain}")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="bash timeout 10 nslookup {domain}", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
class Input(AntaTest.Input):
|
||||||
"""Inputs for the VerifyDNSLookup test."""
|
"""Input model for the VerifyDNSLookup test."""
|
||||||
|
|
||||||
domain_names: List[str]
|
domain_names: list[str]
|
||||||
"""List of domain names"""
|
"""List of domain names."""
|
||||||
|
|
||||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||||
|
"""Render the template for each domain name in the input list."""
|
||||||
return [template.render(domain=domain_name) for domain_name in self.inputs.domain_names]
|
return [template.render(domain=domain_name) for domain_name in self.inputs.domain_names]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyDNSLookup."""
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
failed_domains = []
|
failed_domains = []
|
||||||
for command in self.instance_commands:
|
for command in self.instance_commands:
|
||||||
domain = command.params["domain"]
|
domain = command.params.domain
|
||||||
output = command.json_output["messages"][0]
|
output = command.json_output["messages"][0]
|
||||||
if f"Can't find {domain}: No answer" in output:
|
if f"Can't find {domain}: No answer" in output:
|
||||||
failed_domains.append(domain)
|
failed_domains.append(domain)
|
||||||
|
@ -89,29 +107,43 @@ class VerifyDNSLookup(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyDNSServers(AntaTest):
|
class VerifyDNSServers(AntaTest):
|
||||||
"""
|
"""Verifies if the DNS (Domain Name Service) servers are correctly configured.
|
||||||
Verifies if the DNS (Domain Name Service) servers are correctly configured.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the DNS server specified in the input is configured with the correct VRF and priority.
|
----------------
|
||||||
* 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.
|
* 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.
|
||||||
|
|
||||||
|
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"
|
name = "VerifyDNSServers"
|
||||||
description = "Verifies if the DNS servers are correctly configured."
|
description = "Verifies if the DNS servers are correctly configured."
|
||||||
categories = ["services"]
|
categories: ClassVar[list[str]] = ["services"]
|
||||||
commands = [AntaCommand(command="show ip name-server")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip name-server", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
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."""
|
"""List of DNS servers to verify."""
|
||||||
|
|
||||||
class DnsServers(BaseModel):
|
class DnsServer(BaseModel):
|
||||||
"""DNS server details"""
|
"""Model for a DNS server."""
|
||||||
|
|
||||||
server_address: Union[IPv4Address, IPv6Address]
|
server_address: IPv4Address | IPv6Address
|
||||||
"""The IPv4/IPv6 address of the DNS server."""
|
"""The IPv4/IPv6 address of the DNS server."""
|
||||||
vrf: str = "default"
|
vrf: str = "default"
|
||||||
"""The VRF for the DNS server. Defaults to 'default' if not provided."""
|
"""The VRF for the DNS server. Defaults to 'default' if not provided."""
|
||||||
|
@ -120,6 +152,7 @@ class VerifyDNSServers(AntaTest):
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyDNSServers."""
|
||||||
command_output = self.instance_commands[0].json_output["nameServerConfigs"]
|
command_output = self.instance_commands[0].json_output["nameServerConfigs"]
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
for server in self.inputs.dns_servers:
|
for server in self.inputs.dns_servers:
|
||||||
|
@ -141,35 +174,49 @@ class VerifyDNSServers(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyErrdisableRecovery(AntaTest):
|
class VerifyErrdisableRecovery(AntaTest):
|
||||||
"""
|
"""Verifies the errdisable recovery reason, status, and interval.
|
||||||
Verifies the errdisable recovery reason, status, and interval.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* Success: The test will pass if the errdisable recovery reason status is enabled and the interval matches the input.
|
----------------
|
||||||
* 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.
|
* 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"
|
name = "VerifyErrdisableRecovery"
|
||||||
description = "Verifies the errdisable recovery reason, status, and interval."
|
description = "Verifies the errdisable recovery reason, status, and interval."
|
||||||
categories = ["services"]
|
categories: ClassVar[list[str]] = ["services"]
|
||||||
commands = [AntaCommand(command="show errdisable recovery", ofmt="text")] # Command does not support JSON output hence using text output
|
# 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):
|
class Input(AntaTest.Input):
|
||||||
"""Inputs for the VerifyErrdisableRecovery test."""
|
"""Input model for the VerifyErrdisableRecovery test."""
|
||||||
|
|
||||||
reasons: List[ErrDisableReason]
|
reasons: list[ErrDisableReason]
|
||||||
"""List of errdisable reasons"""
|
"""List of errdisable reasons."""
|
||||||
|
|
||||||
class ErrDisableReason(BaseModel):
|
class ErrDisableReason(BaseModel):
|
||||||
"""Details of an errdisable reason"""
|
"""Model for an errdisable reason."""
|
||||||
|
|
||||||
reason: ErrDisableReasons
|
reason: ErrDisableReasons
|
||||||
"""Type or name of the errdisable reason"""
|
"""Type or name of the errdisable reason."""
|
||||||
interval: ErrDisableInterval
|
interval: ErrDisableInterval
|
||||||
"""Interval of the reason in seconds"""
|
"""Interval of the reason in seconds."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyErrdisableRecovery."""
|
||||||
command_output = self.instance_commands[0].text_output
|
command_output = self.instance_commands[0].text_output
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
for error_reason in self.inputs.reasons:
|
for error_reason in self.inputs.reasons:
|
||||||
|
|
|
@ -1,38 +1,52 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to the EOS various SNMP tests."""
|
||||||
Test functions related to the EOS various SNMP settings
|
|
||||||
"""
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pydantic import conint
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
|
from anta.custom_types import PositiveInteger
|
||||||
from anta.models import AntaCommand, AntaTest
|
from anta.models import AntaCommand, AntaTest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.models import AntaTemplate
|
||||||
|
|
||||||
|
|
||||||
class VerifySnmpStatus(AntaTest):
|
class VerifySnmpStatus(AntaTest):
|
||||||
"""
|
"""Verifies whether the SNMP agent is enabled in a specified VRF.
|
||||||
Verifies whether the SNMP agent is enabled in a specified VRF.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the SNMP agent is enabled in the specified VRF.
|
----------------
|
||||||
* failure: The test will fail if the SNMP agent is disabled in the specified VRF.
|
* Success: The test will pass if the SNMP agent is enabled in the specified VRF.
|
||||||
|
* Failure: The test will fail if the SNMP agent is disabled in the specified VRF.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.snmp:
|
||||||
|
- VerifySnmpStatus:
|
||||||
|
vrf: default
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifySnmpStatus"
|
name = "VerifySnmpStatus"
|
||||||
description = "Verifies if the SNMP agent is enabled."
|
description = "Verifies if the SNMP agent is enabled."
|
||||||
categories = ["snmp"]
|
categories: ClassVar[list[str]] = ["snmp"]
|
||||||
commands = [AntaCommand(command="show 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"
|
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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifySnmpStatus."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if command_output["enabled"] and self.inputs.vrf in command_output["vrfs"]["snmpVrfs"]:
|
if command_output["enabled"] and self.inputs.vrf in command_output["vrfs"]["snmpVrfs"]:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
@ -41,103 +55,134 @@ class VerifySnmpStatus(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifySnmpIPv4Acl(AntaTest):
|
class VerifySnmpIPv4Acl(AntaTest):
|
||||||
"""
|
"""Verifies if the SNMP agent has the right number IPv4 ACL(s) configured for a specified VRF.
|
||||||
Verifies if the SNMP agent has the right number IPv4 ACL(s) configured for a specified VRF.
|
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* success: The test will pass if the SNMP agent has the provided number of IPv4 ACL(s) in the specified VRF.
|
----------------
|
||||||
* failure: The test will fail if the SNMP agent has not the right number of IPv4 ACL(s) in the specified VRF.
|
* Success: The test will pass if the SNMP agent has the provided number of IPv4 ACL(s) in the specified VRF.
|
||||||
|
* Failure: The test will fail if the SNMP agent has not the right number of IPv4 ACL(s) in the specified VRF.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.snmp:
|
||||||
|
- VerifySnmpIPv4Acl:
|
||||||
|
number: 3
|
||||||
|
vrf: default
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifySnmpIPv4Acl"
|
name = "VerifySnmpIPv4Acl"
|
||||||
description = "Verifies if the SNMP agent has IPv4 ACL(s) configured."
|
description = "Verifies if the SNMP agent has IPv4 ACL(s) configured."
|
||||||
categories = ["snmp"]
|
categories: ClassVar[list[str]] = ["snmp"]
|
||||||
commands = [AntaCommand(command="show snmp ipv4 access-list summary")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv4 access-list summary", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
number: conint(ge=0) # type:ignore
|
"""Input model for the VerifySnmpIPv4Acl test."""
|
||||||
"""The number of expected IPv4 ACL(s)"""
|
|
||||||
|
number: PositiveInteger
|
||||||
|
"""The number of expected IPv4 ACL(s)."""
|
||||||
vrf: str = "default"
|
vrf: str = "default"
|
||||||
"""The name of the VRF in which to check for the SNMP agent"""
|
"""The name of the VRF in which to check for the SNMP agent. Defaults to `default` VRF."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifySnmpIPv4Acl."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
ipv4_acl_list = command_output["ipAclList"]["aclList"]
|
ipv4_acl_list = command_output["ipAclList"]["aclList"]
|
||||||
ipv4_acl_number = len(ipv4_acl_list)
|
ipv4_acl_number = len(ipv4_acl_list)
|
||||||
not_configured_acl_list = []
|
|
||||||
if ipv4_acl_number != self.inputs.number:
|
if ipv4_acl_number != self.inputs.number:
|
||||||
self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
|
self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
|
||||||
return
|
return
|
||||||
for ipv4_acl in ipv4_acl_list:
|
|
||||||
if self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]:
|
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"]]
|
||||||
not_configured_acl_list.append(ipv4_acl["name"])
|
|
||||||
if not_configured_acl_list:
|
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_list}")
|
self.result.is_failure(f"SNMP IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
|
||||||
else:
|
else:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
|
||||||
|
|
||||||
class VerifySnmpIPv6Acl(AntaTest):
|
class VerifySnmpIPv6Acl(AntaTest):
|
||||||
"""
|
"""Verifies if the SNMP agent has the right number IPv6 ACL(s) configured for a specified VRF.
|
||||||
Verifies if the SNMP agent has the right number IPv6 ACL(s) configured for a specified VRF.
|
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* success: The test will pass if the SNMP agent has the provided number of IPv6 ACL(s) in the specified VRF.
|
----------------
|
||||||
* failure: The test will fail if the SNMP agent has not the right number of IPv6 ACL(s) in the specified VRF.
|
* Success: The test will pass if the SNMP agent has the provided number of IPv6 ACL(s) in the specified VRF.
|
||||||
|
* Failure: The test will fail if the SNMP agent has not the right number of IPv6 ACL(s) in the specified VRF.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.snmp:
|
||||||
|
- VerifySnmpIPv6Acl:
|
||||||
|
number: 3
|
||||||
|
vrf: default
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifySnmpIPv6Acl"
|
name = "VerifySnmpIPv6Acl"
|
||||||
description = "Verifies if the SNMP agent has IPv6 ACL(s) configured."
|
description = "Verifies if the SNMP agent has IPv6 ACL(s) configured."
|
||||||
categories = ["snmp"]
|
categories: ClassVar[list[str]] = ["snmp"]
|
||||||
commands = [AntaCommand(command="show snmp ipv6 access-list summary")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv6 access-list summary", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
number: conint(ge=0) # type:ignore
|
"""Input model for the VerifySnmpIPv6Acl test."""
|
||||||
"""The number of expected IPv6 ACL(s)"""
|
|
||||||
|
number: PositiveInteger
|
||||||
|
"""The number of expected IPv6 ACL(s)."""
|
||||||
vrf: str = "default"
|
vrf: str = "default"
|
||||||
"""The name of the VRF in which to check for the SNMP agent"""
|
"""The name of the VRF in which to check for the SNMP agent. Defaults to `default` VRF."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifySnmpIPv6Acl."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
|
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
|
||||||
ipv6_acl_number = len(ipv6_acl_list)
|
ipv6_acl_number = len(ipv6_acl_list)
|
||||||
not_configured_acl_list = []
|
|
||||||
if ipv6_acl_number != self.inputs.number:
|
if ipv6_acl_number != self.inputs.number:
|
||||||
self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
|
self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
|
||||||
return
|
return
|
||||||
for ipv6_acl in ipv6_acl_list:
|
|
||||||
if self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]:
|
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"]]
|
||||||
not_configured_acl_list.append(ipv6_acl["name"])
|
|
||||||
if not_configured_acl_list:
|
if acl_not_configured:
|
||||||
self.result.is_failure(f"SNMP IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
|
self.result.is_failure(f"SNMP IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {acl_not_configured}")
|
||||||
else:
|
else:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
|
||||||
|
|
||||||
class VerifySnmpLocation(AntaTest):
|
class VerifySnmpLocation(AntaTest):
|
||||||
"""
|
"""Verifies the SNMP location of a device.
|
||||||
This class verifies the SNMP location of a device.
|
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* success: The test will pass if the SNMP location matches the provided input.
|
----------------
|
||||||
* failure: The test will fail if the SNMP location does not match the provided input.
|
* Success: The test will pass if the SNMP location matches the provided input.
|
||||||
|
* Failure: The test will fail if the SNMP location does not match the provided input.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.snmp:
|
||||||
|
- VerifySnmpLocation:
|
||||||
|
location: New York
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifySnmpLocation"
|
name = "VerifySnmpLocation"
|
||||||
description = "Verifies the SNMP location of a device."
|
description = "Verifies the SNMP location of a device."
|
||||||
categories = ["snmp"]
|
categories: ClassVar[list[str]] = ["snmp"]
|
||||||
commands = [AntaCommand(command="show snmp")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
class Input(AntaTest.Input):
|
||||||
"""Defines the input parameters for this test case."""
|
"""Input model for the VerifySnmpLocation test."""
|
||||||
|
|
||||||
location: str
|
location: str
|
||||||
"""Expected SNMP location of the device."""
|
"""Expected SNMP location of the device."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifySnmpLocation."""
|
||||||
location = self.instance_commands[0].json_output["location"]["location"]
|
location = self.instance_commands[0].json_output["location"]["location"]
|
||||||
|
|
||||||
if location != self.inputs.location:
|
if location != self.inputs.location:
|
||||||
|
@ -147,27 +192,36 @@ class VerifySnmpLocation(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifySnmpContact(AntaTest):
|
class VerifySnmpContact(AntaTest):
|
||||||
"""
|
"""Verifies the SNMP contact of a device.
|
||||||
This class verifies the SNMP contact of a device.
|
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* success: The test will pass if the SNMP contact matches the provided input.
|
----------------
|
||||||
* failure: The test will fail if the SNMP contact does not match the provided input.
|
* Success: The test will pass if the SNMP contact matches the provided input.
|
||||||
|
* Failure: The test will fail if the SNMP contact does not match the provided input.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.snmp:
|
||||||
|
- VerifySnmpContact:
|
||||||
|
contact: Jon@example.com
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifySnmpContact"
|
name = "VerifySnmpContact"
|
||||||
description = "Verifies the SNMP contact of a device."
|
description = "Verifies the SNMP contact of a device."
|
||||||
categories = ["snmp"]
|
categories: ClassVar[list[str]] = ["snmp"]
|
||||||
commands = [AntaCommand(command="show snmp")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
class Input(AntaTest.Input):
|
||||||
"""Defines the input parameters for this test case."""
|
"""Input model for the VerifySnmpContact test."""
|
||||||
|
|
||||||
contact: str
|
contact: str
|
||||||
"""Expected SNMP contact details of the device."""
|
"""Expected SNMP contact details of the device."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifySnmpContact."""
|
||||||
contact = self.instance_commands[0].json_output["contact"]["contact"]
|
contact = self.instance_commands[0].json_output["contact"]["contact"]
|
||||||
|
|
||||||
if contact != self.inputs.contact:
|
if contact != self.inputs.contact:
|
||||||
|
|
|
@ -1,35 +1,53 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to the EOS software tests."""
|
||||||
Test functions related to the EOS software
|
|
||||||
"""
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# Need to keep List for pydantic in python 3.8
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from anta.models import AntaCommand, AntaTest
|
from anta.models import AntaCommand, AntaTest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.models import AntaTemplate
|
||||||
|
|
||||||
|
|
||||||
class VerifyEOSVersion(AntaTest):
|
class VerifyEOSVersion(AntaTest):
|
||||||
"""
|
"""Verifies that the device is running one of the allowed EOS version.
|
||||||
Verifies the device is running one of the allowed EOS version.
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if the device is running one of the allowed EOS version.
|
||||||
|
* Failure: The test will fail if the device is not running one of the allowed EOS version.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.software:
|
||||||
|
- VerifyEOSVersion:
|
||||||
|
versions:
|
||||||
|
- 4.25.4M
|
||||||
|
- 4.26.1F
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyEOSVersion"
|
name = "VerifyEOSVersion"
|
||||||
description = "Verifies the device is running one of the allowed EOS version."
|
description = "Verifies the EOS version of the device."
|
||||||
categories = ["software"]
|
categories: ClassVar[list[str]] = ["software"]
|
||||||
commands = [AntaCommand(command="show version")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
versions: List[str]
|
"""Input model for the VerifyEOSVersion test."""
|
||||||
"""List of allowed EOS versions"""
|
|
||||||
|
versions: list[str]
|
||||||
|
"""List of allowed EOS versions."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyEOSVersion."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if command_output["version"] in self.inputs.versions:
|
if command_output["version"] in self.inputs.versions:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
@ -38,21 +56,38 @@ class VerifyEOSVersion(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyTerminAttrVersion(AntaTest):
|
class VerifyTerminAttrVersion(AntaTest):
|
||||||
"""
|
"""Verifies that he device is running one of the allowed TerminAttr version.
|
||||||
Verifies the device is running one of the allowed TerminAttr version.
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if the device is running one of the allowed TerminAttr version.
|
||||||
|
* Failure: The test will fail if the device is not running one of the allowed TerminAttr version.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.software:
|
||||||
|
- VerifyTerminAttrVersion:
|
||||||
|
versions:
|
||||||
|
- v1.13.6
|
||||||
|
- v1.8.0
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyTerminAttrVersion"
|
name = "VerifyTerminAttrVersion"
|
||||||
description = "Verifies the device is running one of the allowed TerminAttr version."
|
description = "Verifies the TerminAttr version of the device."
|
||||||
categories = ["software"]
|
categories: ClassVar[list[str]] = ["software"]
|
||||||
commands = [AntaCommand(command="show version detail")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
versions: List[str]
|
"""Input model for the VerifyTerminAttrVersion test."""
|
||||||
"""List of allowed TerminAttr versions"""
|
|
||||||
|
versions: list[str]
|
||||||
|
"""List of allowed TerminAttr versions."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyTerminAttrVersion."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
command_output_data = command_output["details"]["packages"]["TerminAttr-core"]["version"]
|
command_output_data = command_output["details"]["packages"]["TerminAttr-core"]["version"]
|
||||||
if command_output_data in self.inputs.versions:
|
if command_output_data in self.inputs.versions:
|
||||||
|
@ -62,17 +97,32 @@ class VerifyTerminAttrVersion(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyEOSExtensions(AntaTest):
|
class VerifyEOSExtensions(AntaTest):
|
||||||
"""
|
"""Verifies that all EOS extensions installed on the device are enabled for boot persistence.
|
||||||
Verifies all EOS extensions installed on the device are enabled for boot persistence.
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if all EOS extensions installed on the device are enabled for boot persistence.
|
||||||
|
* Failure: The test will fail if some EOS extensions installed on the device are not enabled for boot persistence.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.software:
|
||||||
|
- VerifyEOSExtensions:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyEOSExtensions"
|
name = "VerifyEOSExtensions"
|
||||||
description = "Verifies all EOS extensions installed on the device are enabled for boot persistence."
|
description = "Verifies that all EOS extensions installed on the device are enabled for boot persistence."
|
||||||
categories = ["software"]
|
categories: ClassVar[list[str]] = ["software"]
|
||||||
commands = [AntaCommand(command="show extensions"), AntaCommand(command="show boot-extensions")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||||
|
AntaCommand(command="show extensions", revision=2),
|
||||||
|
AntaCommand(command="show boot-extensions", revision=1),
|
||||||
|
]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyEOSExtensions."""
|
||||||
boot_extensions = []
|
boot_extensions = []
|
||||||
show_extensions_command_output = self.instance_commands[0].json_output
|
show_extensions_command_output = self.instance_commands[0].json_output
|
||||||
show_boot_extensions_command_output = self.instance_commands[1].json_output
|
show_boot_extensions_command_output = self.instance_commands[1].json_output
|
||||||
|
@ -80,9 +130,9 @@ class VerifyEOSExtensions(AntaTest):
|
||||||
extension for extension, extension_data in show_extensions_command_output["extensions"].items() if extension_data["status"] == "installed"
|
extension for extension, extension_data in show_extensions_command_output["extensions"].items() if extension_data["status"] == "installed"
|
||||||
]
|
]
|
||||||
for extension in show_boot_extensions_command_output["extensions"]:
|
for extension in show_boot_extensions_command_output["extensions"]:
|
||||||
extension = extension.strip("\n")
|
formatted_extension = extension.strip("\n")
|
||||||
if extension != "":
|
if formatted_extension != "":
|
||||||
boot_extensions.append(extension)
|
boot_extensions.append(formatted_extension)
|
||||||
installed_extensions.sort()
|
installed_extensions.sort()
|
||||||
boot_extensions.sort()
|
boot_extensions.sort()
|
||||||
if installed_extensions == boot_extensions:
|
if installed_extensions == boot_extensions:
|
||||||
|
|
|
@ -1,52 +1,71 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to various Spanning Tree Protocol (STP) tests."""
|
||||||
Test functions related to various Spanning Tree Protocol (STP) settings
|
|
||||||
"""
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# Need to keep List for pydantic in python 3.8
|
from typing import ClassVar, Literal
|
||||||
from typing import List, Literal
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
from anta.custom_types import Vlan
|
from anta.custom_types import Vlan
|
||||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||||
from anta.tools.get_value import get_value
|
from anta.tools import get_value
|
||||||
|
|
||||||
|
|
||||||
class VerifySTPMode(AntaTest):
|
class VerifySTPMode(AntaTest):
|
||||||
"""
|
"""Verifies the configured STP mode for a provided list of VLAN(s).
|
||||||
Verifies the configured STP mode for a provided list of VLAN(s).
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the STP mode is configured properly in the specified VLAN(s).
|
----------------
|
||||||
* failure: The test will fail if the STP mode is NOT configured properly for one or more specified VLAN(s).
|
* Success: The test will pass if the STP mode is configured properly in the specified VLAN(s).
|
||||||
|
* Failure: The test will fail if the STP mode is NOT configured properly for one or more specified VLAN(s).
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.stp:
|
||||||
|
- VerifySTPMode:
|
||||||
|
mode: rapidPvst
|
||||||
|
vlans:
|
||||||
|
- 10
|
||||||
|
- 20
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifySTPMode"
|
name = "VerifySTPMode"
|
||||||
description = "Verifies the configured STP mode for a provided list of VLAN(s)."
|
description = "Verifies the configured STP mode for a provided list of VLAN(s)."
|
||||||
categories = ["stp"]
|
categories: ClassVar[list[str]] = ["stp"]
|
||||||
commands = [AntaTemplate(template="show spanning-tree vlan {vlan}")]
|
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"
|
mode: Literal["mstp", "rstp", "rapidPvst"] = "mstp"
|
||||||
"""STP mode to verify"""
|
"""STP mode to verify. Supported values: mstp, rstp, rapidPvst. Defaults to mstp."""
|
||||||
vlans: List[Vlan]
|
vlans: list[Vlan]
|
||||||
"""List of VLAN on which to verify STP mode"""
|
"""List of VLAN on which to verify STP mode."""
|
||||||
|
|
||||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||||
|
"""Render the template for each VLAN in the input list."""
|
||||||
return [template.render(vlan=vlan) for vlan in self.inputs.vlans]
|
return [template.render(vlan=vlan) for vlan in self.inputs.vlans]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifySTPMode."""
|
||||||
not_configured = []
|
not_configured = []
|
||||||
wrong_stp_mode = []
|
wrong_stp_mode = []
|
||||||
for command in self.instance_commands:
|
for command in self.instance_commands:
|
||||||
if "vlan" in command.params:
|
vlan_id = command.params.vlan
|
||||||
vlan_id = command.params["vlan"]
|
if not (
|
||||||
if not (stp_mode := get_value(command.json_output, f"spanningTreeVlanInstances.{vlan_id}.spanningTreeVlanInstance.protocol")):
|
stp_mode := get_value(
|
||||||
|
command.json_output,
|
||||||
|
f"spanningTreeVlanInstances.{vlan_id}.spanningTreeVlanInstance.protocol",
|
||||||
|
)
|
||||||
|
):
|
||||||
not_configured.append(vlan_id)
|
not_configured.append(vlan_id)
|
||||||
elif stp_mode != self.inputs.mode:
|
elif stp_mode != self.inputs.mode:
|
||||||
wrong_stp_mode.append(vlan_id)
|
wrong_stp_mode.append(vlan_id)
|
||||||
|
@ -59,21 +78,29 @@ class VerifySTPMode(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifySTPBlockedPorts(AntaTest):
|
class VerifySTPBlockedPorts(AntaTest):
|
||||||
"""
|
"""Verifies there is no STP blocked ports.
|
||||||
Verifies there is no STP blocked ports.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if there are NO ports blocked by STP.
|
----------------
|
||||||
* failure: The test will fail if there are ports blocked by STP.
|
* Success: The test will pass if there are NO ports blocked by STP.
|
||||||
|
* Failure: The test will fail if there are ports blocked by STP.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.stp:
|
||||||
|
- VerifySTPBlockedPorts:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifySTPBlockedPorts"
|
name = "VerifySTPBlockedPorts"
|
||||||
description = "Verifies there is no STP blocked ports."
|
description = "Verifies there is no STP blocked ports."
|
||||||
categories = ["stp"]
|
categories: ClassVar[list[str]] = ["stp"]
|
||||||
commands = [AntaCommand(command="show spanning-tree blockedports")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree blockedports", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifySTPBlockedPorts."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if not (stp_instances := command_output["spanningTreeInstances"]):
|
if not (stp_instances := command_output["spanningTreeInstances"]):
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
@ -84,21 +111,29 @@ class VerifySTPBlockedPorts(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifySTPCounters(AntaTest):
|
class VerifySTPCounters(AntaTest):
|
||||||
"""
|
"""Verifies there is no errors in STP BPDU packets.
|
||||||
Verifies there is no errors in STP BPDU packets.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if there are NO STP BPDU packet errors under all interfaces participating in STP.
|
----------------
|
||||||
* failure: The test will fail if there are STP BPDU packet errors on one or many interface(s).
|
* 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"
|
name = "VerifySTPCounters"
|
||||||
description = "Verifies there is no errors in STP BPDU packets."
|
description = "Verifies there is no errors in STP BPDU packets."
|
||||||
categories = ["stp"]
|
categories: ClassVar[list[str]] = ["stp"]
|
||||||
commands = [AntaCommand(command="show spanning-tree counters")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree counters", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifySTPCounters."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
interfaces_with_errors = [
|
interfaces_with_errors = [
|
||||||
interface for interface, counters in command_output["interfaces"].items() if counters["bpduTaggedError"] or counters["bpduOtherError"] != 0
|
interface for interface, counters in command_output["interfaces"].items() if counters["bpduTaggedError"] or counters["bpduOtherError"] != 0
|
||||||
|
@ -110,77 +145,104 @@ class VerifySTPCounters(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifySTPForwardingPorts(AntaTest):
|
class VerifySTPForwardingPorts(AntaTest):
|
||||||
"""
|
"""Verifies that all interfaces are in a forwarding state for a provided list of VLAN(s).
|
||||||
Verifies that all interfaces are in a forwarding state for a provided list of VLAN(s).
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if all interfaces are in a forwarding state for the specified VLAN(s).
|
----------------
|
||||||
* failure: The test will fail if one or many interfaces are NOT in a forwarding state in the specified VLAN(s).
|
* Success: The test will pass if all interfaces are in a forwarding state for the specified VLAN(s).
|
||||||
|
* Failure: The test will fail if one or many interfaces are NOT in a forwarding state in the specified VLAN(s).
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.stp:
|
||||||
|
- VerifySTPForwardingPorts:
|
||||||
|
vlans:
|
||||||
|
- 10
|
||||||
|
- 20
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifySTPForwardingPorts"
|
name = "VerifySTPForwardingPorts"
|
||||||
description = "Verifies that all interfaces are forwarding for a provided list of VLAN(s)."
|
description = "Verifies that all interfaces are forwarding for a provided list of VLAN(s)."
|
||||||
categories = ["stp"]
|
categories: ClassVar[list[str]] = ["stp"]
|
||||||
commands = [AntaTemplate(template="show spanning-tree topology vlan {vlan} status")]
|
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
|
class Input(AntaTest.Input):
|
||||||
vlans: List[Vlan]
|
"""Input model for the VerifySTPForwardingPorts test."""
|
||||||
"""List of VLAN on which to verify forwarding states"""
|
|
||||||
|
vlans: list[Vlan]
|
||||||
|
"""List of VLAN on which to verify forwarding states."""
|
||||||
|
|
||||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||||
|
"""Render the template for each VLAN in the input list."""
|
||||||
return [template.render(vlan=vlan) for vlan in self.inputs.vlans]
|
return [template.render(vlan=vlan) for vlan in self.inputs.vlans]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifySTPForwardingPorts."""
|
||||||
not_configured = []
|
not_configured = []
|
||||||
not_forwarding = []
|
not_forwarding = []
|
||||||
for command in self.instance_commands:
|
for command in self.instance_commands:
|
||||||
if "vlan" in command.params:
|
vlan_id = command.params.vlan
|
||||||
vlan_id = command.params["vlan"]
|
|
||||||
if not (topologies := get_value(command.json_output, "topologies")):
|
if not (topologies := get_value(command.json_output, "topologies")):
|
||||||
not_configured.append(vlan_id)
|
not_configured.append(vlan_id)
|
||||||
else:
|
else:
|
||||||
for value in topologies.values():
|
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"]
|
interfaces_not_forwarding = [interface for interface, state in value["interfaces"].items() if state["state"] != "forwarding"]
|
||||||
if interfaces_not_forwarding:
|
if interfaces_not_forwarding:
|
||||||
not_forwarding.append({f"VLAN {vlan_id}": interfaces_not_forwarding})
|
not_forwarding.append({f"VLAN {vlan_id}": interfaces_not_forwarding})
|
||||||
if not_configured:
|
if not_configured:
|
||||||
self.result.is_failure(f"STP instance is not configured for the following VLAN(s): {not_configured}")
|
self.result.is_failure(f"STP instance is not configured for the following VLAN(s): {not_configured}")
|
||||||
if not_forwarding:
|
if not_forwarding:
|
||||||
self.result.is_failure(f"The following VLAN(s) have interface(s) that are not in a 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:
|
if not not_configured and not interfaces_not_forwarding:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
|
||||||
|
|
||||||
class VerifySTPRootPriority(AntaTest):
|
class VerifySTPRootPriority(AntaTest):
|
||||||
"""
|
"""Verifies the STP root priority for a provided list of VLAN or MST instance ID(s).
|
||||||
Verifies the STP root priority for a provided list of VLAN or MST instance ID(s).
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the STP root priority is configured properly for the specified VLAN or MST instance ID(s).
|
----------------
|
||||||
* failure: The test will fail if the STP root priority is NOT configured properly for the specified VLAN or MST instance ID(s).
|
* Success: The test will pass if the STP root priority is configured properly for the specified VLAN or MST instance ID(s).
|
||||||
|
* Failure: The test will fail if the STP root priority is NOT configured properly for the specified VLAN or MST instance ID(s).
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.stp:
|
||||||
|
- VerifySTPRootPriority:
|
||||||
|
priority: 32768
|
||||||
|
instances:
|
||||||
|
- 10
|
||||||
|
- 20
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifySTPRootPriority"
|
name = "VerifySTPRootPriority"
|
||||||
description = "Verifies the STP root priority for a provided list of VLAN or MST instance ID(s)."
|
description = "Verifies the STP root priority for a provided list of VLAN or MST instance ID(s)."
|
||||||
categories = ["stp"]
|
categories: ClassVar[list[str]] = ["stp"]
|
||||||
commands = [AntaCommand(command="show spanning-tree root detail")]
|
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
|
priority: int
|
||||||
"""STP root priority to verify"""
|
"""STP root priority to verify."""
|
||||||
instances: List[Vlan] = []
|
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."""
|
"""List of VLAN or MST instance ID(s). If empty, ALL VLAN or MST instance ID(s) will be verified."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifySTPRootPriority."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if not (stp_instances := command_output["instances"]):
|
if not (stp_instances := command_output["instances"]):
|
||||||
self.result.is_failure("No STP instances configured")
|
self.result.is_failure("No STP instances configured")
|
||||||
return
|
return
|
||||||
# Checking the type of instances based on first instance
|
# Checking the type of instances based on first instance
|
||||||
first_name = list(stp_instances)[0]
|
first_name = next(iter(stp_instances))
|
||||||
if first_name.startswith("MST"):
|
if first_name.startswith("MST"):
|
||||||
prefix = "MST"
|
prefix = "MST"
|
||||||
elif first_name.startswith("VL"):
|
elif first_name.startswith("VL"):
|
||||||
|
|
117
anta/tests/stun.py
Normal file
117
anta/tests/stun.py
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
# 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 ipaddress import IPv4Address
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from anta.custom_types import Port
|
||||||
|
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||||
|
from anta.tools import get_failed_logs, get_value
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyStunClient(AntaTest):
|
||||||
|
"""
|
||||||
|
Verifies the configuration of the STUN client, specifically the IPv4 source address and port.
|
||||||
|
|
||||||
|
Optionally, it can also verify the public address and port.
|
||||||
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if the STUN client is correctly configured with the specified IPv4 source address/port and public address/port.
|
||||||
|
* Failure: The test will fail if the STUN client is not configured or if the IPv4 source address, public address, or port details are incorrect.
|
||||||
|
|
||||||
|
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
|
||||||
|
- source_address: 100.64.3.2
|
||||||
|
public_address: 100.64.3.21
|
||||||
|
source_port: 4500
|
||||||
|
public_port: 6006
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "VerifyStunClient"
|
||||||
|
description = "Verifies the STUN client is configured with the specified IPv4 source address and port. Validate the public IP and port if provided."
|
||||||
|
categories: ClassVar[list[str]] = ["stun"]
|
||||||
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show stun client translations {source_address} {source_port}")]
|
||||||
|
|
||||||
|
class Input(AntaTest.Input):
|
||||||
|
"""Input model for the VerifyStunClient test."""
|
||||||
|
|
||||||
|
stun_clients: list[ClientAddress]
|
||||||
|
|
||||||
|
class ClientAddress(BaseModel):
|
||||||
|
"""Source and public address/port details of STUN client."""
|
||||||
|
|
||||||
|
source_address: IPv4Address
|
||||||
|
"""IPv4 source address of STUN client."""
|
||||||
|
source_port: Port = 4500
|
||||||
|
"""Source port number for STUN client."""
|
||||||
|
public_address: IPv4Address | None = None
|
||||||
|
"""Optional IPv4 public address of STUN client."""
|
||||||
|
public_port: Port | None = None
|
||||||
|
"""Optional public port number for STUN client."""
|
||||||
|
|
||||||
|
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||||
|
"""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 VerifyStunClient."""
|
||||||
|
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"]
|
||||||
|
source_address = str(command.params.source_address)
|
||||||
|
source_port = command.params.source_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"STUN client transaction for source `{source_address}:{source_port}` is not found.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract the public address and port from the client input
|
||||||
|
public_address = client_input.public_address
|
||||||
|
public_port = client_input.public_port
|
||||||
|
|
||||||
|
# Extract the transaction ID from the bindings
|
||||||
|
transaction_id = next(iter(bindings.keys()))
|
||||||
|
|
||||||
|
# Prepare the actual and expected STUN data for comparison
|
||||||
|
actual_stun_data = {
|
||||||
|
"source ip": get_value(bindings, f"{transaction_id}.sourceAddress.ip"),
|
||||||
|
"source port": get_value(bindings, f"{transaction_id}.sourceAddress.port"),
|
||||||
|
}
|
||||||
|
expected_stun_data = {"source ip": source_address, "source port": source_port}
|
||||||
|
|
||||||
|
# If public address is provided, add it to the actual and expected STUN data
|
||||||
|
if public_address is not None:
|
||||||
|
actual_stun_data["public ip"] = get_value(bindings, f"{transaction_id}.publicAddress.ip")
|
||||||
|
expected_stun_data["public ip"] = str(public_address)
|
||||||
|
|
||||||
|
# If public port is provided, add it to the actual and expected STUN data
|
||||||
|
if public_port is not None:
|
||||||
|
actual_stun_data["public port"] = get_value(bindings, f"{transaction_id}.publicAddress.port")
|
||||||
|
expected_stun_data["public port"] = public_port
|
||||||
|
|
||||||
|
# If the actual STUN data does not match the expected STUN data, mark the test as failure
|
||||||
|
if actual_stun_data != expected_stun_data:
|
||||||
|
failed_log = get_failed_logs(expected_stun_data, actual_stun_data)
|
||||||
|
self.result.is_failure(f"For STUN source `{source_address}:{source_port}`:{failed_log}")
|
|
@ -1,40 +1,57 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to system-level features and protocols tests."""
|
||||||
Test functions related to system-level features and protocols
|
|
||||||
"""
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
from pydantic import conint
|
from anta.custom_types import PositiveInteger
|
||||||
|
|
||||||
from anta.models import AntaCommand, AntaTest
|
from anta.models import AntaCommand, AntaTest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.models import AntaTemplate
|
||||||
|
|
||||||
|
CPU_IDLE_THRESHOLD = 25
|
||||||
|
MEMORY_THRESHOLD = 0.25
|
||||||
|
DISK_SPACE_THRESHOLD = 75
|
||||||
|
|
||||||
|
|
||||||
class VerifyUptime(AntaTest):
|
class VerifyUptime(AntaTest):
|
||||||
"""
|
"""Verifies if the device uptime is higher than the provided minimum uptime value.
|
||||||
This test verifies if the device uptime is higher than the provided minimum uptime value.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the device uptime is higher than the provided value.
|
----------------
|
||||||
* failure: The test will fail if the device uptime is lower than the provided value.
|
* Success: The test will pass if the device uptime is higher than the provided value.
|
||||||
|
* Failure: The test will fail if the device uptime is lower than the provided value.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.system:
|
||||||
|
- VerifyUptime:
|
||||||
|
minimum: 86400
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyUptime"
|
name = "VerifyUptime"
|
||||||
description = "Verifies the device uptime."
|
description = "Verifies the device uptime."
|
||||||
categories = ["system"]
|
categories: ClassVar[list[str]] = ["system"]
|
||||||
commands = [AntaCommand(command="show uptime")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show uptime", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
minimum: conint(ge=0) # type: ignore
|
"""Input model for the VerifyUptime test."""
|
||||||
"""Minimum uptime in seconds"""
|
|
||||||
|
minimum: PositiveInteger
|
||||||
|
"""Minimum uptime in seconds."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyUptime."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if command_output["upTime"] > self.inputs.minimum:
|
if command_output["upTime"] > self.inputs.minimum:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
@ -43,24 +60,32 @@ class VerifyUptime(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyReloadCause(AntaTest):
|
class VerifyReloadCause(AntaTest):
|
||||||
"""
|
"""Verifies the last reload cause of the device.
|
||||||
This test verifies the last reload cause of the device.
|
|
||||||
|
|
||||||
Expected results:
|
Expected Results
|
||||||
* success: The test will pass if there are NO reload causes or if the last reload was caused by the user or after an FPGA upgrade.
|
----------------
|
||||||
* failure: The test will fail if the last reload was NOT caused by the user or after an FPGA upgrade.
|
* Success: The test will pass if there are NO reload causes or if the last reload was caused by the user or after an FPGA upgrade.
|
||||||
* error: The test will report an error if the reload cause is NOT available.
|
* Failure: The test will fail if the last reload was NOT caused by the user or after an FPGA upgrade.
|
||||||
|
* Error: The test will report an error if the reload cause is NOT available.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.system:
|
||||||
|
- VerifyReloadCause:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyReloadCause"
|
name = "VerifyReloadCause"
|
||||||
description = "Verifies the last reload cause of the device."
|
description = "Verifies the last reload cause of the device."
|
||||||
categories = ["system"]
|
categories: ClassVar[list[str]] = ["system"]
|
||||||
commands = [AntaCommand(command="show reload cause")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show reload cause", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyReloadCause."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if "resetCauses" not in command_output.keys():
|
if "resetCauses" not in command_output:
|
||||||
self.result.is_error(message="No reload causes available")
|
self.result.is_error(message="No reload causes available")
|
||||||
return
|
return
|
||||||
if len(command_output["resetCauses"]) == 0:
|
if len(command_output["resetCauses"]) == 0:
|
||||||
|
@ -79,24 +104,33 @@ class VerifyReloadCause(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyCoredump(AntaTest):
|
class VerifyCoredump(AntaTest):
|
||||||
"""
|
"""Verifies if there are core dump files in the /var/core directory.
|
||||||
This test verifies if there are core dump files in the /var/core directory.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if there are NO core dump(s) in /var/core.
|
----------------
|
||||||
* failure: The test will fail if there are core dump(s) in /var/core.
|
* Success: The test will pass if there are NO core dump(s) in /var/core.
|
||||||
|
* Failure: The test will fail if there are core dump(s) in /var/core.
|
||||||
|
|
||||||
Note:
|
Info
|
||||||
* This test will NOT check for minidump(s) generated by certain agents in /var/core/minidump.
|
----
|
||||||
|
* This test will NOT check for minidump(s) generated by certain agents in /var/core/minidump.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.system:
|
||||||
|
- VerifyCoreDump:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyCoredump"
|
name = "VerifyCoredump"
|
||||||
description = "Verifies there are no core dump files."
|
description = "Verifies there are no core dump files."
|
||||||
categories = ["system"]
|
categories: ClassVar[list[str]] = ["system"]
|
||||||
commands = [AntaCommand(command="show system coredump", ofmt="json")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system coredump", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyCoredump."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
core_files = command_output["coreFiles"]
|
core_files = command_output["coreFiles"]
|
||||||
if "minidump" in core_files:
|
if "minidump" in core_files:
|
||||||
|
@ -108,21 +142,29 @@ class VerifyCoredump(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyAgentLogs(AntaTest):
|
class VerifyAgentLogs(AntaTest):
|
||||||
"""
|
"""Verifies that no agent crash reports are present on the device.
|
||||||
This test verifies that no agent crash reports are present on the device.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if there is NO agent crash reported.
|
----------------
|
||||||
* failure: The test will fail if any agent crashes are reported.
|
* Success: The test will pass if there is NO agent crash reported.
|
||||||
|
* Failure: The test will fail if any agent crashes are reported.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.system:
|
||||||
|
- VerifyAgentLogs:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyAgentLogs"
|
name = "VerifyAgentLogs"
|
||||||
description = "Verifies there are no agent crash reports."
|
description = "Verifies there are no agent crash reports."
|
||||||
categories = ["system"]
|
categories: ClassVar[list[str]] = ["system"]
|
||||||
commands = [AntaCommand(command="show agent logs crash", ofmt="text")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show agent logs crash", ofmt="text")]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyAgentLogs."""
|
||||||
command_output = self.instance_commands[0].text_output
|
command_output = self.instance_commands[0].text_output
|
||||||
if len(command_output) == 0:
|
if len(command_output) == 0:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
@ -133,92 +175,124 @@ class VerifyAgentLogs(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyCPUUtilization(AntaTest):
|
class VerifyCPUUtilization(AntaTest):
|
||||||
"""
|
"""Verifies whether the CPU utilization is below 75%.
|
||||||
This test verifies whether the CPU utilization is below 75%.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the CPU utilization is below 75%.
|
----------------
|
||||||
* failure: The test will fail if the CPU utilization is over 75%.
|
* Success: The test will pass if the CPU utilization is below 75%.
|
||||||
|
* Failure: The test will fail if the CPU utilization is over 75%.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.system:
|
||||||
|
- VerifyCPUUtilization:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyCPUUtilization"
|
name = "VerifyCPUUtilization"
|
||||||
description = "Verifies whether the CPU utilization is below 75%."
|
description = "Verifies whether the CPU utilization is below 75%."
|
||||||
categories = ["system"]
|
categories: ClassVar[list[str]] = ["system"]
|
||||||
commands = [AntaCommand(command="show processes top once")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show processes top once", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyCPUUtilization."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
command_output_data = command_output["cpuInfo"]["%Cpu(s)"]["idle"]
|
command_output_data = command_output["cpuInfo"]["%Cpu(s)"]["idle"]
|
||||||
if command_output_data > 25:
|
if command_output_data > CPU_IDLE_THRESHOLD:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
self.result.is_failure(f"Device has reported a high CPU utilization: {100 - command_output_data}%")
|
self.result.is_failure(f"Device has reported a high CPU utilization: {100 - command_output_data}%")
|
||||||
|
|
||||||
|
|
||||||
class VerifyMemoryUtilization(AntaTest):
|
class VerifyMemoryUtilization(AntaTest):
|
||||||
"""
|
"""Verifies whether the memory utilization is below 75%.
|
||||||
This test verifies whether the memory utilization is below 75%.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the memory utilization is below 75%.
|
----------------
|
||||||
* failure: The test will fail if the memory utilization is over 75%.
|
* Success: The test will pass if the memory utilization is below 75%.
|
||||||
|
* Failure: The test will fail if the memory utilization is over 75%.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.system:
|
||||||
|
- VerifyMemoryUtilization:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyMemoryUtilization"
|
name = "VerifyMemoryUtilization"
|
||||||
description = "Verifies whether the memory utilization is below 75%."
|
description = "Verifies whether the memory utilization is below 75%."
|
||||||
categories = ["system"]
|
categories: ClassVar[list[str]] = ["system"]
|
||||||
commands = [AntaCommand(command="show version")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyMemoryUtilization."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
memory_usage = command_output["memFree"] / command_output["memTotal"]
|
memory_usage = command_output["memFree"] / command_output["memTotal"]
|
||||||
if memory_usage > 0.25:
|
if memory_usage > MEMORY_THRESHOLD:
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
self.result.is_failure(f"Device has reported a high memory usage: {(1 - memory_usage)*100:.2f}%")
|
self.result.is_failure(f"Device has reported a high memory usage: {(1 - memory_usage)*100:.2f}%")
|
||||||
|
|
||||||
|
|
||||||
class VerifyFileSystemUtilization(AntaTest):
|
class VerifyFileSystemUtilization(AntaTest):
|
||||||
"""
|
"""Verifies that no partition is utilizing more than 75% of its disk space.
|
||||||
This test verifies that no partition is utilizing more than 75% of its disk space.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if all partitions are using less than 75% of its disk space.
|
----------------
|
||||||
* failure: The test will fail if any partitions are using more than 75% of its disk space.
|
* Success: The test will pass if all partitions are using less than 75% of its disk space.
|
||||||
|
* Failure: The test will fail if any partitions are using more than 75% of its disk space.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.system:
|
||||||
|
- VerifyFileSystemUtilization:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyFileSystemUtilization"
|
name = "VerifyFileSystemUtilization"
|
||||||
description = "Verifies that no partition is utilizing more than 75% of its disk space."
|
description = "Verifies that no partition is utilizing more than 75% of its disk space."
|
||||||
categories = ["system"]
|
categories: ClassVar[list[str]] = ["system"]
|
||||||
commands = [AntaCommand(command="bash timeout 10 df -h", ofmt="text")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="bash timeout 10 df -h", ofmt="text")]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyFileSystemUtilization."""
|
||||||
command_output = self.instance_commands[0].text_output
|
command_output = self.instance_commands[0].text_output
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
for line in command_output.split("\n")[1:]:
|
for line in command_output.split("\n")[1:]:
|
||||||
if "loop" not in line and len(line) > 0 and (percentage := int(line.split()[4].replace("%", ""))) > 75:
|
if "loop" not in line and len(line) > 0 and (percentage := int(line.split()[4].replace("%", ""))) > DISK_SPACE_THRESHOLD:
|
||||||
self.result.is_failure(f"Mount point {line} is higher than 75%: reported {percentage}%")
|
self.result.is_failure(f"Mount point {line} is higher than 75%: reported {percentage}%")
|
||||||
|
|
||||||
|
|
||||||
class VerifyNTP(AntaTest):
|
class VerifyNTP(AntaTest):
|
||||||
"""
|
"""Verifies that the Network Time Protocol (NTP) is synchronized.
|
||||||
This test verifies that the Network Time Protocol (NTP) is synchronized.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the NTP is synchronised.
|
----------------
|
||||||
* failure: The test will fail if the NTP is NOT synchronised.
|
* Success: The test will pass if the NTP is synchronised.
|
||||||
|
* Failure: The test will fail if the NTP is NOT synchronised.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.system:
|
||||||
|
- VerifyNTP:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyNTP"
|
name = "VerifyNTP"
|
||||||
description = "Verifies if NTP is synchronised."
|
description = "Verifies if NTP is synchronised."
|
||||||
categories = ["system"]
|
categories: ClassVar[list[str]] = ["system"]
|
||||||
commands = [AntaCommand(command="show ntp status", ofmt="text")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ntp status", ofmt="text")]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyNTP."""
|
||||||
command_output = self.instance_commands[0].text_output
|
command_output = self.instance_commands[0].text_output
|
||||||
if command_output.split("\n")[0].split(" ")[0] == "synchronised":
|
if command_output.split("\n")[0].split(" ")[0] == "synchronised":
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
|
|
@ -1,42 +1,53 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to VLAN tests."""
|
||||||
Test functions related to VLAN
|
|
||||||
"""
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Literal
|
from typing import TYPE_CHECKING, ClassVar, Literal
|
||||||
|
|
||||||
from anta.custom_types import Vlan
|
from anta.custom_types import Vlan
|
||||||
from anta.models import AntaCommand, AntaTest
|
from anta.models import AntaCommand, AntaTest
|
||||||
from anta.tools.get_value import get_value
|
from anta.tools import get_failed_logs, get_value
|
||||||
from anta.tools.utils import get_failed_logs
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from anta.models import AntaTemplate
|
||||||
|
|
||||||
|
|
||||||
class VerifyVlanInternalPolicy(AntaTest):
|
class VerifyVlanInternalPolicy(AntaTest):
|
||||||
"""
|
"""Verifies if the VLAN internal allocation policy is ascending or descending and if the VLANs are within the specified range.
|
||||||
This class checks if the VLAN internal allocation policy is ascending or descending and
|
|
||||||
if the VLANs are within the specified range.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* Success: The test will pass if the VLAN internal allocation policy is either ascending or descending
|
----------------
|
||||||
|
* Success: The test will pass if the VLAN internal allocation policy is either ascending or descending
|
||||||
and the VLANs are within the specified range.
|
and the VLANs are within the specified range.
|
||||||
* Failure: The test will fail if the VLAN internal allocation policy is neither ascending nor descending
|
* Failure: The test will fail if the VLAN internal allocation policy is neither ascending nor descending
|
||||||
or the VLANs are outside the specified range.
|
or the VLANs are outside the specified range.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.vlan:
|
||||||
|
- VerifyVlanInternalPolicy:
|
||||||
|
policy: ascending
|
||||||
|
start_vlan_id: 1006
|
||||||
|
end_vlan_id: 4094
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyVlanInternalPolicy"
|
name = "VerifyVlanInternalPolicy"
|
||||||
description = "This test checks the VLAN internal allocation policy and the range of VLANs."
|
description = "Verifies the VLAN internal allocation policy and the range of VLANs."
|
||||||
categories = ["vlan"]
|
categories: ClassVar[list[str]] = ["vlan"]
|
||||||
commands = [AntaCommand(command="show vlan internal allocation policy")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vlan internal allocation policy", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
class Input(AntaTest.Input):
|
||||||
"""Inputs for the VerifyVlanInternalPolicy test."""
|
"""Input model for the VerifyVlanInternalPolicy test."""
|
||||||
|
|
||||||
policy: Literal["ascending", "descending"]
|
policy: Literal["ascending", "descending"]
|
||||||
"""The VLAN internal allocation policy."""
|
"""The VLAN internal allocation policy. Supported values: ascending, descending."""
|
||||||
start_vlan_id: Vlan
|
start_vlan_id: Vlan
|
||||||
"""The starting VLAN ID in the range."""
|
"""The starting VLAN ID in the range."""
|
||||||
end_vlan_id: Vlan
|
end_vlan_id: Vlan
|
||||||
|
@ -44,6 +55,7 @@ class VerifyVlanInternalPolicy(AntaTest):
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyVlanInternalPolicy."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
|
|
||||||
keys_to_verify = ["policy", "startVlanId", "endVlanId"]
|
keys_to_verify = ["policy", "startVlanId", "endVlanId"]
|
||||||
|
|
|
@ -1,44 +1,54 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""
|
"""Module related to VXLAN tests."""
|
||||||
Test functions related to VXLAN
|
|
||||||
"""
|
|
||||||
# Mypy does not understand AntaTest.Input typing
|
# Mypy does not understand AntaTest.Input typing
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from ipaddress import IPv4Address
|
from ipaddress import IPv4Address
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
# Need to keep List and Dict for pydantic in python 3.8
|
|
||||||
from typing import Dict, List
|
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from anta.custom_types import Vlan, Vni, VxlanSrcIntf
|
from anta.custom_types import Vlan, Vni, VxlanSrcIntf
|
||||||
from anta.models import AntaCommand, AntaTest
|
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 VerifyVxlan1Interface(AntaTest):
|
class VerifyVxlan1Interface(AntaTest):
|
||||||
"""
|
"""Verifies if the Vxlan1 interface is configured and 'up/up'.
|
||||||
This test verifies if the Vxlan1 interface is configured and 'up/up'.
|
|
||||||
|
|
||||||
!!! warning
|
Warning
|
||||||
The name of this test has been updated from 'VerifyVxlan' for better representation.
|
-------
|
||||||
|
The name of this test has been updated from 'VerifyVxlan' for better representation.
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the Vxlan1 interface is configured with line protocol status and interface status 'up'.
|
----------------
|
||||||
* failure: The test will fail if the Vxlan1 interface line protocol status or interface status are not 'up'.
|
* Success: The test will pass if the Vxlan1 interface is configured with line protocol status and interface status 'up'.
|
||||||
* skipped: The test will be skipped if the Vxlan1 interface is not configured.
|
* Failure: The test will fail if the Vxlan1 interface line protocol status or interface status are not 'up'.
|
||||||
|
* Skipped: The test will be skipped if the Vxlan1 interface is not configured.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.vxlan:
|
||||||
|
- VerifyVxlan1Interface:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyVxlan1Interface"
|
name = "VerifyVxlan1Interface"
|
||||||
description = "Verifies the Vxlan1 interface status."
|
description = "Verifies the Vxlan1 interface status."
|
||||||
categories = ["vxlan"]
|
categories: ClassVar[list[str]] = ["vxlan"]
|
||||||
commands = [AntaCommand(command="show interfaces description", ofmt="json")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces description", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyVxlan1Interface."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if "Vxlan1" not in command_output["interfaceDescriptions"]:
|
if "Vxlan1" not in command_output["interfaceDescriptions"]:
|
||||||
self.result.is_skipped("Vxlan1 interface is not configured")
|
self.result.is_skipped("Vxlan1 interface is not configured")
|
||||||
|
@ -50,27 +60,35 @@ class VerifyVxlan1Interface(AntaTest):
|
||||||
else:
|
else:
|
||||||
self.result.is_failure(
|
self.result.is_failure(
|
||||||
f"Vxlan1 interface is {command_output['interfaceDescriptions']['Vxlan1']['lineProtocolStatus']}"
|
f"Vxlan1 interface is {command_output['interfaceDescriptions']['Vxlan1']['lineProtocolStatus']}"
|
||||||
f"/{command_output['interfaceDescriptions']['Vxlan1']['interfaceStatus']}"
|
f"/{command_output['interfaceDescriptions']['Vxlan1']['interfaceStatus']}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class VerifyVxlanConfigSanity(AntaTest):
|
class VerifyVxlanConfigSanity(AntaTest):
|
||||||
"""
|
"""Verifies that no issues are detected with the VXLAN configuration.
|
||||||
This test verifies that no issues are detected with the VXLAN configuration.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if no issues are detected with the VXLAN configuration.
|
----------------
|
||||||
* failure: The test will fail if issues are detected with the VXLAN configuration.
|
* Success: The test will pass if no issues are detected with the VXLAN configuration.
|
||||||
* skipped: The test will be skipped if VXLAN is not configured on the device.
|
* Failure: The test will fail if issues are detected with the VXLAN configuration.
|
||||||
|
* Skipped: The test will be skipped if VXLAN is not configured on the device.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.vxlan:
|
||||||
|
- VerifyVxlanConfigSanity:
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyVxlanConfigSanity"
|
name = "VerifyVxlanConfigSanity"
|
||||||
description = "Verifies there are no VXLAN config-sanity inconsistencies."
|
description = "Verifies there are no VXLAN config-sanity inconsistencies."
|
||||||
categories = ["vxlan"]
|
categories: ClassVar[list[str]] = ["vxlan"]
|
||||||
commands = [AntaCommand(command="show vxlan config-sanity", ofmt="json")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan config-sanity", revision=1)]
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyVxlanConfigSanity."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
if "categories" not in command_output or len(command_output["categories"]) == 0:
|
if "categories" not in command_output or len(command_output["categories"]) == 0:
|
||||||
self.result.is_skipped("VXLAN is not configured")
|
self.result.is_skipped("VXLAN is not configured")
|
||||||
|
@ -87,26 +105,39 @@ class VerifyVxlanConfigSanity(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyVxlanVniBinding(AntaTest):
|
class VerifyVxlanVniBinding(AntaTest):
|
||||||
"""
|
"""Verifies the VNI-VLAN bindings of the Vxlan1 interface.
|
||||||
This test verifies the VNI-VLAN bindings of the Vxlan1 interface.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the VNI-VLAN bindings provided are properly configured.
|
----------------
|
||||||
* failure: The test will fail if any VNI lacks bindings or if any bindings are incorrect.
|
* Success: The test will pass if the VNI-VLAN bindings provided are properly configured.
|
||||||
* skipped: The test will be skipped if the Vxlan1 interface is not configured.
|
* Failure: The test will fail if any VNI lacks bindings or if any bindings are incorrect.
|
||||||
|
* Skipped: The test will be skipped if the Vxlan1 interface is not configured.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.vxlan:
|
||||||
|
- VerifyVxlanVniBinding:
|
||||||
|
bindings:
|
||||||
|
10010: 10
|
||||||
|
10020: 20
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyVxlanVniBinding"
|
name = "VerifyVxlanVniBinding"
|
||||||
description = "Verifies the VNI-VLAN bindings of the Vxlan1 interface."
|
description = "Verifies the VNI-VLAN bindings of the Vxlan1 interface."
|
||||||
categories = ["vxlan"]
|
categories: ClassVar[list[str]] = ["vxlan"]
|
||||||
commands = [AntaCommand(command="show vxlan vni", ofmt="json")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan vni", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
bindings: Dict[Vni, Vlan]
|
"""Input model for the VerifyVxlanVniBinding test."""
|
||||||
"""VNI to VLAN bindings to verify"""
|
|
||||||
|
bindings: dict[Vni, Vlan]
|
||||||
|
"""VNI to VLAN bindings to verify."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyVxlanVniBinding."""
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
|
||||||
no_binding = []
|
no_binding = []
|
||||||
|
@ -117,17 +148,17 @@ class VerifyVxlanVniBinding(AntaTest):
|
||||||
return
|
return
|
||||||
|
|
||||||
for vni, vlan in self.inputs.bindings.items():
|
for vni, vlan in self.inputs.bindings.items():
|
||||||
vni = str(vni)
|
str_vni = str(vni)
|
||||||
if vni in vxlan1["vniBindings"]:
|
if str_vni in vxlan1["vniBindings"]:
|
||||||
retrieved_vlan = vxlan1["vniBindings"][vni]["vlan"]
|
retrieved_vlan = vxlan1["vniBindings"][str_vni]["vlan"]
|
||||||
elif vni in vxlan1["vniBindingsToVrf"]:
|
elif str_vni in vxlan1["vniBindingsToVrf"]:
|
||||||
retrieved_vlan = vxlan1["vniBindingsToVrf"][vni]["vlan"]
|
retrieved_vlan = vxlan1["vniBindingsToVrf"][str_vni]["vlan"]
|
||||||
else:
|
else:
|
||||||
no_binding.append(vni)
|
no_binding.append(str_vni)
|
||||||
retrieved_vlan = None
|
retrieved_vlan = None
|
||||||
|
|
||||||
if retrieved_vlan and vlan != retrieved_vlan:
|
if retrieved_vlan and vlan != retrieved_vlan:
|
||||||
wrong_binding.append({vni: retrieved_vlan})
|
wrong_binding.append({str_vni: retrieved_vlan})
|
||||||
|
|
||||||
if no_binding:
|
if no_binding:
|
||||||
self.result.is_failure(f"The following VNI(s) have no binding: {no_binding}")
|
self.result.is_failure(f"The following VNI(s) have no binding: {no_binding}")
|
||||||
|
@ -137,26 +168,39 @@ class VerifyVxlanVniBinding(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyVxlanVtep(AntaTest):
|
class VerifyVxlanVtep(AntaTest):
|
||||||
"""
|
"""Verifies the VTEP peers of the Vxlan1 interface.
|
||||||
This test verifies the VTEP peers of the Vxlan1 interface.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if all provided VTEP peers are identified and matching.
|
----------------
|
||||||
* failure: The test will fail if any VTEP peer is missing or there are unexpected VTEP peers.
|
* Success: The test will pass if all provided VTEP peers are identified and matching.
|
||||||
* skipped: The test will be skipped if the Vxlan1 interface is not configured.
|
* Failure: The test will fail if any VTEP peer is missing or there are unexpected VTEP peers.
|
||||||
|
* Skipped: The test will be skipped if the Vxlan1 interface is not configured.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.vxlan:
|
||||||
|
- VerifyVxlanVtep:
|
||||||
|
vteps:
|
||||||
|
- 10.1.1.5
|
||||||
|
- 10.1.1.6
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyVxlanVtep"
|
name = "VerifyVxlanVtep"
|
||||||
description = "Verifies the VTEP peers of the Vxlan1 interface"
|
description = "Verifies the VTEP peers of the Vxlan1 interface"
|
||||||
categories = ["vxlan"]
|
categories: ClassVar[list[str]] = ["vxlan"]
|
||||||
commands = [AntaCommand(command="show vxlan vtep", ofmt="json")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan vtep", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
vteps: List[IPv4Address]
|
"""Input model for the VerifyVxlanVtep test."""
|
||||||
"""List of VTEP peers to verify"""
|
|
||||||
|
vteps: list[IPv4Address]
|
||||||
|
"""List of VTEP peers to verify."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyVxlanVtep."""
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
|
|
||||||
inputs_vteps = [str(input_vtep) for input_vtep in self.inputs.vteps]
|
inputs_vteps = [str(input_vtep) for input_vtep in self.inputs.vteps]
|
||||||
|
@ -176,30 +220,40 @@ class VerifyVxlanVtep(AntaTest):
|
||||||
|
|
||||||
|
|
||||||
class VerifyVxlan1ConnSettings(AntaTest):
|
class VerifyVxlan1ConnSettings(AntaTest):
|
||||||
"""
|
"""Verifies the interface vxlan1 source interface and UDP port.
|
||||||
Verifies the interface vxlan1 source interface and UDP port.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: Passes if the interface vxlan1 source interface and UDP port are correct.
|
----------------
|
||||||
* failure: Fails if the interface vxlan1 source interface or UDP port are incorrect.
|
* Success: Passes if the interface vxlan1 source interface and UDP port are correct.
|
||||||
* skipped: Skips if the Vxlan1 interface is not configured.
|
* Failure: Fails if the interface vxlan1 source interface or UDP port are incorrect.
|
||||||
|
* Skipped: Skips if the Vxlan1 interface is not configured.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
anta.tests.vxlan:
|
||||||
|
- VerifyVxlan1ConnSettings:
|
||||||
|
source_interface: Loopback1
|
||||||
|
udp_port: 4789
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "VerifyVxlan1ConnSettings"
|
name = "VerifyVxlan1ConnSettings"
|
||||||
description = "Verifies the interface vxlan1 source interface and UDP port."
|
description = "Verifies the interface vxlan1 source interface and UDP port."
|
||||||
categories = ["vxlan"]
|
categories: ClassVar[list[str]] = ["vxlan"]
|
||||||
commands = [AntaCommand(command="show interfaces")]
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces", revision=1)]
|
||||||
|
|
||||||
class Input(AntaTest.Input):
|
class Input(AntaTest.Input):
|
||||||
"""Inputs for the VerifyVxlan1ConnSettings test."""
|
"""Input model for the VerifyVxlan1ConnSettings test."""
|
||||||
|
|
||||||
source_interface: VxlanSrcIntf
|
source_interface: VxlanSrcIntf
|
||||||
"""Source loopback interface of vxlan1 interface"""
|
"""Source loopback interface of vxlan1 interface."""
|
||||||
udp_port: int = Field(ge=1024, le=65335)
|
udp_port: int = Field(ge=1024, le=65335)
|
||||||
"""UDP port used for vxlan1 interface"""
|
"""UDP port used for vxlan1 interface."""
|
||||||
|
|
||||||
@AntaTest.anta_test
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyVxlan1ConnSettings."""
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
|
|
||||||
|
|
230
anta/tools.py
Normal file
230
anta/tools.py
Normal file
|
@ -0,0 +1,230 @@
|
||||||
|
# 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.
|
||||||
|
"""Common functions used in ANTA tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def get_failed_logs(expected_output: dict[Any, Any], actual_output: dict[Any, Any]) -> str:
|
||||||
|
"""Get the failed log for a test.
|
||||||
|
|
||||||
|
Returns the failed log or an empty string if there is no difference between the expected and actual output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
----
|
||||||
|
expected_output (dict): Expected output of a test.
|
||||||
|
actual_output (dict): Actual output of a test
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str: Failed log of a test.
|
||||||
|
|
||||||
|
"""
|
||||||
|
failed_logs = []
|
||||||
|
|
||||||
|
for element, expected_data in expected_output.items():
|
||||||
|
actual_data = actual_output.get(element)
|
||||||
|
|
||||||
|
if actual_data is None:
|
||||||
|
failed_logs.append(f"\nExpected `{expected_data}` as the {element}, but it was not found in the actual output.")
|
||||||
|
elif actual_data != expected_data:
|
||||||
|
failed_logs.append(f"\nExpected `{expected_data}` as the {element}, but found `{actual_data}` instead.")
|
||||||
|
|
||||||
|
return "".join(failed_logs)
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-many-arguments
|
||||||
|
def get_dict_superset(
|
||||||
|
list_of_dicts: list[dict[Any, Any]],
|
||||||
|
input_dict: dict[Any, Any],
|
||||||
|
default: Any | None = None,
|
||||||
|
var_name: str | None = None,
|
||||||
|
custom_error_msg: str | None = None,
|
||||||
|
*,
|
||||||
|
required: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Get the first dictionary from a list of dictionaries that is a superset of the input dict.
|
||||||
|
|
||||||
|
Returns the supplied default value or None if there is no match and "required" is False.
|
||||||
|
|
||||||
|
Will return the first matching item if there are multiple matching items.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
list_of_dicts: list(dict)
|
||||||
|
List of Dictionaries to get list items from
|
||||||
|
input_dict : dict
|
||||||
|
Dictionary to check subset with a list of dict
|
||||||
|
default: any
|
||||||
|
Default value returned if the key and value are not found
|
||||||
|
required: bool
|
||||||
|
Fail if there is no match
|
||||||
|
var_name : str
|
||||||
|
String used for raising an exception with the full variable name
|
||||||
|
custom_error_msg : str
|
||||||
|
Custom error message to raise when required is True and the value is not found
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
any
|
||||||
|
Dict or default value
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ValueError
|
||||||
|
If the keys and values are not found and "required" == True
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not isinstance(list_of_dicts, list) or not list_of_dicts or not isinstance(input_dict, dict) or not input_dict:
|
||||||
|
if required:
|
||||||
|
error_msg = custom_error_msg or f"{var_name} not found in the provided list."
|
||||||
|
raise ValueError(error_msg)
|
||||||
|
return default
|
||||||
|
|
||||||
|
for list_item in list_of_dicts:
|
||||||
|
if isinstance(list_item, dict) and input_dict.items() <= list_item.items():
|
||||||
|
return list_item
|
||||||
|
|
||||||
|
if required:
|
||||||
|
error_msg = custom_error_msg or f"{var_name} not found in the provided list."
|
||||||
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-many-arguments
|
||||||
|
def get_value(
|
||||||
|
dictionary: dict[Any, Any],
|
||||||
|
key: str,
|
||||||
|
default: Any | None = None,
|
||||||
|
org_key: str | None = None,
|
||||||
|
separator: str = ".",
|
||||||
|
*,
|
||||||
|
required: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
"""Get a value from a dictionary or nested dictionaries.
|
||||||
|
|
||||||
|
Key supports dot-notation like "foo.bar" to do deeper lookups.
|
||||||
|
|
||||||
|
Returns the supplied default value or None if the key is not found and required is False.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
dictionary : dict
|
||||||
|
Dictionary to get key from
|
||||||
|
key : str
|
||||||
|
Dictionary Key - supporting dot-notation for nested dictionaries
|
||||||
|
default : any
|
||||||
|
Default value returned if the key is not found
|
||||||
|
required : bool
|
||||||
|
Fail if the key is not found
|
||||||
|
org_key : str
|
||||||
|
Internal variable used for raising exception with the full key name even when called recursively
|
||||||
|
separator: str
|
||||||
|
String to use as the separator parameter in the split function. Useful in cases when the key
|
||||||
|
can contain variables with "." inside (e.g. hostnames)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
any
|
||||||
|
Value or default value
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ValueError
|
||||||
|
If the key is not found and required == True.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if org_key is None:
|
||||||
|
org_key = key
|
||||||
|
keys = key.split(separator)
|
||||||
|
value = dictionary.get(keys[0])
|
||||||
|
if value is None:
|
||||||
|
if required:
|
||||||
|
raise ValueError(org_key)
|
||||||
|
return default
|
||||||
|
|
||||||
|
if len(keys) > 1:
|
||||||
|
return get_value(value, separator.join(keys[1:]), default=default, required=required, org_key=org_key, separator=separator)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-many-arguments
|
||||||
|
def get_item(
|
||||||
|
list_of_dicts: list[dict[Any, Any]],
|
||||||
|
key: Any,
|
||||||
|
value: Any,
|
||||||
|
default: Any | None = None,
|
||||||
|
var_name: str | None = None,
|
||||||
|
custom_error_msg: str | None = None,
|
||||||
|
*,
|
||||||
|
required: bool = False,
|
||||||
|
case_sensitive: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
"""Get one dictionary from a list of dictionaries by matching the given key and value.
|
||||||
|
|
||||||
|
Returns the supplied default value or None if there is no match and "required" is False.
|
||||||
|
|
||||||
|
Will return the first matching item if there are multiple matching items.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
list_of_dicts : list(dict)
|
||||||
|
List of Dictionaries to get list item from
|
||||||
|
key : any
|
||||||
|
Dictionary Key to match on
|
||||||
|
value : any
|
||||||
|
Value that must match
|
||||||
|
default : any
|
||||||
|
Default value returned if the key and value is not found
|
||||||
|
required : bool
|
||||||
|
Fail if there is no match
|
||||||
|
case_sensitive : bool
|
||||||
|
If the search value is a string, the comparison will ignore case by default
|
||||||
|
var_name : str
|
||||||
|
String used for raising exception with the full variable name
|
||||||
|
custom_error_msg : str
|
||||||
|
Custom error message to raise when required is True and the value is not found
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
any
|
||||||
|
Dict or default value
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ValueError
|
||||||
|
If the key and value is not found and "required" == True
|
||||||
|
|
||||||
|
"""
|
||||||
|
if var_name is None:
|
||||||
|
var_name = key
|
||||||
|
|
||||||
|
if (not isinstance(list_of_dicts, list)) or list_of_dicts == [] or value is None or key is None:
|
||||||
|
if required is True:
|
||||||
|
raise ValueError(custom_error_msg or var_name)
|
||||||
|
return default
|
||||||
|
|
||||||
|
for list_item in list_of_dicts:
|
||||||
|
if not isinstance(list_item, dict):
|
||||||
|
# List item is not a dict as required. Skip this item
|
||||||
|
continue
|
||||||
|
|
||||||
|
item_value = list_item.get(key)
|
||||||
|
|
||||||
|
# Perform case-insensitive comparison if value and item_value are strings and case_sensitive is False
|
||||||
|
if not case_sensitive and isinstance(value, str) and isinstance(item_value, str):
|
||||||
|
if item_value.casefold() == value.casefold():
|
||||||
|
return list_item
|
||||||
|
elif item_value == value:
|
||||||
|
# Match. Return this item
|
||||||
|
return list_item
|
||||||
|
|
||||||
|
# No Match
|
||||||
|
if required is True:
|
||||||
|
raise ValueError(custom_error_msg or var_name)
|
||||||
|
return default
|
|
@ -1,64 +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.
|
|
||||||
|
|
||||||
"""Get one dictionary from a list of dictionaries by matching the given key and values."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def get_dict_superset(
|
|
||||||
list_of_dicts: list[dict[Any, Any]],
|
|
||||||
input_dict: dict[Any, Any],
|
|
||||||
default: Optional[Any] = None,
|
|
||||||
required: bool = False,
|
|
||||||
var_name: Optional[str] = None,
|
|
||||||
custom_error_msg: Optional[str] = None,
|
|
||||||
) -> Any:
|
|
||||||
"""Get the first dictionary from a list of dictionaries that is a superset of the input dict.
|
|
||||||
|
|
||||||
Returns the supplied default value or None if there is no match and "required" is False.
|
|
||||||
|
|
||||||
Will return the first matching item if there are multiple matching items.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
list_of_dicts: list(dict)
|
|
||||||
List of Dictionaries to get list items from
|
|
||||||
input_dict : dict
|
|
||||||
Dictionary to check subset with a list of dict
|
|
||||||
default: any
|
|
||||||
Default value returned if the key and value are not found
|
|
||||||
required: bool
|
|
||||||
Fail if there is no match
|
|
||||||
var_name : str
|
|
||||||
String used for raising an exception with the full variable name
|
|
||||||
custom_error_msg : str
|
|
||||||
Custom error message to raise when required is True and the value is not found
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
any
|
|
||||||
Dict or default value
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
ValueError
|
|
||||||
If the keys and values are not found and "required" == True
|
|
||||||
"""
|
|
||||||
if not isinstance(list_of_dicts, list) or not list_of_dicts or not isinstance(input_dict, dict) or not input_dict:
|
|
||||||
if required:
|
|
||||||
error_msg = custom_error_msg or f"{var_name} not found in the provided list."
|
|
||||||
raise ValueError(error_msg)
|
|
||||||
return default
|
|
||||||
|
|
||||||
for list_item in list_of_dicts:
|
|
||||||
if isinstance(list_item, dict) and input_dict.items() <= list_item.items():
|
|
||||||
return list_item
|
|
||||||
|
|
||||||
if required:
|
|
||||||
error_msg = custom_error_msg or f"{var_name} not found in the provided list."
|
|
||||||
raise ValueError(error_msg)
|
|
||||||
|
|
||||||
return default
|
|
|
@ -1,83 +0,0 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
|
||||||
# that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
"""Get one dictionary from a list of dictionaries by matching the given key and value."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=too-many-arguments
|
|
||||||
def get_item(
|
|
||||||
list_of_dicts: list[dict[Any, Any]],
|
|
||||||
key: Any,
|
|
||||||
value: Any,
|
|
||||||
default: Optional[Any] = None,
|
|
||||||
required: bool = False,
|
|
||||||
case_sensitive: bool = False,
|
|
||||||
var_name: Optional[str] = None,
|
|
||||||
custom_error_msg: Optional[str] = None,
|
|
||||||
) -> Any:
|
|
||||||
"""Get one dictionary from a list of dictionaries by matching the given key and value.
|
|
||||||
|
|
||||||
Returns the supplied default value or None if there is no match and "required" is False.
|
|
||||||
|
|
||||||
Will return the first matching item if there are multiple matching items.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
list_of_dicts : list(dict)
|
|
||||||
List of Dictionaries to get list item from
|
|
||||||
key : any
|
|
||||||
Dictionary Key to match on
|
|
||||||
value : any
|
|
||||||
Value that must match
|
|
||||||
default : any
|
|
||||||
Default value returned if the key and value is not found
|
|
||||||
required : bool
|
|
||||||
Fail if there is no match
|
|
||||||
case_sensitive : bool
|
|
||||||
If the search value is a string, the comparison will ignore case by default
|
|
||||||
var_name : str
|
|
||||||
String used for raising exception with the full variable name
|
|
||||||
custom_error_msg : str
|
|
||||||
Custom error message to raise when required is True and the value is not found
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
any
|
|
||||||
Dict or default value
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
ValueError
|
|
||||||
If the key and value is not found and "required" == True
|
|
||||||
"""
|
|
||||||
if var_name is None:
|
|
||||||
var_name = key
|
|
||||||
|
|
||||||
if (not isinstance(list_of_dicts, list)) or list_of_dicts == [] or value is None or key is None:
|
|
||||||
if required is True:
|
|
||||||
raise ValueError(custom_error_msg or var_name)
|
|
||||||
return default
|
|
||||||
|
|
||||||
for list_item in list_of_dicts:
|
|
||||||
if not isinstance(list_item, dict):
|
|
||||||
# List item is not a dict as required. Skip this item
|
|
||||||
continue
|
|
||||||
|
|
||||||
item_value = list_item.get(key)
|
|
||||||
|
|
||||||
# Perform case-insensitive comparison if value and item_value are strings and case_sensitive is False
|
|
||||||
if not case_sensitive and isinstance(value, str) and isinstance(item_value, str):
|
|
||||||
if item_value.casefold() == value.casefold():
|
|
||||||
return list_item
|
|
||||||
elif item_value == value:
|
|
||||||
# Match. Return this item
|
|
||||||
return list_item
|
|
||||||
|
|
||||||
# No Match
|
|
||||||
if required is True:
|
|
||||||
raise ValueError(custom_error_msg or var_name)
|
|
||||||
return default
|
|
|
@ -1,56 +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.
|
|
||||||
"""
|
|
||||||
Get a value from a dictionary or nested dictionaries.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=too-many-arguments
|
|
||||||
def get_value(
|
|
||||||
dictionary: dict[Any, Any], key: str, default: Optional[Any] = None, required: bool = False, org_key: Optional[str] = None, separator: str = "."
|
|
||||||
) -> Any:
|
|
||||||
"""
|
|
||||||
Get a value from a dictionary or nested dictionaries.
|
|
||||||
Key supports dot-notation like "foo.bar" to do deeper lookups.
|
|
||||||
Returns the supplied default value or None if the key is not found and required is False.
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
dictionary : dict
|
|
||||||
Dictionary to get key from
|
|
||||||
key : str
|
|
||||||
Dictionary Key - supporting dot-notation for nested dictionaries
|
|
||||||
default : any
|
|
||||||
Default value returned if the key is not found
|
|
||||||
required : bool
|
|
||||||
Fail if the key is not found
|
|
||||||
org_key : str
|
|
||||||
Internal variable used for raising exception with the full key name even when called recursively
|
|
||||||
separator: str
|
|
||||||
String to use as the separator parameter in the split function. Useful in cases when the key
|
|
||||||
can contain variables with "." inside (e.g. hostnames)
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
any
|
|
||||||
Value or default value
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
ValueError
|
|
||||||
If the key is not found and required == True
|
|
||||||
"""
|
|
||||||
|
|
||||||
if org_key is None:
|
|
||||||
org_key = key
|
|
||||||
keys = key.split(separator)
|
|
||||||
value = dictionary.get(keys[0])
|
|
||||||
if value is None:
|
|
||||||
if required:
|
|
||||||
raise ValueError(org_key)
|
|
||||||
return default
|
|
||||||
|
|
||||||
if len(keys) > 1:
|
|
||||||
return get_value(value, separator.join(keys[1:]), default=default, required=required, org_key=org_key, separator=separator)
|
|
||||||
return value
|
|
|
@ -1,26 +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.
|
|
||||||
"""
|
|
||||||
Toolkit for ANTA.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def exc_to_str(exception: BaseException) -> str:
|
|
||||||
"""
|
|
||||||
Helper function that returns a human readable string from an BaseException object
|
|
||||||
"""
|
|
||||||
return f"{type(exception).__name__}{f' ({exception})' if str(exception) else ''}"
|
|
||||||
|
|
||||||
|
|
||||||
def tb_to_str(exception: BaseException) -> str:
|
|
||||||
"""
|
|
||||||
Helper function that returns a traceback string from an BaseException object
|
|
||||||
"""
|
|
||||||
return "Traceback (most recent call last):\n" + "".join(traceback.format_tb(exception.__traceback__))
|
|
|
@ -1,34 +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.
|
|
||||||
"""
|
|
||||||
Toolkit for ANTA.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def get_failed_logs(expected_output: dict[Any, Any], actual_output: dict[Any, Any]) -> str:
|
|
||||||
"""
|
|
||||||
Get the failed log for a test.
|
|
||||||
Returns the failed log or an empty string if there is no difference between the expected and actual output.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
expected_output (dict): Expected output of a test.
|
|
||||||
actual_output (dict): Actual output of a test
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Failed log of a test.
|
|
||||||
"""
|
|
||||||
failed_logs = []
|
|
||||||
|
|
||||||
for element, expected_data in expected_output.items():
|
|
||||||
actual_data = actual_output.get(element)
|
|
||||||
|
|
||||||
if actual_data is None:
|
|
||||||
failed_logs.append(f"\nExpected `{expected_data}` as the {element}, but it was not found in the actual output.")
|
|
||||||
elif actual_data != expected_data:
|
|
||||||
failed_logs.append(f"\nExpected `{expected_data}` as the {element}, but found `{actual_data}` instead.")
|
|
||||||
|
|
||||||
return "".join(failed_logs)
|
|
|
@ -4,16 +4,14 @@
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
[![License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](https://github.com/arista-netdevops-community/anta/blob/main/LICENSE)
|
|
||||||
[![Linting and Testing Anta](https://github.com/arista-netdevops-community/anta/actions/workflows/code-testing.yml/badge.svg)](https://github.com/arista-netdevops-community/anta/actions/workflows/code-testing.yml)
|
|
||||||
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
|
|
||||||
![GitHub commit activity (branch)](https://img.shields.io/github/commit-activity/m/arista-netdevops-community/anta)
|
|
||||||
[![github release](https://img.shields.io/github/release/arista-netdevops-community/anta.svg)](https://github.com/arista-netdevops-community/anta/releases/)
|
|
||||||
![PyPI - Downloads](https://img.shields.io/pypi/dm/anta)
|
|
||||||
![coverage](https://raw.githubusercontent.com/arista-netdevops-community/anta/coverage-badge/latest-release-coverage.svg)
|
|
||||||
|
|
||||||
# Arista Network Test Automation (ANTA) Framework
|
# Arista Network Test Automation (ANTA) Framework
|
||||||
|
|
||||||
|
| **Code** | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![Numpy](https://img.shields.io/badge/Docstring_format-numpy-blue)](https://numpydoc.readthedocs.io/en/latest/format.html) |
|
||||||
|
| :------------: | :-------|
|
||||||
|
| **License** | [![License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](https://github.com/arista-netdevops-community/anta/blob/main/LICENSE) |
|
||||||
|
| **GitHub** | [![CI](https://github.com/arista-netdevops-community/anta/actions/workflows/code-testing.yml/badge.svg)](https://github.com/arista-netdevops-community/anta/actions/workflows/code-testing.yml) ![Coverage](https://raw.githubusercontent.com/arista-netdevops-community/anta/coverage-badge/latest-release-coverage.svg) ![Commit](https://img.shields.io/github/last-commit/arista-netdevops-community/anta) ![GitHub commit activity (branch)](https://img.shields.io/github/commit-activity/m/arista-netdevops-community/anta) [![Github release](https://img.shields.io/github/release/arista-netdevops-community/anta.svg)](https://github.com/arista-netdevops-community/anta/releases/) [![Contributors](https://img.shields.io/github/contributors/arista-netdevops-community/anta)](https://github.com/arista-netdevops-community/anta/graphs/contributors) |
|
||||||
|
| **PyPi** | ![PyPi Version](https://img.shields.io/pypi/v/anta) ![Python Versions](https://img.shields.io/pypi/pyversions/anta) ![Python format](https://img.shields.io/pypi/format/anta) ![PyPI - Downloads](https://img.shields.io/pypi/dm/anta) |
|
||||||
|
|
||||||
ANTA is Python framework that automates tests for Arista devices.
|
ANTA is Python framework that automates tests for Arista devices.
|
||||||
|
|
||||||
- ANTA provides a [set of tests](api/tests.md) to validate the state of your network
|
- ANTA provides a [set of tests](api/tests.md) to validate the state of your network
|
||||||
|
|
|
@ -72,7 +72,6 @@ if __name__ == "__main__":
|
||||||
filename="inv.yml",
|
filename="inv.yml",
|
||||||
username="arista",
|
username="arista",
|
||||||
password="@rista123",
|
password="@rista123",
|
||||||
timeout=15,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Run the main coroutine
|
# Run the main coroutine
|
||||||
|
@ -129,7 +128,6 @@ if __name__ == "__main__":
|
||||||
filename="inv.yml",
|
filename="inv.yml",
|
||||||
username="arista",
|
username="arista",
|
||||||
password="@rista123",
|
password="@rista123",
|
||||||
timeout=15,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a list of commands with json output
|
# Create a list of commands with json output
|
||||||
|
@ -228,7 +226,7 @@ class VerifyTransceiversManufacturers(AntaTest):
|
||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
The test itself does not return any value, but the result is directly availble from your AntaTest object and exposes a `anta.result_manager.models.TestResult` object with result, name of the test and optional messages:
|
The test itself does not return any value, but the result is directly available from your AntaTest object and exposes a `anta.result_manager.models.TestResult` object with result, name of the test and optional messages:
|
||||||
|
|
||||||
|
|
||||||
- `name` (str): Device name where the test has run.
|
- `name` (str): Device name where the test has run.
|
||||||
|
@ -256,7 +254,7 @@ To make it easier to get data, ANTA defines 2 different classes to manage comman
|
||||||
Represent a command with following information:
|
Represent a command with following information:
|
||||||
|
|
||||||
- Command to run
|
- Command to run
|
||||||
- Ouput format expected
|
- Output format expected
|
||||||
- eAPI version
|
- eAPI version
|
||||||
- Output of the command
|
- Output of the command
|
||||||
|
|
||||||
|
@ -272,13 +270,13 @@ cmd2 = AntaCommand(command="show running-config diffs", ofmt="text")
|
||||||
!!! tip "Command revision and version"
|
!!! tip "Command revision and version"
|
||||||
* Most of EOS commands return a JSON structure according to a model (some commands may not be modeled hence the necessity to use `text` outformat sometimes.
|
* Most of EOS commands return a JSON structure according to a model (some commands may not be modeled hence the necessity to use `text` outformat sometimes.
|
||||||
* The model can change across time (adding feature, ... ) and when the model is changed in a non backward-compatible way, the __revision__ number is bumped. The initial model starts with __revision__ 1.
|
* The model can change across time (adding feature, ... ) and when the model is changed in a non backward-compatible way, the __revision__ number is bumped. The initial model starts with __revision__ 1.
|
||||||
* A __revision__ applies to a particular CLI command whereas a __version__ is global to an eAPI call. The __version__ is internally translated to a specific __revision__ for each CLI command in the RPC call. The currently supported __version__ vaues are `1` and `latest`.
|
* A __revision__ applies to a particular CLI command whereas a __version__ is global to an eAPI call. The __version__ is internally translated to a specific __revision__ for each CLI command in the RPC call. The currently supported __version__ values are `1` and `latest`.
|
||||||
* A __revision takes precedence over a version__ (e.g. if a command is run with version="latest" and revision=1, the first revision of the model is returned)
|
* A __revision takes precedence over a version__ (e.g. if a command is run with version="latest" and revision=1, the first revision of the model is returned)
|
||||||
* By default eAPI returns the first revision of each model to ensure that when upgrading, intergation with existing tools is not broken. This is done by using by default `version=1` in eAPI calls.
|
* By default, eAPI returns the first revision of each model to ensure that when upgrading, integrations with existing tools are not broken. This is done by using by default `version=1` in eAPI calls.
|
||||||
|
|
||||||
ANTA uses by default `version="latest"` in AntaCommand. For some commands, you may want to run them with a different revision or version.
|
By default, ANTA uses `version="latest"` in AntaCommand, but when developing tests, the revision MUST be provided when the outformat of the command is `json`. As explained earlier, this is to ensure that the eAPI always returns the same output model and that the test remains always valid from the day it was created. For some commands, you may also want to run them with a different revision or version.
|
||||||
|
|
||||||
For instance the `VerifyRoutingTableSize` test leverages the first revision of `show bfd peers`:
|
For instance, the `VerifyBFDPeersHealth` test leverages the first revision of `show bfd peers`:
|
||||||
|
|
||||||
```
|
```
|
||||||
# revision 1 as later revision introduce additional nesting for type
|
# revision 1 as later revision introduce additional nesting for type
|
||||||
|
|
|
@ -47,7 +47,7 @@ There might be scenarios where caching is not wanted. You can disable caching in
|
||||||
```bash
|
```bash
|
||||||
anta --disable-cache --username arista --password arista nrfu table
|
anta --disable-cache --username arista --password arista nrfu table
|
||||||
```
|
```
|
||||||
2. Caching can be disabled per device, network or range by setting the `disable_cache` key to `True` when definining the ANTA [Inventory](../usage-inventory-catalog.md#create-an-inventory-file) file:
|
2. Caching can be disabled per device, network or range by setting the `disable_cache` key to `True` when defining the ANTA [Inventory](../usage-inventory-catalog.md#create-an-inventory-file) file:
|
||||||
```yaml
|
```yaml
|
||||||
anta_inventory:
|
anta_inventory:
|
||||||
hosts:
|
hosts:
|
||||||
|
@ -71,7 +71,7 @@ There might be scenarios where caching is not wanted. You can disable caching in
|
||||||
```
|
```
|
||||||
This approach effectively disables caching for **ALL** commands sent to devices targeted by the `disable_cache` key.
|
This approach effectively disables caching for **ALL** commands sent to devices targeted by the `disable_cache` key.
|
||||||
|
|
||||||
3. For tests developpers, caching can be disabled for a specific [`AntaCommand`](../advanced_usages/as-python-lib.md#antacommand-class) or [`AntaTemplate`](../advanced_usages/as-python-lib.md#antatemplate-class) by setting the `use_cache` attribute to `False`. That means the command output will always be collected on the device and therefore, never use caching.
|
3. For tests developers, caching can be disabled for a specific [`AntaCommand`](../advanced_usages/as-python-lib.md#antacommand-class) or [`AntaTemplate`](../advanced_usages/as-python-lib.md#antatemplate-class) by setting the `use_cache` attribute to `False`. That means the command output will always be collected on the device and therefore, never use caching.
|
||||||
|
|
||||||
### Disable caching in a child class of `AntaDevice`
|
### Disable caching in a child class of `AntaDevice`
|
||||||
|
|
||||||
|
@ -82,6 +82,6 @@ class AnsibleEOSDevice(AntaDevice):
|
||||||
"""
|
"""
|
||||||
Implementation of an AntaDevice using Ansible HttpApi plugin for EOS.
|
Implementation of an AntaDevice using Ansible HttpApi plugin for EOS.
|
||||||
"""
|
"""
|
||||||
def __init__(self, name: str, connection: ConnectionBase, tags: list = None) -> None:
|
def __init__(self, name: str, connection: ConnectionBase, tags: set = None) -> None:
|
||||||
super().__init__(name, tags, disable_cache=True)
|
super().__init__(name, tags, disable_cache=True)
|
||||||
```
|
```
|
||||||
|
|
|
@ -21,24 +21,32 @@ from anta.decorators import skip_on_platforms
|
||||||
|
|
||||||
|
|
||||||
class VerifyTemperature(AntaTest):
|
class VerifyTemperature(AntaTest):
|
||||||
"""
|
"""Verifies if the device temperature is within acceptable limits.
|
||||||
This test verifies if the device temperature is within acceptable limits.
|
|
||||||
|
|
||||||
Expected Results:
|
Expected Results
|
||||||
* success: The test will pass if the device temperature is currently OK: 'temperatureOk'.
|
----------------
|
||||||
* failure: The test will fail if the device temperature is NOT OK.
|
* 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"
|
name = "VerifyTemperature"
|
||||||
description = "Verifies if the device temperature is within the acceptable range."
|
description = "Verifies the device temperature."
|
||||||
categories = ["hardware"]
|
categories: ClassVar[list[str]] = ["hardware"]
|
||||||
commands = [AntaCommand(command="show system environment temperature", ofmt="json")]
|
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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
|
"""Main test function for VerifyTemperature."""
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
temperature_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else ""
|
temperature_status = command_output.get("systemStatus", "")
|
||||||
if temperature_status == "temperatureOk":
|
if temperature_status == "temperatureOk":
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
|
@ -54,7 +62,7 @@ class VerifyTemperature(AntaTest):
|
||||||
- `name` (`str`): Name of the test. Used during reporting.
|
- `name` (`str`): Name of the test. Used during reporting.
|
||||||
- `description` (`str`): A human readable description of your test.
|
- `description` (`str`): A human readable description of your test.
|
||||||
- `categories` (`list[str]`): A list of categories in which the test belongs.
|
- `categories` (`list[str]`): A list of categories in which the test belongs.
|
||||||
- `commands` (`list[Union[AntaTemplate, AntaCommand]]`): A list of command to collect from devices. This list __must__ be a list of [AntaCommand](../api/models.md#anta.models.AntaCommand) or [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances. Rendering [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances will be discussed later.
|
- `commands` (`[list[AntaCommand | AntaTemplate]]`): A list of command to collect from devices. This list __must__ be a list of [AntaCommand](../api/models.md#anta.models.AntaCommand) or [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances. Rendering [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances will be discussed later.
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
All these class attributes are mandatory. If any attribute is missing, a `NotImplementedError` exception will be raised during class instantiation.
|
All these class attributes are mandatory. If any attribute is missing, a `NotImplementedError` exception will be raised during class instantiation.
|
||||||
|
@ -127,7 +135,7 @@ The base definition of [AntaTest.Input](../api/models.md#anta.models.AntaTest.In
|
||||||
### Methods
|
### Methods
|
||||||
|
|
||||||
- [test(self) -> None](../api/models.md#anta.models.AntaTest.test): This is an abstract method that __must__ be implemented. It contains the test logic that can access the collected command outputs using the `instance_commands` instance attribute, access the test inputs using the `inputs` instance attribute and __must__ set the `result` instance attribute accordingly. It must be implemented using the `AntaTest.anta_test` decorator that provides logging and will collect commands before executing the `test()` method.
|
- [test(self) -> None](../api/models.md#anta.models.AntaTest.test): This is an abstract method that __must__ be implemented. It contains the test logic that can access the collected command outputs using the `instance_commands` instance attribute, access the test inputs using the `inputs` instance attribute and __must__ set the `result` instance attribute accordingly. It must be implemented using the `AntaTest.anta_test` decorator that provides logging and will collect commands before executing the `test()` method.
|
||||||
- [render(self, template: AntaTemplate) -> list[AntaCommand]](../api/models.md#anta.models.AntaTest.render): This method only needs to be implemented if [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances are present in the `commands` class attribute. It will be called for every [AntaTemplate](../api/models.md#anta.models.AntaTemplate) occurence and __must__ return a list of [AntaCommand](../api/models.md#anta.models.AntaCommand) using the [AntaTemplate.render()](../api/models.md#anta.models.AntaTemplate.render) method. It can access test inputs using the `inputs` instance attribute.
|
- [render(self, template: AntaTemplate) -> list[AntaCommand]](../api/models.md#anta.models.AntaTest.render): This method only needs to be implemented if [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances are present in the `commands` class attribute. It will be called for every [AntaTemplate](../api/models.md#anta.models.AntaTemplate) occurrence and __must__ return a list of [AntaCommand](../api/models.md#anta.models.AntaCommand) using the [AntaTemplate.render()](../api/models.md#anta.models.AntaTemplate.render) method. It can access test inputs using the `inputs` instance attribute.
|
||||||
|
|
||||||
## Test execution
|
## Test execution
|
||||||
|
|
||||||
|
@ -191,8 +199,24 @@ If the user needs to provide inputs for your test, you need to define a [pydanti
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class <YourTestName>(AntaTest):
|
class <YourTestName>(AntaTest):
|
||||||
|
"""Verifies ...
|
||||||
|
|
||||||
|
Expected Results
|
||||||
|
----------------
|
||||||
|
* Success: The test will pass if ...
|
||||||
|
* Failure: The test will fail if ...
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```yaml
|
||||||
|
your.module.path:
|
||||||
|
- YourTestName:
|
||||||
|
field_name: example_field_value
|
||||||
|
```
|
||||||
|
"""
|
||||||
...
|
...
|
||||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
class Input(AntaTest.Input):
|
||||||
|
"""Inputs for my awesome test."""
|
||||||
<input field name>: <input field type>
|
<input field name>: <input field type>
|
||||||
"""<input field docstring>"""
|
"""<input field docstring>"""
|
||||||
```
|
```
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for AAA tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for interfaces tests
|
|
||||||
|
|
||||||
::: anta.tests.aaa
|
::: anta.tests.aaa
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for BFD tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for bfd tests
|
|
||||||
|
|
||||||
::: anta.tests.bfd
|
::: anta.tests.bfd
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
show_labels: true
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for device configuration tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for configuration tests
|
|
||||||
|
|
||||||
::: anta.tests.configuration
|
::: anta.tests.configuration
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for connectivity tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for connectivity tests
|
|
||||||
|
|
||||||
::: anta.tests.connectivity
|
::: anta.tests.connectivity
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for Field Notices tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for Field Notices tests
|
|
||||||
|
|
||||||
::: anta.tests.field_notices
|
::: anta.tests.field_notices
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
20
docs/api/tests.greent.md
Normal file
20
docs/api/tests.greent.md
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for GreenT tests
|
||||||
|
---
|
||||||
|
<!--
|
||||||
|
~ 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.tests.greent
|
||||||
|
options:
|
||||||
|
show_root_heading: false
|
||||||
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for hardware tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for hardware tests
|
|
||||||
|
|
||||||
::: anta.tests.hardware
|
::: anta.tests.hardware
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for interfaces tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for interfaces tests
|
|
||||||
|
|
||||||
::: anta.tests.interfaces
|
::: anta.tests.interfaces
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
20
docs/api/tests.lanz.md
Normal file
20
docs/api/tests.lanz.md
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for LANZ tests
|
||||||
|
---
|
||||||
|
<!--
|
||||||
|
~ 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.tests.lanz
|
||||||
|
options:
|
||||||
|
show_root_heading: false
|
||||||
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for logging tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for logging tests
|
|
||||||
|
|
||||||
::: anta.tests.logging
|
::: anta.tests.logging
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
|
@ -4,22 +4,28 @@
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA Tests landing page
|
# ANTA Tests Landing Page
|
||||||
|
|
||||||
This section describes all the available tests provided by ANTA package.
|
This section describes all the available tests provided by the ANTA package.
|
||||||
|
|
||||||
|
## Available Tests
|
||||||
|
|
||||||
|
Here are the tests that we currently provide:
|
||||||
|
|
||||||
- [AAA](tests.aaa.md)
|
- [AAA](tests.aaa.md)
|
||||||
- [BFD](tests.bfd.md)
|
- [BFD](tests.bfd.md)
|
||||||
- [Configuration](tests.configuration.md)
|
- [Configuration](tests.configuration.md)
|
||||||
- [Connectivity](tests.connectivity.md)
|
- [Connectivity](tests.connectivity.md)
|
||||||
- [Field Notice](tests.field_notices.md)
|
- [Field Notice](tests.field_notices.md)
|
||||||
|
- [GreenT](tests.greent.md)
|
||||||
- [Hardware](tests.hardware.md)
|
- [Hardware](tests.hardware.md)
|
||||||
- [Interfaces](tests.interfaces.md)
|
- [Interfaces](tests.interfaces.md)
|
||||||
|
- [LANZ](tests.lanz.md)
|
||||||
- [Logging](tests.logging.md)
|
- [Logging](tests.logging.md)
|
||||||
- [MLAG](tests.mlag.md)
|
- [MLAG](tests.mlag.md)
|
||||||
- [Multicast](tests.multicast.md)
|
- [Multicast](tests.multicast.md)
|
||||||
- [Profiles](tests.profiles.md)
|
- [Profiles](tests.profiles.md)
|
||||||
|
- [PTP](tests.ptp.md)
|
||||||
- [Routing Generic](tests.routing.generic.md)
|
- [Routing Generic](tests.routing.generic.md)
|
||||||
- [Routing BGP](tests.routing.bgp.md)
|
- [Routing BGP](tests.routing.bgp.md)
|
||||||
- [Routing OSPF](tests.routing.ospf.md)
|
- [Routing OSPF](tests.routing.ospf.md)
|
||||||
|
@ -28,10 +34,11 @@ This section describes all the available tests provided by ANTA package.
|
||||||
- [SNMP](tests.snmp.md)
|
- [SNMP](tests.snmp.md)
|
||||||
- [Software](tests.software.md)
|
- [Software](tests.software.md)
|
||||||
- [STP](tests.stp.md)
|
- [STP](tests.stp.md)
|
||||||
|
- [STUN](tests.stun.md)
|
||||||
- [System](tests.system.md)
|
- [System](tests.system.md)
|
||||||
- [VLAN](tests.vlan.md)
|
- [VLAN](tests.vlan.md)
|
||||||
- [VXLAN](tests.vxlan.md)
|
- [VXLAN](tests.vxlan.md)
|
||||||
|
|
||||||
|
## Using the Tests
|
||||||
|
|
||||||
|
All these tests can be imported in a [catalog](../usage-inventory-catalog.md) to be used by [the ANTA CLI](../cli/nrfu.md) or in your [own framework](../advanced_usages/as-python-lib.md).
|
||||||
All these tests can be imported in a [catalog](../usage-inventory-catalog.md) to be used by [the anta cli](../cli/nrfu.md) or in your [own framework](../advanced_usages/as-python-lib.md)
|
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for MLAG tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for mlag tests
|
|
||||||
|
|
||||||
::: anta.tests.mlag
|
::: anta.tests.mlag
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for multicast and IGMP tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for multicast tests
|
|
||||||
|
|
||||||
::: anta.tests.multicast
|
::: anta.tests.multicast
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for profiles tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for profiles tests
|
|
||||||
|
|
||||||
::: anta.tests.profiles
|
::: anta.tests.profiles
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
20
docs/api/tests.ptp.md
Normal file
20
docs/api/tests.ptp.md
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for PTP tests
|
||||||
|
---
|
||||||
|
<!--
|
||||||
|
~ 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.tests.ptp
|
||||||
|
options:
|
||||||
|
show_root_heading: false
|
||||||
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for BGP tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for BGP tests
|
|
||||||
|
|
||||||
::: anta.tests.routing.bgp
|
::: anta.tests.routing.bgp
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for generic routing tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for routing-generic tests
|
|
||||||
|
|
||||||
::: anta.tests.routing.generic
|
::: anta.tests.routing.generic
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for OSPF tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for routing-ospf tests
|
|
||||||
|
|
||||||
::: anta.tests.routing.ospf
|
::: anta.tests.routing.ospf
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for security tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for security tests
|
|
||||||
|
|
||||||
::: anta.tests.security
|
::: anta.tests.security
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for services tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for services tests
|
|
||||||
|
|
||||||
::: anta.tests.services
|
::: anta.tests.services
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for SNMP tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for SNMP tests
|
|
||||||
|
|
||||||
::: anta.tests.snmp
|
::: anta.tests.snmp
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for Software tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for software tests
|
|
||||||
|
|
||||||
::: anta.tests.software
|
::: anta.tests.software
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
---
|
||||||
|
anta_title: ANTA catalog for STP tests
|
||||||
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
~ Use of this source code is governed by the Apache License 2.0
|
~ Use of this source code is governed by the Apache License 2.0
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# ANTA catalog for STP tests
|
|
||||||
|
|
||||||
::: anta.tests.stp
|
::: anta.tests.stp
|
||||||
options:
|
options:
|
||||||
show_root_heading: false
|
show_root_heading: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
|
show_bases: false
|
||||||
merge_init_into_class: false
|
merge_init_into_class: false
|
||||||
|
anta_hide_test_module_description: true
|
||||||
|
show_labels: true
|
||||||
|
filters:
|
||||||
|
- "!test"
|
||||||
|
- "!render"
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue