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
|
@ -2,6 +2,7 @@
|
|||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""Arista Network Test Automation (ANTA) Framework."""
|
||||
|
||||
import importlib.metadata
|
||||
import os
|
||||
|
||||
|
@ -18,7 +19,7 @@ __credits__ = [
|
|||
]
|
||||
__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")
|
||||
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""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 typing import Any, AnyStr
|
||||
|
@ -12,8 +12,7 @@ Device = aioeapi.Device
|
|||
|
||||
|
||||
class EapiCommandError(RuntimeError):
|
||||
"""
|
||||
Exception class for EAPI command errors
|
||||
"""Exception class for EAPI command errors.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
|
@ -25,8 +24,8 @@ class EapiCommandError(RuntimeError):
|
|||
"""
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]):
|
||||
"""Initializer for the EapiCommandError exception"""
|
||||
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."""
|
||||
self.failed = failed
|
||||
self.errmsg = errmsg
|
||||
self.errors = errors
|
||||
|
@ -35,7 +34,7 @@ class EapiCommandError(RuntimeError):
|
|||
super().__init__()
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""returns the error message associated with the exception"""
|
||||
"""Returns the error message associated with the exception."""
|
||||
return self.errmsg
|
||||
|
||||
|
||||
|
@ -43,8 +42,7 @@ aioeapi.EapiCommandError = EapiCommandError
|
|||
|
||||
|
||||
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
|
||||
----------
|
||||
|
@ -101,7 +99,7 @@ async def jsonrpc_exec(self, jsonrpc: dict) -> list[dict | AnyStr]: # type: ign
|
|||
failed=commands[err_at]["cmd"],
|
||||
errors=cmd_data[err_at]["errors"],
|
||||
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.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Catalog related functions
|
||||
"""
|
||||
"""Catalog related functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from inspect import isclass
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type, Union
|
||||
from typing import TYPE_CHECKING, Any, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_validator
|
||||
from pydantic.types import ImportString
|
||||
from pydantic_core import PydanticCustomError
|
||||
from yaml import YAMLError, safe_load
|
||||
|
||||
from anta.logger import anta_log_exception
|
||||
from anta.models import AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# { <module_name> : [ { <test_class_name>: <input_as_dict_or_None> }, ... ] }
|
||||
RawCatalogInput = Dict[str, List[Dict[str, Optional[Dict[str, Any]]]]]
|
||||
RawCatalogInput = dict[str, list[dict[str, Optional[dict[str, Any]]]]]
|
||||
|
||||
# [ ( <AntaTest class>, <input_as AntaTest.Input or dict or None > ), ... ]
|
||||
ListAntaTestTuples = List[Tuple[Type[AntaTest], Optional[Union[AntaTest.Input, Dict[str, Any]]]]]
|
||||
ListAntaTestTuples = list[tuple[type[AntaTest], Optional[Union[AntaTest.Input, dict[str, Any]]]]]
|
||||
|
||||
|
||||
class AntaTestDefinition(BaseModel):
|
||||
"""
|
||||
Define a test with its associated inputs.
|
||||
"""Define a test with its associated inputs.
|
||||
|
||||
test: An AntaTest concrete subclass
|
||||
inputs: The associated AntaTest.Input subclass instance
|
||||
|
@ -39,13 +40,13 @@ class AntaTestDefinition(BaseModel):
|
|||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
test: Type[AntaTest]
|
||||
test: type[AntaTest]
|
||||
inputs: AntaTest.Input
|
||||
|
||||
def __init__(self, **data: Any) -> None:
|
||||
"""
|
||||
Inject test in the context to allow to instantiate Input in the BeforeValidator
|
||||
https://docs.pydantic.dev/2.0/usage/validators/#using-validation-context-with-basemodel-initialization
|
||||
def __init__(self, **data: type[AntaTest] | AntaTest.Input | dict[str, Any] | None) -> None:
|
||||
"""Inject test in the context to allow to instantiate Input in the BeforeValidator.
|
||||
|
||||
https://docs.pydantic.dev/2.0/usage/validators/#using-validation-context-with-basemodel-initialization.
|
||||
"""
|
||||
self.__pydantic_validator__.validate_python(
|
||||
data,
|
||||
|
@ -56,133 +57,170 @@ class AntaTestDefinition(BaseModel):
|
|||
|
||||
@field_validator("inputs", mode="before")
|
||||
@classmethod
|
||||
def instantiate_inputs(cls, data: AntaTest.Input | dict[str, Any] | None, info: ValidationInfo) -> AntaTest.Input:
|
||||
"""
|
||||
def instantiate_inputs(
|
||||
cls: type[AntaTestDefinition],
|
||||
data: AntaTest.Input | dict[str, Any] | None,
|
||||
info: ValidationInfo,
|
||||
) -> AntaTest.Input:
|
||||
"""Ensure the test inputs can be instantiated and thus are valid.
|
||||
|
||||
If the test has no inputs, allow the user to omit providing the `inputs` field.
|
||||
If the test has inputs, allow the user to provide a valid dictionary of the input fields.
|
||||
This model validator will instantiate an Input class from the `test` class field.
|
||||
"""
|
||||
if info.context is None:
|
||||
raise ValueError("Could not validate inputs as no test class could be identified")
|
||||
msg = "Could not validate inputs as no test class could be identified"
|
||||
raise ValueError(msg)
|
||||
# Pydantic guarantees at this stage that test_class is a subclass of AntaTest because of the ordering
|
||||
# of fields in the class definition - so no need to check for this
|
||||
test_class = info.context["test"]
|
||||
if not (isclass(test_class) and issubclass(test_class, AntaTest)):
|
||||
raise ValueError(f"Could not validate inputs as no test class {test_class} is not a subclass of AntaTest")
|
||||
msg = f"Could not validate inputs as no test class {test_class} is not a subclass of AntaTest"
|
||||
raise ValueError(msg)
|
||||
|
||||
if data is None:
|
||||
return test_class.Input()
|
||||
if isinstance(data, AntaTest.Input):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
return test_class.Input(**data)
|
||||
raise ValueError(f"Coud not instantiate inputs as type {type(data).__name__} is not valid")
|
||||
try:
|
||||
if data is None:
|
||||
return test_class.Input()
|
||||
if isinstance(data, dict):
|
||||
return test_class.Input(**data)
|
||||
except ValidationError as e:
|
||||
inputs_msg = str(e).replace("\n", "\n\t")
|
||||
err_type = "wrong_test_inputs"
|
||||
raise PydanticCustomError(
|
||||
err_type,
|
||||
f"{test_class.name} test inputs are not valid: {inputs_msg}\n",
|
||||
{"errors": e.errors()},
|
||||
) from e
|
||||
msg = f"Could not instantiate inputs as type {type(data).__name__} is not valid"
|
||||
raise ValueError(msg)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_inputs(self) -> "AntaTestDefinition":
|
||||
"""
|
||||
def check_inputs(self) -> 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`.
|
||||
"""
|
||||
if not isinstance(self.inputs, self.test.Input):
|
||||
raise ValueError(f"Test input has type {self.inputs.__class__.__qualname__} but expected type {self.test.Input.__qualname__}")
|
||||
msg = f"Test input has type {self.inputs.__class__.__qualname__} but expected type {self.test.Input.__qualname__}"
|
||||
raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError
|
||||
return self
|
||||
|
||||
|
||||
class AntaCatalogFile(RootModel[Dict[ImportString[Any], List[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
This model represents an ANTA Test Catalog File.
|
||||
class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods
|
||||
"""Represents an ANTA Test Catalog File.
|
||||
|
||||
A valid test catalog file must have the following structure:
|
||||
Example:
|
||||
-------
|
||||
A valid test catalog file must have the following structure:
|
||||
```
|
||||
<Python module>:
|
||||
- <AntaTest subclass>:
|
||||
<AntaTest.Input compliant dictionary>
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
root: Dict[ImportString[Any], List[AntaTestDefinition]]
|
||||
root: dict[ImportString[Any], list[AntaTestDefinition]]
|
||||
|
||||
@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")
|
||||
@classmethod
|
||||
def check_tests(cls, data: Any) -> Any:
|
||||
"""
|
||||
Allow the user to provide a Python data structure that only has string values.
|
||||
def check_tests(cls: type[AntaCatalogFile], data: Any) -> Any: # noqa: ANN401
|
||||
"""Allow the user to provide a Python data structure that only has string values.
|
||||
|
||||
This validator will try to flatten and import Python modules, check if the tests classes
|
||||
are actually defined in their respective Python module and instantiate Input instances
|
||||
with provided value to validate test inputs.
|
||||
"""
|
||||
|
||||
def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[ModuleType, list[Any]]:
|
||||
"""
|
||||
Allow the user to provide a data structure with nested Python modules.
|
||||
|
||||
Example:
|
||||
```
|
||||
anta.tests.routing:
|
||||
generic:
|
||||
- <AntaTestDefinition>
|
||||
bgp:
|
||||
- <AntaTestDefinition>
|
||||
```
|
||||
`anta.tests.routing.generic` and `anta.tests.routing.bgp` are importable Python modules.
|
||||
"""
|
||||
modules: dict[ModuleType, list[Any]] = {}
|
||||
for module_name, tests in data.items():
|
||||
if package and not module_name.startswith("."):
|
||||
module_name = f".{module_name}"
|
||||
try:
|
||||
module: ModuleType = importlib.import_module(name=module_name, package=package)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
# A test module is potentially user-defined code.
|
||||
# We need to catch everything if we want to have meaningful logs
|
||||
module_str = f"{module_name[1:] if module_name.startswith('.') else module_name}{f' from package {package}' if package else ''}"
|
||||
message = f"Module named {module_str} cannot be imported. Verify that the module exists and there is no Python syntax issues."
|
||||
anta_log_exception(e, message, logger)
|
||||
raise ValueError(message) from e
|
||||
if isinstance(tests, dict):
|
||||
# This is an inner Python module
|
||||
modules.update(flatten_modules(data=tests, package=module.__name__))
|
||||
else:
|
||||
if not isinstance(tests, list):
|
||||
raise ValueError(f"Syntax error when parsing: {tests}\nIt must be a list of ANTA tests. Check the test catalog.")
|
||||
# This is a list of AntaTestDefinition
|
||||
modules[module] = tests
|
||||
return modules
|
||||
|
||||
if isinstance(data, dict):
|
||||
typed_data: dict[ModuleType, list[Any]] = flatten_modules(data)
|
||||
typed_data: dict[ModuleType, list[Any]] = AntaCatalogFile.flatten_modules(data)
|
||||
for module, tests in typed_data.items():
|
||||
test_definitions: list[AntaTestDefinition] = []
|
||||
for test_definition in tests:
|
||||
if not isinstance(test_definition, dict):
|
||||
raise ValueError(f"Syntax error when parsing: {test_definition}\nIt must be a dictionary. Check the test catalog.")
|
||||
msg = f"Syntax error when parsing: {test_definition}\nIt must be a dictionary. Check the test catalog."
|
||||
raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError
|
||||
if len(test_definition) != 1:
|
||||
raise ValueError(
|
||||
f"Syntax error when parsing: {test_definition}\nIt must be a dictionary with a single entry. Check the indentation in the test catalog."
|
||||
msg = (
|
||||
f"Syntax error when parsing: {test_definition}\n"
|
||||
"It must be a dictionary with a single entry. Check the indentation in the test catalog."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
for test_name, test_inputs in test_definition.copy().items():
|
||||
test: type[AntaTest] | None = getattr(module, test_name, None)
|
||||
if test is None:
|
||||
raise ValueError(
|
||||
f"{test_name} is not defined in Python module {module.__name__}{f' (from {module.__file__})' if module.__file__ is not None else ''}"
|
||||
msg = (
|
||||
f"{test_name} is not defined in Python module {module.__name__}"
|
||||
f"{f' (from {module.__file__})' if module.__file__ is not None else ''}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
test_definitions.append(AntaTestDefinition(test=test, inputs=test_inputs))
|
||||
typed_data[module] = test_definitions
|
||||
return typed_data
|
||||
|
||||
|
||||
class AntaCatalog:
|
||||
"""
|
||||
Class representing an ANTA Catalog.
|
||||
"""Class representing an ANTA Catalog.
|
||||
|
||||
It can be instantiated using its contructor or one of the static methods: `parse()`, `from_list()` or `from_dict()`
|
||||
It can be instantiated using its constructor or one of the static methods: `parse()`, `from_list()` or `from_dict()`
|
||||
"""
|
||||
|
||||
def __init__(self, tests: list[AntaTestDefinition] | None = None, filename: str | Path | None = None) -> None:
|
||||
"""
|
||||
Constructor of AntaCatalog.
|
||||
def __init__(
|
||||
self,
|
||||
tests: list[AntaTestDefinition] | None = None,
|
||||
filename: str | Path | None = None,
|
||||
) -> None:
|
||||
"""Instantiate an AntaCatalog instance.
|
||||
|
||||
Args:
|
||||
----
|
||||
tests: A list of AntaTestDefinition instances.
|
||||
filename: The path from which the catalog is loaded.
|
||||
|
||||
"""
|
||||
self._tests: list[AntaTestDefinition] = []
|
||||
if tests is not None:
|
||||
|
@ -196,34 +234,38 @@ class AntaCatalog:
|
|||
|
||||
@property
|
||||
def filename(self) -> Path | None:
|
||||
"""Path of the file used to create this AntaCatalog instance"""
|
||||
"""Path of the file used to create this AntaCatalog instance."""
|
||||
return self._filename
|
||||
|
||||
@property
|
||||
def tests(self) -> list[AntaTestDefinition]:
|
||||
"""List of AntaTestDefinition in this catalog"""
|
||||
"""List of AntaTestDefinition in this catalog."""
|
||||
return self._tests
|
||||
|
||||
@tests.setter
|
||||
def tests(self, value: list[AntaTestDefinition]) -> None:
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("The catalog must contain a list of tests")
|
||||
msg = "The catalog must contain a list of tests"
|
||||
raise TypeError(msg)
|
||||
for t in value:
|
||||
if not isinstance(t, AntaTestDefinition):
|
||||
raise ValueError("A test in the catalog must be an AntaTestDefinition instance")
|
||||
msg = "A test in the catalog must be an AntaTestDefinition instance"
|
||||
raise TypeError(msg)
|
||||
self._tests = value
|
||||
|
||||
@staticmethod
|
||||
def parse(filename: str | Path) -> AntaCatalog:
|
||||
"""
|
||||
Create an AntaCatalog instance from a test catalog file.
|
||||
"""Create an AntaCatalog instance from a test catalog file.
|
||||
|
||||
Args:
|
||||
----
|
||||
filename: Path to test catalog YAML file
|
||||
|
||||
"""
|
||||
try:
|
||||
with open(file=filename, mode="r", encoding="UTF-8") as file:
|
||||
data = safe_load(file)
|
||||
file: Path = filename if isinstance(filename, Path) else Path(filename)
|
||||
with file.open(encoding="UTF-8") as f:
|
||||
data = safe_load(f)
|
||||
except (TypeError, YAMLError, OSError) as e:
|
||||
message = f"Unable to parse ANTA Test Catalog file '{filename}'"
|
||||
anta_log_exception(e, message, logger)
|
||||
|
@ -233,15 +275,17 @@ class AntaCatalog:
|
|||
|
||||
@staticmethod
|
||||
def from_dict(data: RawCatalogInput, filename: str | Path | None = None) -> AntaCatalog:
|
||||
"""
|
||||
Create an AntaCatalog instance from a dictionary data structure.
|
||||
"""Create an AntaCatalog instance from a dictionary data structure.
|
||||
|
||||
See RawCatalogInput type alias for details.
|
||||
It is the data structure returned by `yaml.load()` function of a valid
|
||||
YAML Test Catalog file.
|
||||
|
||||
Args:
|
||||
----
|
||||
data: Python dictionary used to instantiate the AntaCatalog instance
|
||||
filename: value to be set as AntaCatalog instance attribute
|
||||
|
||||
"""
|
||||
tests: list[AntaTestDefinition] = []
|
||||
if data is None:
|
||||
|
@ -249,12 +293,17 @@ class AntaCatalog:
|
|||
return AntaCatalog(filename=filename)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Wrong input type for catalog data{f' (from {filename})' if filename is not None else ''}, must be a dict, got {type(data).__name__}")
|
||||
msg = f"Wrong input type for catalog data{f' (from {filename})' if filename is not None else ''}, must be a dict, got {type(data).__name__}"
|
||||
raise TypeError(msg)
|
||||
|
||||
try:
|
||||
catalog_data = AntaCatalogFile(**data) # type: ignore[arg-type]
|
||||
except ValidationError as e:
|
||||
anta_log_exception(e, f"Test catalog is invalid!{f' (from {filename})' if filename is not None else ''}", logger)
|
||||
anta_log_exception(
|
||||
e,
|
||||
f"Test catalog is invalid!{f' (from {filename})' if filename is not None else ''}",
|
||||
logger,
|
||||
)
|
||||
raise
|
||||
for t in catalog_data.root.values():
|
||||
tests.extend(t)
|
||||
|
@ -262,12 +311,14 @@ class AntaCatalog:
|
|||
|
||||
@staticmethod
|
||||
def from_list(data: ListAntaTestTuples) -> AntaCatalog:
|
||||
"""
|
||||
Create an AntaCatalog instance from a list data structure.
|
||||
"""Create an AntaCatalog instance from a list data structure.
|
||||
|
||||
See ListAntaTestTuples type alias for details.
|
||||
|
||||
Args:
|
||||
----
|
||||
data: Python list used to instantiate the AntaCatalog instance
|
||||
|
||||
"""
|
||||
tests: list[AntaTestDefinition] = []
|
||||
try:
|
||||
|
@ -277,15 +328,40 @@ class AntaCatalog:
|
|||
raise
|
||||
return AntaCatalog(tests)
|
||||
|
||||
def get_tests_by_tags(self, tags: list[str], strict: bool = False) -> list[AntaTestDefinition]:
|
||||
"""
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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] = []
|
||||
for test in self.tests:
|
||||
if test.inputs.filters and (f := test.inputs.filters.tags):
|
||||
if (strict and all(t in tags for t in f)) or (not strict and any(t in tags for t in f)):
|
||||
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)
|
||||
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.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
ANTA CLI
|
||||
"""
|
||||
"""ANTA CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
@ -16,7 +14,7 @@ import click
|
|||
from anta import GITHUB_SUGGESTION, __version__
|
||||
from anta.cli.check import check as check_command
|
||||
from anta.cli.debug import debug as debug_command
|
||||
from anta.cli.exec import exec as exec_command
|
||||
from anta.cli.exec import _exec as exec_command
|
||||
from anta.cli.get import get as get_command
|
||||
from anta.cli.nrfu import nrfu as nrfu_command
|
||||
from anta.cli.utils import AliasedGroup, ExitCode
|
||||
|
@ -47,7 +45,7 @@ logger = logging.getLogger(__name__)
|
|||
),
|
||||
)
|
||||
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)
|
||||
setup_logging(log_level, log_file)
|
||||
|
||||
|
@ -60,11 +58,15 @@ anta.add_command(debug_command)
|
|||
|
||||
|
||||
def cli() -> None:
|
||||
"""Entrypoint for pyproject.toml"""
|
||||
"""Entrypoint for pyproject.toml."""
|
||||
try:
|
||||
anta(obj={}, auto_envvar_prefix="ANTA")
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
anta_log_exception(e, f"Uncaught Exception when running ANTA CLI\n{GITHUB_SUGGESTION}", logger)
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
anta_log_exception(
|
||||
exc,
|
||||
f"Uncaught Exception when running ANTA CLI\n{GITHUB_SUGGESTION}",
|
||||
logger,
|
||||
)
|
||||
sys.exit(ExitCode.INTERNAL_ERROR)
|
||||
|
||||
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Click commands to validate configuration files
|
||||
"""
|
||||
"""Click commands to validate configuration files."""
|
||||
|
||||
import click
|
||||
|
||||
from anta.cli.check import commands
|
||||
|
@ -11,7 +10,7 @@ from anta.cli.check import commands
|
|||
|
||||
@click.group
|
||||
def check() -> None:
|
||||
"""Commands to validate configuration files"""
|
||||
"""Commands to validate configuration files."""
|
||||
|
||||
|
||||
check.add_command(commands.catalog)
|
||||
|
|
|
@ -2,28 +2,28 @@
|
|||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
# pylint: disable = redefined-outer-name
|
||||
"""
|
||||
Click commands to validate configuration files
|
||||
"""
|
||||
"""Click commands to validate configuration files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
from rich.pretty import pretty_repr
|
||||
|
||||
from anta.catalog import AntaCatalog
|
||||
from anta.cli.console import console
|
||||
from anta.cli.utils import catalog_options
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.catalog import AntaCatalog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@click.command
|
||||
@catalog_options
|
||||
def catalog(catalog: AntaCatalog) -> None:
|
||||
"""
|
||||
Check that the catalog is valid
|
||||
"""
|
||||
"""Check that the catalog is valid."""
|
||||
console.print(f"[bold][green]Catalog is valid: {catalog.filename}")
|
||||
console.print(pretty_repr(catalog.tests))
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
ANTA Top-level Console
|
||||
https://rich.readthedocs.io/en/stable/console.html#console-api
|
||||
"""ANTA Top-level Console.
|
||||
|
||||
https://rich.readthedocs.io/en/stable/console.html#console-api.
|
||||
"""
|
||||
|
||||
from rich.console import Console
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Click commands to execute EOS commands on remote devices
|
||||
"""
|
||||
"""Click commands to execute EOS commands on remote devices."""
|
||||
|
||||
import click
|
||||
|
||||
from anta.cli.debug import commands
|
||||
|
@ -11,7 +10,7 @@ from anta.cli.debug import commands
|
|||
|
||||
@click.group
|
||||
def debug() -> None:
|
||||
"""Commands to execute EOS commands on remote devices"""
|
||||
"""Commands to execute EOS commands on remote devices."""
|
||||
|
||||
|
||||
debug.add_command(commands.run_cmd)
|
||||
|
|
|
@ -2,23 +2,24 @@
|
|||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
# pylint: disable = redefined-outer-name
|
||||
"""
|
||||
Click commands to execute EOS commands on remote devices
|
||||
"""
|
||||
"""Click commands to execute EOS commands on remote devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Literal
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import click
|
||||
|
||||
from anta.cli.console import console
|
||||
from anta.cli.debug.utils import debug_options
|
||||
from anta.cli.utils import ExitCode
|
||||
from anta.device import AntaDevice
|
||||
from anta.models import AntaCommand, AntaTemplate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.device import AntaDevice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -26,8 +27,16 @@ logger = logging.getLogger(__name__)
|
|||
@debug_options
|
||||
@click.pass_context
|
||||
@click.option("--command", "-c", type=str, required=True, help="Command to run")
|
||||
def run_cmd(ctx: click.Context, device: AntaDevice, command: str, ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int) -> None:
|
||||
"""Run arbitrary command to an ANTA device"""
|
||||
def run_cmd(
|
||||
ctx: click.Context,
|
||||
device: AntaDevice,
|
||||
command: str,
|
||||
ofmt: Literal["json", "text"],
|
||||
version: Literal["1", "latest"],
|
||||
revision: int,
|
||||
) -> None:
|
||||
# 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]")
|
||||
# I do not assume the following line, but click make me do it
|
||||
v: Literal[1, "latest"] = version if version == "latest" else 1
|
||||
|
@ -45,18 +54,32 @@ def run_cmd(ctx: click.Context, device: AntaDevice, command: str, ofmt: Literal[
|
|||
@click.command
|
||||
@debug_options
|
||||
@click.pass_context
|
||||
@click.option("--template", "-t", type=str, required=True, help="Command template to run. E.g. 'show vlan {vlan_id}'")
|
||||
@click.option(
|
||||
"--template",
|
||||
"-t",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Command template to run. E.g. 'show vlan {vlan_id}'",
|
||||
)
|
||||
@click.argument("params", required=True, nargs=-1)
|
||||
def run_template(
|
||||
ctx: click.Context, device: AntaDevice, template: str, params: list[str], ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int
|
||||
ctx: click.Context,
|
||||
device: AntaDevice,
|
||||
template: str,
|
||||
params: list[str],
|
||||
ofmt: Literal["json", "text"],
|
||||
version: Literal["1", "latest"],
|
||||
revision: int,
|
||||
) -> None:
|
||||
# pylint: disable=too-many-arguments
|
||||
"""Run arbitrary templated command to an ANTA device.
|
||||
|
||||
Takes a list of arguments (keys followed by a value) to build a dictionary used as template parameters.
|
||||
Example:
|
||||
|
||||
Example:
|
||||
-------
|
||||
anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1
|
||||
|
||||
"""
|
||||
template_params = dict(zip(params[::2], params[1::2]))
|
||||
|
||||
|
@ -64,7 +87,7 @@ def run_template(
|
|||
# I do not assume the following line, but click make me do it
|
||||
v: Literal[1, "latest"] = version if version == "latest" else 1
|
||||
t = AntaTemplate(template=template, ofmt=ofmt, version=v, revision=revision)
|
||||
c = t.render(**template_params) # type: ignore
|
||||
c = t.render(**template_params)
|
||||
asyncio.run(device.collect(c))
|
||||
if not c.collected:
|
||||
console.print(f"[bold red] Command '{c.command}' failed to execute!")
|
||||
|
|
|
@ -1,35 +1,56 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Utils functions to use with anta.cli.debug module.
|
||||
"""
|
||||
"""Utils functions to use with anta.cli.debug module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
import click
|
||||
|
||||
from anta.cli.utils import ExitCode, inventory_options
|
||||
from anta.inventory import AntaInventory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.inventory import AntaInventory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def debug_options(f: Any) -> Any:
|
||||
"""Click common options required to execute a command on a specific device"""
|
||||
def debug_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Click common options required to execute a command on a specific device."""
|
||||
|
||||
@inventory_options
|
||||
@click.option("--ofmt", type=click.Choice(["json", "text"]), default="json", help="EOS eAPI format to use. can be text or json")
|
||||
@click.option("--version", "-v", type=click.Choice(["1", "latest"]), default="latest", help="EOS eAPI version")
|
||||
@click.option(
|
||||
"--ofmt",
|
||||
type=click.Choice(["json", "text"]),
|
||||
default="json",
|
||||
help="EOS eAPI format to use. can be text or json",
|
||||
)
|
||||
@click.option(
|
||||
"--version",
|
||||
"-v",
|
||||
type=click.Choice(["1", "latest"]),
|
||||
default="latest",
|
||||
help="EOS eAPI version",
|
||||
)
|
||||
@click.option("--revision", "-r", type=int, help="eAPI command revision", required=False)
|
||||
@click.option("--device", "-d", type=str, required=True, help="Device from inventory to use")
|
||||
@click.pass_context
|
||||
@functools.wraps(f)
|
||||
def wrapper(ctx: click.Context, *args: tuple[Any], inventory: AntaInventory, tags: list[str] | None, device: str, **kwargs: dict[str, Any]) -> Any:
|
||||
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
|
||||
# ruff: noqa: ARG001
|
||||
try:
|
||||
d = inventory[device]
|
||||
except KeyError as e:
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Click commands to execute various scripts on EOS devices
|
||||
"""
|
||||
"""Click commands to execute various scripts on EOS devices."""
|
||||
|
||||
import click
|
||||
|
||||
from anta.cli.exec import commands
|
||||
|
||||
|
||||
@click.group
|
||||
def exec() -> None: # pylint: disable=redefined-builtin
|
||||
"""Commands to execute various scripts on EOS devices"""
|
||||
@click.group("exec")
|
||||
def _exec() -> None: # pylint: disable=redefined-builtin
|
||||
"""Commands to execute various scripts on EOS devices."""
|
||||
|
||||
|
||||
exec.add_command(commands.clear_counters)
|
||||
exec.add_command(commands.snapshot)
|
||||
exec.add_command(commands.collect_tech_support)
|
||||
_exec.add_command(commands.clear_counters)
|
||||
_exec.add_command(commands.snapshot)
|
||||
_exec.add_command(commands.collect_tech_support)
|
||||
|
|
|
@ -1,31 +1,34 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Click commands to execute various scripts on EOS devices
|
||||
"""
|
||||
"""Click commands to execute various scripts on EOS devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
from yaml import safe_load
|
||||
|
||||
from anta.cli.console import console
|
||||
from anta.cli.exec.utils import clear_counters_utils, collect_commands, collect_scheduled_show_tech
|
||||
from anta.cli.utils import inventory_options
|
||||
from anta.inventory import AntaInventory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.inventory import AntaInventory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@click.command
|
||||
@inventory_options
|
||||
def clear_counters(inventory: AntaInventory, tags: list[str] | None) -> None:
|
||||
"""Clear counter statistics on EOS devices"""
|
||||
def clear_counters(inventory: AntaInventory, tags: set[str] | None) -> None:
|
||||
"""Clear counter statistics on EOS devices."""
|
||||
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,
|
||||
type=click.Path(file_okay=False, dir_okay=True, exists=False, writable=True, path_type=Path),
|
||||
help="Directory to save commands output.",
|
||||
default=f"anta_snapshot_{datetime.now().strftime('%Y-%m-%d_%H_%M_%S')}",
|
||||
default=f"anta_snapshot_{datetime.now(tz=timezone.utc).astimezone().strftime('%Y-%m-%d_%H_%M_%S')}",
|
||||
show_default=True,
|
||||
)
|
||||
def snapshot(inventory: AntaInventory, tags: list[str] | None, commands_list: Path, output: Path) -> None:
|
||||
"""Collect commands output from devices in inventory"""
|
||||
print(f"Collecting data for {commands_list}")
|
||||
print(f"Output directory is {output}")
|
||||
def snapshot(inventory: AntaInventory, tags: set[str] | None, commands_list: Path, output: Path) -> None:
|
||||
"""Collect commands output from devices in inventory."""
|
||||
console.print(f"Collecting data for {commands_list}")
|
||||
console.print(f"Output directory is {output}")
|
||||
try:
|
||||
with open(commands_list, "r", encoding="UTF-8") as file:
|
||||
with commands_list.open(encoding="UTF-8") as file:
|
||||
file_content = file.read()
|
||||
eos_commands = safe_load(file_content)
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Error reading {commands_list}")
|
||||
logger.error("Error reading %s", commands_list)
|
||||
sys.exit(1)
|
||||
asyncio.run(collect_commands(inventory, eos_commands, output, tags=tags))
|
||||
|
||||
|
||||
@click.command()
|
||||
@inventory_options
|
||||
@click.option("--output", "-o", default="./tech-support", show_default=True, help="Path for test catalog", type=click.Path(path_type=Path), required=False)
|
||||
@click.option("--latest", help="Number of scheduled show-tech to retrieve", type=int, required=False)
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
default="./tech-support",
|
||||
show_default=True,
|
||||
help="Path for test catalog",
|
||||
type=click.Path(path_type=Path),
|
||||
required=False,
|
||||
)
|
||||
@click.option(
|
||||
"--latest",
|
||||
help="Number of scheduled show-tech to retrieve",
|
||||
type=int,
|
||||
required=False,
|
||||
)
|
||||
@click.option(
|
||||
"--configure",
|
||||
help="Ensure devices have 'aaa authorization exec default local' configured (required for SCP on EOS). THIS WILL CHANGE THE CONFIGURATION OF YOUR NETWORK.",
|
||||
|
@ -73,6 +89,13 @@ def snapshot(inventory: AntaInventory, tags: list[str] | None, commands_list: Pa
|
|||
is_flag=True,
|
||||
show_default=True,
|
||||
)
|
||||
def collect_tech_support(inventory: AntaInventory, tags: list[str] | None, output: Path, latest: int | None, configure: bool) -> None:
|
||||
"""Collect scheduled tech-support from EOS devices"""
|
||||
asyncio.run(collect_scheduled_show_tech(inventory, output, configure, tags=tags, latest=latest))
|
||||
def collect_tech_support(
|
||||
inventory: AntaInventory,
|
||||
tags: set[str] | None,
|
||||
output: Path,
|
||||
latest: int | None,
|
||||
*,
|
||||
configure: bool,
|
||||
) -> None:
|
||||
"""Collect scheduled tech-support from EOS devices."""
|
||||
asyncio.run(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
|
||||
# that can be found in the LICENSE file.
|
||||
|
||||
"""
|
||||
Exec CLI helpers
|
||||
"""
|
||||
"""Exec CLI helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
@ -13,24 +12,25 @@ import json
|
|||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from aioeapi import EapiCommandError
|
||||
from click.exceptions import UsageError
|
||||
from httpx import ConnectError, HTTPError
|
||||
|
||||
from anta.device import AntaDevice, AsyncEOSDevice
|
||||
from anta.inventory import AntaInventory
|
||||
from anta.models import AntaCommand
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.inventory import AntaInventory
|
||||
|
||||
EOS_SCHEDULED_TECH_SUPPORT = "/mnt/flash/schedule/tech-support"
|
||||
INVALID_CHAR = "`~!@#$/"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def clear_counters_utils(anta_inventory: AntaInventory, tags: list[str] | None = None) -> None:
|
||||
"""
|
||||
Clear counters
|
||||
"""
|
||||
async def clear_counters_utils(anta_inventory: AntaInventory, tags: set[str] | None = None) -> None:
|
||||
"""Clear counters."""
|
||||
|
||||
async def clear(dev: AntaDevice) -> None:
|
||||
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)
|
||||
for command in commands:
|
||||
if not command.collected:
|
||||
logger.error(f"Could not clear counters on device {dev.name}: {command.errors}")
|
||||
logger.info(f"Cleared counters on {dev.name} ({dev.hw_model})")
|
||||
logger.error("Could not clear counters on device %s: %s", dev.name, command.errors)
|
||||
logger.info("Cleared counters on %s (%s)", dev.name, dev.hw_model)
|
||||
|
||||
logger.info("Connecting to devices...")
|
||||
await anta_inventory.connect_inventory()
|
||||
devices = anta_inventory.get_inventory(established_only=True, tags=tags).values()
|
||||
devices = anta_inventory.get_inventory(established_only=True, tags=tags).devices
|
||||
logger.info("Clearing counters on remote devices...")
|
||||
await asyncio.gather(*(clear(device) for device in devices))
|
||||
|
||||
|
@ -53,11 +53,9 @@ async def collect_commands(
|
|||
inv: AntaInventory,
|
||||
commands: dict[str, str],
|
||||
root_dir: Path,
|
||||
tags: list[str] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Collect EOS commands
|
||||
"""
|
||||
"""Collect EOS commands."""
|
||||
|
||||
async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "text"]) -> None:
|
||||
outdir = Path() / root_dir / dev.name / outformat
|
||||
|
@ -66,7 +64,7 @@ async def collect_commands(
|
|||
c = AntaCommand(command=command, ofmt=outformat)
|
||||
await dev.collect(c)
|
||||
if not c.collected:
|
||||
logger.error(f"Could not collect commands on device {dev.name}: {c.errors}")
|
||||
logger.error("Could not collect commands on device %s: %s", dev.name, c.errors)
|
||||
return
|
||||
if c.ofmt == "json":
|
||||
outfile = outdir / f"{safe_command}.json"
|
||||
|
@ -76,11 +74,11 @@ async def collect_commands(
|
|||
content = c.text_output
|
||||
with outfile.open(mode="w", encoding="UTF-8") as f:
|
||||
f.write(content)
|
||||
logger.info(f"Collected command '{command}' from device {dev.name} ({dev.hw_model})")
|
||||
logger.info("Collected command '%s' from device %s (%s)", command, dev.name, dev.hw_model)
|
||||
|
||||
logger.info("Connecting to devices...")
|
||||
await inv.connect_inventory()
|
||||
devices = inv.get_inventory(established_only=True, tags=tags).values()
|
||||
devices = inv.get_inventory(established_only=True, tags=tags).devices
|
||||
logger.info("Collecting commands from remote devices")
|
||||
coros = []
|
||||
if "json_format" in commands:
|
||||
|
@ -90,18 +88,14 @@ async def collect_commands(
|
|||
res = await asyncio.gather(*coros, return_exceptions=True)
|
||||
for r in res:
|
||||
if isinstance(r, Exception):
|
||||
logger.error(f"Error when collecting commands: {str(r)}")
|
||||
logger.error("Error when collecting commands: %s", str(r))
|
||||
|
||||
|
||||
async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, configure: bool, tags: list[str] | None = None, latest: int | None = None) -> None:
|
||||
"""
|
||||
Collect scheduled show-tech on devices
|
||||
"""
|
||||
async def collect_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."""
|
||||
|
||||
async def collect(device: AntaDevice) -> None:
|
||||
"""
|
||||
Collect all the tech-support files stored on Arista switches flash and copy them locally
|
||||
"""
|
||||
"""Collect all the tech-support files stored on Arista switches flash and copy them locally."""
|
||||
try:
|
||||
# Get the tech-support filename to retrieve
|
||||
cmd = f"bash timeout 10 ls -1t {EOS_SCHEDULED_TECH_SUPPORT}"
|
||||
|
@ -110,9 +104,9 @@ async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, config
|
|||
command = AntaCommand(command=cmd, ofmt="text")
|
||||
await device.collect(command=command)
|
||||
if command.collected and command.text_output:
|
||||
filenames = list(map(lambda f: Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}"), command.text_output.splitlines()))
|
||||
filenames = [Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}") for f in command.text_output.splitlines()]
|
||||
else:
|
||||
logger.error(f"Unable to get tech-support filenames on {device.name}: verify that {EOS_SCHEDULED_TECH_SUPPORT} is not empty")
|
||||
logger.error("Unable to get tech-support filenames on %s: verify that %s is not empty", device.name, EOS_SCHEDULED_TECH_SUPPORT)
|
||||
return
|
||||
|
||||
# Create directories
|
||||
|
@ -124,12 +118,15 @@ async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, config
|
|||
await device.collect(command=command)
|
||||
|
||||
if command.collected and not command.text_output:
|
||||
logger.debug(f"'aaa authorization exec default local' is not configured on device {device.name}")
|
||||
logger.debug("'aaa authorization exec default local' is not configured on device %s", device.name)
|
||||
if configure:
|
||||
# Otherwise mypy complains about enable
|
||||
assert isinstance(device, AsyncEOSDevice)
|
||||
# TODO - @mtache - add `config` field to `AntaCommand` object to handle this use case.
|
||||
commands = []
|
||||
# TODO: @mtache - add `config` field to `AntaCommand` object to handle this use case.
|
||||
# Otherwise mypy complains about enable as it is only implemented for AsyncEOSDevice
|
||||
# TODO: Should enable be also included in AntaDevice?
|
||||
if not isinstance(device, AsyncEOSDevice):
|
||||
msg = "anta exec collect-tech-support is only supported with AsyncEOSDevice for now."
|
||||
raise UsageError(msg)
|
||||
if device.enable and device._enable_password is not None: # pylint: disable=protected-access
|
||||
commands.append({"cmd": "enable", "input": device._enable_password}) # pylint: disable=protected-access
|
||||
elif device.enable:
|
||||
|
@ -138,24 +135,24 @@ async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, config
|
|||
[
|
||||
{"cmd": "configure terminal"},
|
||||
{"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")
|
||||
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:
|
||||
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
|
||||
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")
|
||||
logger.info(f"Collected {len(filenames)} scheduled tech-support from {device.name}")
|
||||
logger.info("Collected %s scheduled tech-support from %s", len(filenames), device.name)
|
||||
|
||||
except (EapiCommandError, HTTPError, ConnectError) as e:
|
||||
logger.error(f"Unable to collect tech-support on {device.name}: {str(e)}")
|
||||
logger.error("Unable to collect tech-support on %s: %s", device.name, str(e))
|
||||
|
||||
logger.info("Connecting to devices...")
|
||||
await inv.connect_inventory()
|
||||
devices = inv.get_inventory(established_only=True, tags=tags).values()
|
||||
devices = inv.get_inventory(established_only=True, tags=tags).devices
|
||||
await asyncio.gather(*(collect(device) for device in devices))
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Click commands to get information from or generate inventories
|
||||
"""
|
||||
"""Click commands to get information from or generate inventories."""
|
||||
|
||||
import click
|
||||
|
||||
from anta.cli.get import commands
|
||||
|
@ -11,7 +10,7 @@ from anta.cli.get import commands
|
|||
|
||||
@click.group
|
||||
def get() -> None:
|
||||
"""Commands to get information from or generate inventories"""
|
||||
"""Commands to get information from or generate inventories."""
|
||||
|
||||
|
||||
get.add_command(commands.from_cvp)
|
||||
|
|
|
@ -2,15 +2,15 @@
|
|||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
# pylint: disable = redefined-outer-name
|
||||
"""
|
||||
Click commands to get information from or generate inventories
|
||||
"""
|
||||
"""Click commands to get information from or generate inventories."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import click
|
||||
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.get.utils import inventory_output_options
|
||||
from anta.cli.utils import ExitCode, inventory_options
|
||||
from anta.inventory import AntaInventory
|
||||
|
||||
from .utils import create_inventory_from_ansible, create_inventory_from_cvp, get_cv_token
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.inventory import AntaInventory
|
||||
|
||||
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("--container", "-c", help="CloudVision container where devices are configured", type=str)
|
||||
def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str | None) -> None:
|
||||
"""
|
||||
Build ANTA inventory from Cloudvision
|
||||
# pylint: disable=too-many-arguments
|
||||
"""Build ANTA inventory from Cloudvision.
|
||||
|
||||
TODO - handle get_inventory and get_devices_in_container failure
|
||||
"""
|
||||
logger.info(f"Getting authentication token for user '{username}' from CloudVision instance '{host}'")
|
||||
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)
|
||||
|
||||
clnt = CvpClient()
|
||||
try:
|
||||
clnt.connect(nodes=[host], username="", password="", api_token=token)
|
||||
except CvpApiError as error:
|
||||
logger.error(f"Error connecting to CloudVision: {error}")
|
||||
logger.error("Error connecting to CloudVision: %s", error)
|
||||
ctx.exit(ExitCode.USAGE_ERROR)
|
||||
logger.info(f"Connected to CloudVision instance '{host}'")
|
||||
logger.info("Connected to CloudVision instance '%s'", host)
|
||||
|
||||
cvp_inventory = None
|
||||
if container is None:
|
||||
# Get a list of all devices
|
||||
logger.info(f"Getting full inventory from CloudVision instance '{host}'")
|
||||
logger.info("Getting full inventory from CloudVision instance '%s'", host)
|
||||
cvp_inventory = clnt.api.get_inventory()
|
||||
else:
|
||||
# Get devices under a container
|
||||
logger.info(f"Getting inventory for container {container} from CloudVision instance '{host}'")
|
||||
logger.info("Getting inventory for container %s from CloudVision instance '%s'", container, host)
|
||||
cvp_inventory = clnt.api.get_devices_in_container(container)
|
||||
create_inventory_from_cvp(cvp_inventory, output)
|
||||
|
||||
|
@ -74,8 +76,8 @@ def from_cvp(ctx: click.Context, output: Path, host: str, username: str, passwor
|
|||
required=True,
|
||||
)
|
||||
def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_inventory: Path) -> None:
|
||||
"""Build ANTA inventory from an ansible inventory YAML file"""
|
||||
logger.info(f"Building inventory from ansible file '{ansible_inventory}'")
|
||||
"""Build ANTA inventory from an ansible inventory YAML file."""
|
||||
logger.info("Building inventory from ansible file '%s'", ansible_inventory)
|
||||
try:
|
||||
create_inventory_from_ansible(
|
||||
inventory=ansible_inventory,
|
||||
|
@ -90,10 +92,11 @@ def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_i
|
|||
@click.command
|
||||
@inventory_options
|
||||
@click.option("--connected/--not-connected", help="Display inventory after connection has been created", default=False, required=False)
|
||||
def inventory(inventory: AntaInventory, tags: list[str] | None, connected: bool) -> None:
|
||||
def inventory(inventory: AntaInventory, tags: set[str] | None, *, connected: bool) -> None:
|
||||
"""Show inventory loaded in ANTA."""
|
||||
|
||||
logger.debug(f"Requesting devices for tags: {tags}")
|
||||
# TODO: @gmuloc - tags come from context - we cannot have everything..
|
||||
# ruff: noqa: ARG001
|
||||
logger.debug("Requesting devices for tags: %s", tags)
|
||||
console.print("Current inventory content is:", style="white on blue")
|
||||
|
||||
if connected:
|
||||
|
@ -105,11 +108,11 @@ def inventory(inventory: AntaInventory, tags: list[str] | None, connected: bool)
|
|||
|
||||
@click.command
|
||||
@inventory_options
|
||||
def tags(inventory: AntaInventory, tags: list[str] | None) -> None: # pylint: disable=unused-argument
|
||||
def tags(inventory: AntaInventory, **kwargs: Any) -> None:
|
||||
# pylint: disable=unused-argument
|
||||
"""Get list of configured tags in user inventory."""
|
||||
tags_found = []
|
||||
tags: set[str] = set()
|
||||
for device in inventory.values():
|
||||
tags_found += device.tags
|
||||
tags_found = sorted(set(tags_found))
|
||||
tags.update(device.tags)
|
||||
console.print("Tags found:")
|
||||
console.print_json(json.dumps(tags_found, indent=2))
|
||||
console.print_json(json.dumps(sorted(tags), indent=2))
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Utils functions to use with anta.cli.get.commands module.
|
||||
"""
|
||||
"""Utils functions to use with anta.cli.get.commands module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
|
@ -11,7 +10,7 @@ import json
|
|||
import logging
|
||||
from pathlib import Path
|
||||
from sys import stdin
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
@ -27,8 +26,8 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def inventory_output_options(f: Any) -> Any:
|
||||
"""Click common options required when an inventory is being generated"""
|
||||
def inventory_output_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Click common options required when an inventory is being generated."""
|
||||
|
||||
@click.option(
|
||||
"--output",
|
||||
|
@ -50,7 +49,13 @@ def inventory_output_options(f: Any) -> Any:
|
|||
)
|
||||
@click.pass_context
|
||||
@functools.wraps(f)
|
||||
def wrapper(ctx: click.Context, *args: tuple[Any], output: Path, overwrite: bool, **kwargs: dict[str, Any]) -> Any:
|
||||
def wrapper(
|
||||
ctx: click.Context,
|
||||
*args: tuple[Any],
|
||||
output: Path,
|
||||
overwrite: bool,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> Any:
|
||||
# Boolean to check if the file is empty
|
||||
output_is_not_empty = output.exists() and output.stat().st_size != 0
|
||||
# Check overwrite when file is not empty
|
||||
|
@ -58,7 +63,10 @@ def inventory_output_options(f: Any) -> Any:
|
|||
is_tty = stdin.isatty()
|
||||
if is_tty:
|
||||
# File has content and it is in an interactive TTY --> Prompt user
|
||||
click.confirm(f"Your destination file '{output}' is not empty, continue?", abort=True)
|
||||
click.confirm(
|
||||
f"Your destination file '{output}' is not empty, continue?",
|
||||
abort=True,
|
||||
)
|
||||
else:
|
||||
# File has content and it is not interactive TTY nor overwrite set to True --> execution stop
|
||||
logger.critical("Conversion aborted since destination file is not empty (not running in interactive TTY)")
|
||||
|
@ -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:
|
||||
"""Generate AUTH token from CVP using password"""
|
||||
# TODO, need to handle requests eror
|
||||
"""Generate AUTH token from CVP using password."""
|
||||
# TODO: need to handle requests error
|
||||
|
||||
# use CVP REST API to generate a token
|
||||
URL = f"https://{cvp_ip}/cvpservice/login/authenticate.do"
|
||||
url = f"https://{cvp_ip}/cvpservice/login/authenticate.do"
|
||||
payload = json.dumps({"userId": cvp_username, "password": cvp_password})
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
|
||||
response = requests.request("POST", URL, headers=headers, data=payload, verify=False, timeout=10)
|
||||
response = requests.request("POST", url, headers=headers, data=payload, verify=False, timeout=10)
|
||||
return response.json()["sessionId"]
|
||||
|
||||
|
||||
def write_inventory_to_file(hosts: list[AntaInventoryHost], output: Path) -> None:
|
||||
"""Write a file inventory from pydantic models"""
|
||||
"""Write a file inventory from pydantic models."""
|
||||
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)}))
|
||||
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:
|
||||
"""
|
||||
Create an inventory file from Arista CloudVision inventory
|
||||
"""
|
||||
logger.debug(f"Received {len(inv)} device(s) from CloudVision")
|
||||
"""Create an inventory file from Arista CloudVision inventory."""
|
||||
logger.debug("Received %s device(s) from CloudVision", len(inv))
|
||||
hosts = []
|
||||
for dev in inv:
|
||||
logger.info(f" * adding entry for {dev['hostname']}")
|
||||
hosts.append(AntaInventoryHost(name=dev["hostname"], host=dev["ipAddress"], tags=[dev["containerName"].lower()]))
|
||||
logger.info(" * adding entry for %s", dev["hostname"])
|
||||
hosts.append(
|
||||
AntaInventoryHost(
|
||||
name=dev["hostname"],
|
||||
host=dev["ipAddress"],
|
||||
tags={dev["containerName"].lower()},
|
||||
)
|
||||
)
|
||||
write_inventory_to_file(hosts, output)
|
||||
|
||||
|
||||
def find_ansible_group(data: dict[str, Any], group: str) -> dict[str, Any] | None:
|
||||
"""Retrieve Ansible group from an input data dict."""
|
||||
for k, v in data.items():
|
||||
if isinstance(v, dict):
|
||||
if k == group and ("children" in v or "hosts" in v):
|
||||
return v
|
||||
d = find_ansible_group(v, group)
|
||||
if d is not None:
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def deep_yaml_parsing(data: dict[str, Any], hosts: list[AntaInventoryHost] | None = None) -> list[AntaInventoryHost]:
|
||||
"""Deep parsing of YAML file to extract hosts and associated IPs."""
|
||||
if hosts is None:
|
||||
hosts = []
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict) and "ansible_host" in value:
|
||||
logger.info(" * adding entry for %s", key)
|
||||
hosts.append(AntaInventoryHost(name=key, host=value["ansible_host"]))
|
||||
elif isinstance(value, dict):
|
||||
deep_yaml_parsing(value, hosts)
|
||||
else:
|
||||
return hosts
|
||||
return hosts
|
||||
|
||||
|
||||
def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group: str = "all") -> None:
|
||||
"""
|
||||
Create an ANTA inventory from an Ansible inventory YAML file
|
||||
"""Create an ANTA inventory from an Ansible inventory YAML file.
|
||||
|
||||
Args:
|
||||
----
|
||||
inventory: Ansible Inventory file to read
|
||||
output: ANTA inventory file to generate.
|
||||
ansible_group: Ansible group from where to extract data.
|
||||
|
||||
"""
|
||||
|
||||
def find_ansible_group(data: dict[str, Any], group: str) -> dict[str, Any] | None:
|
||||
for k, v in data.items():
|
||||
if isinstance(v, dict):
|
||||
if k == group and ("children" in v.keys() or "hosts" in v.keys()):
|
||||
return v
|
||||
d = find_ansible_group(v, group)
|
||||
if d is not None:
|
||||
return d
|
||||
return None
|
||||
|
||||
def deep_yaml_parsing(data: dict[str, Any], hosts: list[AntaInventoryHost] | None = None) -> list[AntaInventoryHost]:
|
||||
"""Deep parsing of YAML file to extract hosts and associated IPs"""
|
||||
if hosts is None:
|
||||
hosts = []
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict) and "ansible_host" in value.keys():
|
||||
logger.info(f" * adding entry for {key}")
|
||||
hosts.append(AntaInventoryHost(name=key, host=value["ansible_host"]))
|
||||
elif isinstance(value, dict):
|
||||
deep_yaml_parsing(value, hosts)
|
||||
else:
|
||||
return hosts
|
||||
return hosts
|
||||
|
||||
try:
|
||||
with open(inventory, encoding="utf-8") as inv:
|
||||
with inventory.open(encoding="utf-8") as inv:
|
||||
ansible_inventory = yaml.safe_load(inv)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"Could not parse {inventory}.") from exc
|
||||
msg = f"Could not parse {inventory}."
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
if not ansible_inventory:
|
||||
raise ValueError(f"Ansible inventory {inventory} is empty")
|
||||
msg = f"Ansible inventory {inventory} is empty"
|
||||
raise ValueError(msg)
|
||||
|
||||
ansible_inventory = find_ansible_group(ansible_inventory, ansible_group)
|
||||
|
||||
if ansible_inventory is None:
|
||||
raise ValueError(f"Group {ansible_group} not found in Ansible inventory")
|
||||
msg = f"Group {ansible_group} not found in Ansible inventory"
|
||||
raise ValueError(msg)
|
||||
ansible_hosts = deep_yaml_parsing(ansible_inventory)
|
||||
write_inventory_to_file(ansible_hosts, output)
|
||||
|
|
|
@ -1,38 +1,40 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Click commands that run ANTA tests using anta.runner
|
||||
"""
|
||||
"""Click commands that run ANTA tests using anta.runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING, get_args
|
||||
|
||||
import click
|
||||
|
||||
from anta.catalog import AntaCatalog
|
||||
from anta.cli.nrfu import commands
|
||||
from anta.cli.utils import AliasedGroup, catalog_options, inventory_options
|
||||
from anta.inventory import AntaInventory
|
||||
from anta.custom_types import TestStatus
|
||||
from anta.models import AntaTest
|
||||
from anta.result_manager import ResultManager
|
||||
from anta.runner import main
|
||||
|
||||
from .utils import anta_progress_bar, print_settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.catalog import AntaCatalog
|
||||
from anta.inventory import AntaInventory
|
||||
|
||||
|
||||
class IgnoreRequiredWithHelp(AliasedGroup):
|
||||
"""
|
||||
"""Custom Click Group.
|
||||
|
||||
https://stackoverflow.com/questions/55818737/python-click-application-required-parameters-have-precedence-over-sub-command-he
|
||||
|
||||
Solution to allow help without required options on subcommand
|
||||
This is not planned to be fixed in click as per: https://github.com/pallets/click/issues/295#issuecomment-708129734
|
||||
This is not planned to be fixed in click as per: https://github.com/pallets/click/issues/295#issuecomment-708129734.
|
||||
"""
|
||||
|
||||
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
||||
"""
|
||||
Ignore MissingParameter exception when parsing arguments if `--help`
|
||||
is present for a subcommand
|
||||
"""
|
||||
"""Ignore MissingParameter exception when parsing arguments if `--help` is present for a subcommand."""
|
||||
# Adding a flag for potential callbacks
|
||||
ctx.ensure_object(dict)
|
||||
if "--help" in args:
|
||||
|
@ -51,14 +53,66 @@ class IgnoreRequiredWithHelp(AliasedGroup):
|
|||
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.pass_context
|
||||
@inventory_options
|
||||
@catalog_options
|
||||
@click.option("--ignore-status", help="Always exit with success", show_envvar=True, is_flag=True, default=False)
|
||||
@click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False)
|
||||
def nrfu(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, catalog: AntaCatalog, ignore_status: bool, ignore_error: bool) -> None:
|
||||
"""Run ANTA tests on devices"""
|
||||
@click.option(
|
||||
"--device",
|
||||
"-d",
|
||||
help="Run tests on a specific device. Can be provided multiple times.",
|
||||
type=str,
|
||||
multiple=True,
|
||||
required=False,
|
||||
)
|
||||
@click.option(
|
||||
"--test",
|
||||
"-t",
|
||||
help="Run a specific test. Can be provided multiple times.",
|
||||
type=str,
|
||||
multiple=True,
|
||||
required=False,
|
||||
)
|
||||
@click.option(
|
||||
"--ignore-status",
|
||||
help="Exit code will always be 0.",
|
||||
show_envvar=True,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
)
|
||||
@click.option(
|
||||
"--ignore-error",
|
||||
help="Exit code will be 0 if all tests succeeded or 1 if any test failed.",
|
||||
show_envvar=True,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
)
|
||||
@click.option(
|
||||
"--hide",
|
||||
default=None,
|
||||
type=click.Choice(HIDE_STATUS, case_sensitive=False),
|
||||
multiple=True,
|
||||
help="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 ctx.obj.get("_anta_help"):
|
||||
return
|
||||
|
@ -67,9 +121,10 @@ def nrfu(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, c
|
|||
ctx.obj["result_manager"] = ResultManager()
|
||||
ctx.obj["ignore_status"] = ignore_status
|
||||
ctx.obj["ignore_error"] = ignore_error
|
||||
ctx.obj["hide"] = set(hide) if hide else None
|
||||
print_settings(inventory, catalog)
|
||||
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
|
||||
if ctx.invoked_subcommand is None:
|
||||
ctx.invoke(commands.table)
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Click commands that render ANTA tests results
|
||||
"""
|
||||
"""Click commands that render ANTA tests results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
from typing import Literal
|
||||
|
||||
import click
|
||||
|
||||
|
@ -20,14 +20,19 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
@click.command()
|
||||
@click.pass_context
|
||||
@click.option("--device", "-d", help="Show a summary for this device", type=str, required=False)
|
||||
@click.option("--test", "-t", help="Show a summary for this test", type=str, required=False)
|
||||
@click.option(
|
||||
"--group-by", default=None, type=click.Choice(["device", "test"], case_sensitive=False), help="Group result by test or host. default none", required=False
|
||||
"--group-by",
|
||||
default=None,
|
||||
type=click.Choice(["device", "test"], case_sensitive=False),
|
||||
help="Group result by test or device.",
|
||||
required=False,
|
||||
)
|
||||
def table(ctx: click.Context, device: str | None, test: str | None, group_by: str) -> None:
|
||||
"""ANTA command to check network states with table result"""
|
||||
print_table(results=ctx.obj["result_manager"], device=device, group_by=group_by, test=test)
|
||||
def table(
|
||||
ctx: click.Context,
|
||||
group_by: Literal["device", "test"] | None,
|
||||
) -> None:
|
||||
"""ANTA command to check network states with table result."""
|
||||
print_table(ctx, group_by=group_by)
|
||||
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",
|
||||
)
|
||||
def json(ctx: click.Context, output: pathlib.Path | None) -> None:
|
||||
"""ANTA command to check network state with JSON result"""
|
||||
print_json(results=ctx.obj["result_manager"], output=output)
|
||||
"""ANTA command to check network state with JSON result."""
|
||||
print_json(ctx, output=output)
|
||||
exit_with_code(ctx)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.pass_context
|
||||
@click.option("--search", "-s", help="Regular expression to search in both name and test", type=str, required=False)
|
||||
@click.option("--skip-error", help="Hide tests in errors due to connectivity issue", default=False, is_flag=True, show_default=True, required=False)
|
||||
def text(ctx: click.Context, search: str | None, skip_error: bool) -> None:
|
||||
"""ANTA command to check network states with text result"""
|
||||
print_text(results=ctx.obj["result_manager"], search=search, skip_error=skip_error)
|
||||
def text(ctx: click.Context) -> None:
|
||||
"""ANTA command to check network states with text result."""
|
||||
print_text(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",
|
||||
)
|
||||
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)
|
||||
exit_with_code(ctx)
|
||||
|
|
|
@ -1,101 +1,96 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Utils functions to use with anta.cli.nrfu.commands module.
|
||||
"""
|
||||
"""Utils functions to use with anta.cli.nrfu.commands module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import pathlib
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import rich
|
||||
from rich.panel import Panel
|
||||
from rich.pretty import pprint
|
||||
from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn
|
||||
|
||||
from anta.catalog import AntaCatalog
|
||||
from anta.cli.console import console
|
||||
from anta.inventory import AntaInventory
|
||||
from anta.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__)
|
||||
|
||||
|
||||
def _get_result_manager(ctx: click.Context) -> ResultManager:
|
||||
"""Get a ResultManager instance based on Click context."""
|
||||
return ctx.obj["result_manager"].filter(ctx.obj.get("hide")) if ctx.obj.get("hide") is not None else ctx.obj["result_manager"]
|
||||
|
||||
|
||||
def print_settings(
|
||||
inventory: AntaInventory,
|
||||
catalog: AntaCatalog,
|
||||
) -> None:
|
||||
"""Print ANTA settings before running tests"""
|
||||
"""Print ANTA settings before running tests."""
|
||||
message = f"Running ANTA tests:\n- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests"
|
||||
console.print(Panel.fit(message, style="cyan", title="[green]Settings"))
|
||||
console.print()
|
||||
|
||||
|
||||
def print_table(results: ResultManager, device: str | None = None, test: str | None = None, group_by: str | None = None) -> None:
|
||||
"""Print result in a table"""
|
||||
def print_table(ctx: click.Context, group_by: Literal["device", "test"] | None = None) -> None:
|
||||
"""Print result in a table."""
|
||||
reporter = ReportTable()
|
||||
console.print()
|
||||
if device:
|
||||
console.print(reporter.report_all(result_manager=results, host=device))
|
||||
elif test:
|
||||
console.print(reporter.report_all(result_manager=results, testcase=test))
|
||||
elif group_by == "device":
|
||||
console.print(reporter.report_summary_hosts(result_manager=results, host=None))
|
||||
results = _get_result_manager(ctx)
|
||||
|
||||
if group_by == "device":
|
||||
console.print(reporter.report_summary_devices(results))
|
||||
elif group_by == "test":
|
||||
console.print(reporter.report_summary_tests(result_manager=results, testcase=None))
|
||||
console.print(reporter.report_summary_tests(results))
|
||||
else:
|
||||
console.print(reporter.report_all(result_manager=results))
|
||||
console.print(reporter.report_all(results))
|
||||
|
||||
|
||||
def print_json(results: ResultManager, output: pathlib.Path | None = None) -> None:
|
||||
"""Print result in a json format"""
|
||||
def print_json(ctx: click.Context, output: pathlib.Path | None = None) -> None:
|
||||
"""Print result in a json format."""
|
||||
results = _get_result_manager(ctx)
|
||||
console.print()
|
||||
console.print(Panel("JSON results of all tests", style="cyan"))
|
||||
rich.print_json(results.get_json_results())
|
||||
console.print(Panel("JSON results", style="cyan"))
|
||||
rich.print_json(results.json)
|
||||
if output is not None:
|
||||
with open(output, "w", encoding="utf-8") as fout:
|
||||
fout.write(results.get_json_results())
|
||||
with output.open(mode="w", encoding="utf-8") as fout:
|
||||
fout.write(results.json)
|
||||
|
||||
|
||||
def print_list(results: ResultManager, output: pathlib.Path | None = None) -> None:
|
||||
"""Print result in a list"""
|
||||
def print_text(ctx: click.Context) -> None:
|
||||
"""Print results as simple text."""
|
||||
console.print()
|
||||
console.print(Panel.fit("List results of all tests", style="cyan"))
|
||||
pprint(results.get_results())
|
||||
if output is not None:
|
||||
with open(output, "w", encoding="utf-8") as fout:
|
||||
fout.write(str(results.get_results()))
|
||||
|
||||
|
||||
def print_text(results: ResultManager, search: str | None = None, skip_error: bool = False) -> None:
|
||||
"""Print results as simple text"""
|
||||
console.print()
|
||||
regexp = re.compile(search or ".*")
|
||||
for line in results.get_results():
|
||||
if any(regexp.match(entry) for entry in [line.name, line.test]) and (not skip_error or line.result != "error"):
|
||||
message = f" ({str(line.messages[0])})" if len(line.messages) > 0 else ""
|
||||
console.print(f"{line.name} :: {line.test} :: [{line.result}]{line.result.upper()}[/{line.result}]{message}", highlight=False)
|
||||
for test in _get_result_manager(ctx).results:
|
||||
message = f" ({test.messages[0]!s})" if len(test.messages) > 0 else ""
|
||||
console.print(f"{test.name} :: {test.test} :: [{test.result}]{test.result.upper()}[/{test.result}]{message}", highlight=False)
|
||||
|
||||
|
||||
def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib.Path | None = None) -> None:
|
||||
"""Print result based on template."""
|
||||
console.print()
|
||||
reporter = ReportJinja(template_path=template)
|
||||
json_data = json.loads(results.get_json_results())
|
||||
json_data = json.loads(results.json)
|
||||
report = reporter.render(json_data)
|
||||
console.print(report)
|
||||
if output is not None:
|
||||
with open(output, "w", encoding="utf-8") as file:
|
||||
with output.open(mode="w", encoding="utf-8") as file:
|
||||
file.write(report)
|
||||
|
||||
|
||||
# Adding our own ANTA spinner - overriding rich SPINNERS for our own
|
||||
# so ignore warning for redefinition
|
||||
rich.spinner.SPINNERS = { # type: ignore[attr-defined] # noqa: F811
|
||||
rich.spinner.SPINNERS = { # type: ignore[attr-defined]
|
||||
"anta": {
|
||||
"interval": 150,
|
||||
"frames": [
|
||||
|
@ -112,14 +107,12 @@ rich.spinner.SPINNERS = { # type: ignore[attr-defined] # noqa: F811
|
|||
"( 🐌 )",
|
||||
"( 🐌)",
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def anta_progress_bar() -> Progress:
|
||||
"""
|
||||
Return a customized Progress for progress bar
|
||||
"""
|
||||
"""Return a customized Progress for progress bar."""
|
||||
return Progress(
|
||||
SpinnerColumn("anta"),
|
||||
TextColumn("•"),
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Utils functions to use with anta.cli module.
|
||||
"""
|
||||
"""Utils functions to use with anta.cli module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import functools
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
import click
|
||||
from pydantic import ValidationError
|
||||
|
@ -18,7 +17,7 @@ from yaml import YAMLError
|
|||
|
||||
from anta.catalog import AntaCatalog
|
||||
from anta.inventory import AntaInventory
|
||||
from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError
|
||||
from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from click import Option
|
||||
|
@ -27,10 +26,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
class ExitCode(enum.IntEnum):
|
||||
"""
|
||||
Encodes the valid exit codes by anta
|
||||
inspired from pytest
|
||||
"""
|
||||
"""Encodes the valid exit codes by anta inspired from pytest."""
|
||||
|
||||
# Tests passed.
|
||||
OK = 0
|
||||
|
@ -44,19 +40,18 @@ class ExitCode(enum.IntEnum):
|
|||
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
|
||||
"""
|
||||
Click option callback to parse an ANTA inventory tags
|
||||
"""
|
||||
# ruff: noqa: ARG001
|
||||
"""Click option callback to parse an ANTA inventory tags."""
|
||||
if value is not None:
|
||||
return value.split(",") if "," in value else [value]
|
||||
return set(value.split(",")) if "," in value else {value}
|
||||
return None
|
||||
|
||||
|
||||
def exit_with_code(ctx: click.Context) -> None:
|
||||
"""
|
||||
Exit the Click application with an exit code.
|
||||
"""Exit the Click application with an exit code.
|
||||
|
||||
This function determines the global test status to be either `unset`, `skipped`, `success` or `error`
|
||||
from the `ResultManger` instance.
|
||||
If flag `ignore_error` is set, the `error` status will be ignored in all the tests.
|
||||
|
@ -64,10 +59,12 @@ def exit_with_code(ctx: click.Context) -> None:
|
|||
Exit the application with the following exit code:
|
||||
* 0 if `ignore_status` is `True` or global test status is `unset`, `skipped` or `success`
|
||||
* 1 if status is `failure`
|
||||
* 2 if status is `error`
|
||||
* 2 if status is `error`.
|
||||
|
||||
Args:
|
||||
----
|
||||
ctx: Click Context
|
||||
|
||||
"""
|
||||
if ctx.obj.get("ignore_status"):
|
||||
ctx.exit(ExitCode.OK)
|
||||
|
@ -83,18 +80,19 @@ def exit_with_code(ctx: click.Context) -> None:
|
|||
ctx.exit(ExitCode.TESTS_ERROR)
|
||||
|
||||
logger.error("Please gather logs and open an issue on Github.")
|
||||
raise ValueError(f"Unknown status returned by the ResultManager: {status}. Please gather logs and open an issue on Github.")
|
||||
msg = f"Unknown status returned by the ResultManager: {status}. Please gather logs and open an issue on Github."
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
class AliasedGroup(click.Group):
|
||||
"""
|
||||
Implements a subclass of Group that accepts a prefix for a command.
|
||||
"""Implements a subclass of Group that accepts a prefix for a command.
|
||||
|
||||
If there were a command called push, it would accept pus as an alias (so long as it was unique)
|
||||
From Click documentation
|
||||
From Click documentation.
|
||||
"""
|
||||
|
||||
def get_command(self, ctx: click.Context, cmd_name: str) -> Any:
|
||||
"""Todo: document code"""
|
||||
"""Todo: document code."""
|
||||
rv = click.Group.get_command(self, ctx, cmd_name)
|
||||
if rv is not None:
|
||||
return rv
|
||||
|
@ -107,15 +105,16 @@ class AliasedGroup(click.Group):
|
|||
return None
|
||||
|
||||
def resolve_command(self, ctx: click.Context, args: Any) -> Any:
|
||||
"""Todo: document code"""
|
||||
"""Todo: document code."""
|
||||
# always return the full command name
|
||||
_, cmd, args = super().resolve_command(ctx, args)
|
||||
return cmd.name, cmd, args # type: ignore
|
||||
if not cmd:
|
||||
return None, None, None
|
||||
return cmd.name, cmd, args
|
||||
|
||||
|
||||
# TODO: check code of click.pass_context that raise mypy errors for types and adapt this decorator
|
||||
def inventory_options(f: Any) -> Any:
|
||||
"""Click common options when requiring an inventory to interact with devices"""
|
||||
def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Click common options when requiring an inventory to interact with devices."""
|
||||
|
||||
@click.option(
|
||||
"--username",
|
||||
|
@ -159,26 +158,34 @@ def inventory_options(f: Any) -> Any:
|
|||
)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
help="Global connection timeout",
|
||||
default=30,
|
||||
help="Global API timeout. This value will be used for all devices.",
|
||||
default=30.0,
|
||||
show_envvar=True,
|
||||
envvar="ANTA_TIMEOUT",
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--insecure",
|
||||
help="Disable SSH Host Key validation",
|
||||
help="Disable SSH Host Key validation.",
|
||||
default=False,
|
||||
show_envvar=True,
|
||||
envvar="ANTA_INSECURE",
|
||||
is_flag=True,
|
||||
show_default=True,
|
||||
)
|
||||
@click.option("--disable-cache", help="Disable cache globally", show_envvar=True, envvar="ANTA_DISABLE_CACHE", show_default=True, is_flag=True, default=False)
|
||||
@click.option(
|
||||
"--disable-cache",
|
||||
help="Disable cache globally.",
|
||||
show_envvar=True,
|
||||
envvar="ANTA_DISABLE_CACHE",
|
||||
show_default=True,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
)
|
||||
@click.option(
|
||||
"--inventory",
|
||||
"-i",
|
||||
help="Path to the inventory YAML file",
|
||||
help="Path to the inventory YAML file.",
|
||||
envvar="ANTA_INVENTORY",
|
||||
show_envvar=True,
|
||||
required=True,
|
||||
|
@ -186,8 +193,7 @@ def inventory_options(f: Any) -> Any:
|
|||
)
|
||||
@click.option(
|
||||
"--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,
|
||||
envvar="ANTA_TAGS",
|
||||
type=str,
|
||||
|
@ -200,13 +206,13 @@ def inventory_options(f: Any) -> Any:
|
|||
ctx: click.Context,
|
||||
*args: tuple[Any],
|
||||
inventory: Path,
|
||||
tags: list[str] | None,
|
||||
tags: set[str] | None,
|
||||
username: str,
|
||||
password: str | None,
|
||||
enable_password: str | None,
|
||||
enable: bool,
|
||||
prompt: bool,
|
||||
timeout: int,
|
||||
timeout: float,
|
||||
insecure: bool,
|
||||
disable_cache: bool,
|
||||
**kwargs: dict[str, Any],
|
||||
|
@ -218,17 +224,25 @@ def inventory_options(f: Any) -> Any:
|
|||
if prompt:
|
||||
# User asked for a password prompt
|
||||
if password is None:
|
||||
password = click.prompt("Please enter a password to connect to EOS", type=str, hide_input=True, confirmation_prompt=True)
|
||||
if enable:
|
||||
if enable_password is None:
|
||||
if click.confirm("Is a password required to enter EOS privileged EXEC mode?"):
|
||||
enable_password = click.prompt(
|
||||
"Please enter a password to enter EOS privileged EXEC mode", type=str, hide_input=True, confirmation_prompt=True
|
||||
)
|
||||
password = click.prompt(
|
||||
"Please enter a password to connect to EOS",
|
||||
type=str,
|
||||
hide_input=True,
|
||||
confirmation_prompt=True,
|
||||
)
|
||||
if enable and enable_password is None and click.confirm("Is a password required to enter EOS privileged EXEC mode?"):
|
||||
enable_password = click.prompt(
|
||||
"Please enter a password to enter EOS privileged EXEC mode",
|
||||
type=str,
|
||||
hide_input=True,
|
||||
confirmation_prompt=True,
|
||||
)
|
||||
if password is None:
|
||||
raise click.BadParameter("EOS password needs to be provided by using either the '--password' option or the '--prompt' option.")
|
||||
msg = "EOS password needs to be provided by using either the '--password' option or the '--prompt' option."
|
||||
raise click.BadParameter(msg)
|
||||
if not enable and enable_password:
|
||||
raise click.BadParameter("Providing a password to access EOS Privileged EXEC mode requires '--enable' option.")
|
||||
msg = "Providing a password to access EOS Privileged EXEC mode requires '--enable' option."
|
||||
raise click.BadParameter(msg)
|
||||
try:
|
||||
i = AntaInventory.parse(
|
||||
filename=inventory,
|
||||
|
@ -240,15 +254,15 @@ def inventory_options(f: Any) -> Any:
|
|||
insecure=insecure,
|
||||
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)
|
||||
return f(*args, inventory=i, tags=tags, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def catalog_options(f: Any) -> Any:
|
||||
"""Click common options when requiring a test catalog to execute ANTA tests"""
|
||||
def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Click common options when requiring a test catalog to execute ANTA tests."""
|
||||
|
||||
@click.option(
|
||||
"--catalog",
|
||||
|
@ -256,12 +270,23 @@ def catalog_options(f: Any) -> Any:
|
|||
envvar="ANTA_CATALOG",
|
||||
show_envvar=True,
|
||||
help="Path to the test catalog YAML file",
|
||||
type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path),
|
||||
type=click.Path(
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
exists=True,
|
||||
readable=True,
|
||||
path_type=Path,
|
||||
),
|
||||
required=True,
|
||||
)
|
||||
@click.pass_context
|
||||
@functools.wraps(f)
|
||||
def wrapper(ctx: click.Context, *args: tuple[Any], catalog: Path, **kwargs: dict[str, Any]) -> Any:
|
||||
def wrapper(
|
||||
ctx: click.Context,
|
||||
*args: tuple[Any],
|
||||
catalog: Path,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> Any:
|
||||
# If help is invoke somewhere, do not parse catalog
|
||||
if ctx.obj.get("_anta_help"):
|
||||
return f(*args, catalog=None, **kwargs)
|
||||
|
|
|
@ -1,19 +1,17 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Module that provides predefined types for AntaTest.Input instances
|
||||
"""
|
||||
"""Module that provides predefined types for AntaTest.Input instances."""
|
||||
|
||||
import re
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.functional_validators import AfterValidator, BeforeValidator
|
||||
from typing_extensions import Annotated
|
||||
|
||||
|
||||
def aaa_group_prefix(v: str) -> str:
|
||||
"""Prefix the AAA method with 'group' if it is known"""
|
||||
"""Prefix the AAA method with 'group' if it is known."""
|
||||
built_in_methods = ["local", "none", "logging"]
|
||||
return f"group {v}" if v not in built_in_methods and not v.startswith("group ") else v
|
||||
|
||||
|
@ -24,11 +22,13 @@ def interface_autocomplete(v: str) -> str:
|
|||
Supported alias:
|
||||
- `et`, `eth` will be changed to `Ethernet`
|
||||
- `po` will be changed to `Port-Channel`
|
||||
- `lo` will be changed to `Loopback`"""
|
||||
- `lo` will be changed to `Loopback`
|
||||
"""
|
||||
intf_id_re = re.compile(r"[0-9]+(\/[0-9]+)*(\.[0-9]+)?")
|
||||
m = intf_id_re.search(v)
|
||||
if m is None:
|
||||
raise ValueError(f"Could not parse interface ID in interface '{v}'")
|
||||
msg = f"Could not parse interface ID in interface '{v}'"
|
||||
raise ValueError(msg)
|
||||
intf_id = m[0]
|
||||
|
||||
alias_map = {"et": "Ethernet", "eth": "Ethernet", "po": "Port-Channel", "lo": "Loopback"}
|
||||
|
@ -43,10 +43,12 @@ def interface_autocomplete(v: str) -> str:
|
|||
def interface_case_sensitivity(v: str) -> str:
|
||||
"""Reformat interface name to match expected case sensitivity.
|
||||
|
||||
Examples:
|
||||
Examples
|
||||
--------
|
||||
- ethernet -> Ethernet
|
||||
- vlan -> Vlan
|
||||
- loopback -> Loopback
|
||||
|
||||
"""
|
||||
if isinstance(v, str) and len(v) > 0 and not v[0].isupper():
|
||||
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:
|
||||
"""
|
||||
Abbreviations for different BGP multiprotocol capabilities.
|
||||
Examples:
|
||||
"""Abbreviations for different BGP multiprotocol capabilities.
|
||||
|
||||
Examples
|
||||
--------
|
||||
- IPv4 Unicast
|
||||
- L2vpnEVPN
|
||||
- ipv4 MPLS Labels
|
||||
- ipv4Mplsvpn
|
||||
|
||||
"""
|
||||
patterns = {
|
||||
r"\b(l2[\s\-]?vpn[\s\-]?evpn)\b": "l2VpnEvpn",
|
||||
|
@ -97,8 +101,8 @@ VxlanSrcIntf = Annotated[
|
|||
BeforeValidator(interface_autocomplete),
|
||||
BeforeValidator(interface_case_sensitivity),
|
||||
]
|
||||
Afi = Literal["ipv4", "ipv6", "vpn-ipv4", "vpn-ipv6", "evpn", "rt-membership"]
|
||||
Safi = Literal["unicast", "multicast", "labeled-unicast"]
|
||||
Afi = Literal["ipv4", "ipv6", "vpn-ipv4", "vpn-ipv6", "evpn", "rt-membership", "path-selection", "link-state"]
|
||||
Safi = Literal["unicast", "multicast", "labeled-unicast", "sr-te"]
|
||||
EncryptionAlgorithm = Literal["RSA", "ECDSA"]
|
||||
RsaKeySize = Literal[2048, 3072, 4096]
|
||||
EcdsaKeySize = Literal[256, 384, 521]
|
||||
|
@ -120,3 +124,8 @@ ErrDisableReasons = Literal[
|
|||
"uplink-failure-detection",
|
||||
]
|
||||
ErrDisableInterval = Annotated[int, Field(ge=30, le=86400)]
|
||||
Percent = Annotated[float, Field(ge=0.0, le=100.0)]
|
||||
PositiveInteger = Annotated[int, Field(ge=0)]
|
||||
Revision = Annotated[int, Field(ge=1, le=99)]
|
||||
Hostname = Annotated[str, Field(pattern=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
|
||||
# that can be found in the LICENSE file.
|
||||
"""decorators for tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, cast
|
||||
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
|
||||
|
||||
from anta.models import AntaTest, logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.result_manager.models import TestResult
|
||||
|
||||
# TODO - should probably use mypy Awaitable in some places rather than this everywhere - @gmuloc
|
||||
# TODO: should probably use mypy Awaitable in some places rather than this everywhere - @gmuloc
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def deprecated_test(new_tests: Optional[list[str]] = None) -> Callable[[F], F]:
|
||||
"""
|
||||
Return a decorator to log a message of WARNING severity when a test is deprecated.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
"""
|
||||
|
||||
def decorator(function: F) -> F:
|
||||
"""
|
||||
Actual decorator that logs the message.
|
||||
"""Actual decorator that logs the message.
|
||||
|
||||
Args:
|
||||
function (F): The test function to be decorated.
|
||||
----
|
||||
function: The test function to be decorated.
|
||||
|
||||
Returns:
|
||||
Returns
|
||||
-------
|
||||
F: The decorated function.
|
||||
|
||||
"""
|
||||
|
||||
@wraps(function)
|
||||
|
@ -43,9 +48,9 @@ def deprecated_test(new_tests: Optional[list[str]] = None) -> Callable[[F], F]:
|
|||
anta_test = args[0]
|
||||
if new_tests:
|
||||
new_test_names = ", ".join(new_tests)
|
||||
logger.warning(f"{anta_test.name} test is deprecated. Consider using the following new tests: {new_test_names}.")
|
||||
logger.warning("%s test is deprecated. Consider using the following new tests: %s.", anta_test.name, new_test_names)
|
||||
else:
|
||||
logger.warning(f"{anta_test.name} test is deprecated.")
|
||||
logger.warning("%s test is deprecated.", anta_test.name)
|
||||
return await function(*args, **kwargs)
|
||||
|
||||
return cast(F, wrapper)
|
||||
|
@ -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]:
|
||||
"""
|
||||
Return a decorator to skip a test based on the device's hardware model.
|
||||
"""Return a decorator to skip a test based on the device's hardware model.
|
||||
|
||||
This decorator factory generates a decorator that will check the hardware model of the device
|
||||
the test is run on. If the model is in the list of platforms specified, the test will be skipped.
|
||||
|
||||
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.
|
||||
|
||||
"""
|
||||
|
||||
def decorator(function: F) -> F:
|
||||
"""
|
||||
Actual decorator that either runs the test or skips it based on the device's hardware model.
|
||||
"""Actual decorator that either runs the test or skips it based on the device's hardware model.
|
||||
|
||||
Args:
|
||||
function (F): The test function to be decorated.
|
||||
----
|
||||
function: The test function to be decorated.
|
||||
|
||||
Returns:
|
||||
Returns
|
||||
-------
|
||||
F: The decorated function.
|
||||
|
||||
"""
|
||||
|
||||
@wraps(function)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> TestResult:
|
||||
"""
|
||||
Check the device's hardware model and conditionally run or skip the test.
|
||||
"""Check the device's hardware model and conditionally run or skip the test.
|
||||
|
||||
This wrapper inspects the hardware model of the device the test is run on.
|
||||
If the model is in the list of specified platforms, the test is either skipped.
|
||||
|
|
323
anta/device.py
323
anta/device.py
|
@ -1,65 +1,75 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
ANTA Device Abstraction Module
|
||||
"""
|
||||
"""ANTA Device Abstraction Module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator, Literal, Optional, Union
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import asyncssh
|
||||
import httpcore
|
||||
from aiocache import Cache
|
||||
from aiocache.plugins import HitMissRatioPlugin
|
||||
from asyncssh import SSHClientConnection, SSHClientConnectionOptions
|
||||
from httpx import ConnectError, HTTPError
|
||||
from httpx import ConnectError, HTTPError, TimeoutException
|
||||
|
||||
from anta import __DEBUG__, aioeapi
|
||||
from anta.logger import anta_log_exception, exc_to_str
|
||||
from anta.models import AntaCommand
|
||||
from anta.tools.misc import exc_to_str
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Do not load the default keypairs multiple times due to a performance issue introduced in cryptography 37.0
|
||||
# https://github.com/pyca/cryptography/issues/7236#issuecomment-1131908472
|
||||
CLIENT_KEYS = asyncssh.public_key.load_default_keypairs()
|
||||
|
||||
|
||||
class AntaDevice(ABC):
|
||||
"""
|
||||
Abstract class representing a device in ANTA.
|
||||
"""Abstract class representing a device in ANTA.
|
||||
|
||||
An implementation of this class must override the abstract coroutines `_collect()` and
|
||||
`refresh()`.
|
||||
|
||||
Attributes:
|
||||
Attributes
|
||||
----------
|
||||
name: Device name
|
||||
is_online: True if the device IP is reachable and a port can be open
|
||||
established: True if remote command execution succeeds
|
||||
hw_model: Hardware model of the device
|
||||
tags: List of tags for this device
|
||||
cache: In-memory cache from aiocache library for this device (None if cache is disabled)
|
||||
cache_locks: Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled
|
||||
is_online: True if the device IP is reachable and a port can be open.
|
||||
established: True if remote command execution succeeds.
|
||||
hw_model: Hardware model of the device.
|
||||
tags: Tags for this device.
|
||||
cache: In-memory cache from aiocache library for this device (None if cache is disabled).
|
||||
cache_locks: Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, tags: Optional[list[str]] = None, disable_cache: bool = False) -> None:
|
||||
"""
|
||||
Constructor of AntaDevice
|
||||
def __init__(self, name: str, tags: set[str] | None = None, *, disable_cache: bool = False) -> None:
|
||||
"""Initialize an AntaDevice.
|
||||
|
||||
Args:
|
||||
name: Device name
|
||||
tags: List of tags for this device
|
||||
disable_cache: Disable caching for all commands for this device. Defaults to False.
|
||||
----
|
||||
name: Device name.
|
||||
tags: Tags for this device.
|
||||
disable_cache: Disable caching for all commands for this device.
|
||||
|
||||
"""
|
||||
self.name: str = name
|
||||
self.hw_model: Optional[str] = None
|
||||
self.tags: list[str] = tags if tags is not None else []
|
||||
self.hw_model: str | None = None
|
||||
self.tags: set[str] = tags if tags is not None else set()
|
||||
# A device always has its own name as tag
|
||||
self.tags.append(self.name)
|
||||
self.tags.add(self.name)
|
||||
self.is_online: bool = False
|
||||
self.established: bool = False
|
||||
self.cache: Optional[Cache] = None
|
||||
self.cache_locks: Optional[defaultdict[str, asyncio.Lock]] = None
|
||||
self.cache: Cache | None = None
|
||||
self.cache_locks: defaultdict[str, asyncio.Lock] | None = None
|
||||
|
||||
# Initialize cache if not disabled
|
||||
if not disable_cache:
|
||||
|
@ -68,34 +78,24 @@ class AntaDevice(ABC):
|
|||
@property
|
||||
@abstractmethod
|
||||
def _keys(self) -> tuple[Any, ...]:
|
||||
"""
|
||||
Read-only property to implement hashing and equality for AntaDevice classes.
|
||||
"""
|
||||
"""Read-only property to implement hashing and equality for AntaDevice classes."""
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
"""
|
||||
Implement equality for AntaDevice objects.
|
||||
"""
|
||||
"""Implement equality for AntaDevice objects."""
|
||||
return self._keys == other._keys if isinstance(other, self.__class__) else False
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""
|
||||
Implement hashing for AntaDevice objects.
|
||||
"""
|
||||
"""Implement hashing for AntaDevice objects."""
|
||||
return hash(self._keys)
|
||||
|
||||
def _init_cache(self) -> None:
|
||||
"""
|
||||
Initialize cache for the device, can be overriden by subclasses to manipulate how it works
|
||||
"""
|
||||
"""Initialize cache for the device, can be overridden by subclasses to manipulate how it works."""
|
||||
self.cache = Cache(cache_class=Cache.MEMORY, ttl=60, namespace=self.name, plugins=[HitMissRatioPlugin()])
|
||||
self.cache_locks = defaultdict(asyncio.Lock)
|
||||
|
||||
@property
|
||||
def cache_statistics(self) -> dict[str, Any] | None:
|
||||
"""
|
||||
Returns the device cache statistics for logging purposes
|
||||
"""
|
||||
"""Returns the device cache statistics for logging purposes."""
|
||||
# Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough
|
||||
# https://github.com/pylint-dev/pylint/issues/7258
|
||||
if self.cache is not None:
|
||||
|
@ -104,9 +104,9 @@ class AntaDevice(ABC):
|
|||
return None
|
||||
|
||||
def __rich_repr__(self) -> Iterator[tuple[str, Any]]:
|
||||
"""
|
||||
Implements Rich Repr Protocol
|
||||
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
|
||||
"""Implement Rich Repr Protocol.
|
||||
|
||||
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol.
|
||||
"""
|
||||
yield "name", self.name
|
||||
yield "tags", self.tags
|
||||
|
@ -117,8 +117,8 @@ class AntaDevice(ABC):
|
|||
|
||||
@abstractmethod
|
||||
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
|
||||
for a device in ANTA.
|
||||
|
||||
|
@ -130,12 +130,13 @@ class AntaDevice(ABC):
|
|||
`AntaCommand` object passed as argument would be `None` in this case.
|
||||
|
||||
Args:
|
||||
----
|
||||
command: the command to collect
|
||||
|
||||
"""
|
||||
|
||||
async def collect(self, command: AntaCommand) -> None:
|
||||
"""
|
||||
Collects the output for a specified command.
|
||||
"""Collect the output for a specified command.
|
||||
|
||||
When caching is activated on both the device and the command,
|
||||
this method prioritizes retrieving the output from the cache. In cases where the output isn't cached yet,
|
||||
|
@ -146,7 +147,9 @@ class AntaDevice(ABC):
|
|||
via the private `_collect` method without interacting with the cache.
|
||||
|
||||
Args:
|
||||
----
|
||||
command (AntaCommand): The command to process.
|
||||
|
||||
"""
|
||||
# Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough
|
||||
# https://github.com/pylint-dev/pylint/issues/7258
|
||||
|
@ -155,7 +158,7 @@ class AntaDevice(ABC):
|
|||
cached_output = await self.cache.get(command.uid) # pylint: disable=no-member
|
||||
|
||||
if cached_output is not None:
|
||||
logger.debug(f"Cache hit for {command.command} on {self.name}")
|
||||
logger.debug("Cache hit for %s on %s", command.command, self.name)
|
||||
command.output = cached_output
|
||||
else:
|
||||
await self._collect(command=command)
|
||||
|
@ -164,26 +167,18 @@ class AntaDevice(ABC):
|
|||
await self._collect(command=command)
|
||||
|
||||
async def collect_commands(self, commands: list[AntaCommand]) -> None:
|
||||
"""
|
||||
Collect multiple commands.
|
||||
"""Collect multiple commands.
|
||||
|
||||
Args:
|
||||
----
|
||||
commands: the commands to collect
|
||||
|
||||
"""
|
||||
await asyncio.gather(*(self.collect(command=command) for command in commands))
|
||||
|
||||
def supports(self, command: AntaCommand) -> bool:
|
||||
"""Returns True if the command is supported on the device hardware platform, False otherwise."""
|
||||
unsupported = any("not supported on this hardware platform" in e for e in command.errors)
|
||||
logger.debug(command)
|
||||
if unsupported:
|
||||
logger.debug(f"{command.command} is not supported on {self.hw_model}")
|
||||
return not unsupported
|
||||
|
||||
@abstractmethod
|
||||
async def refresh(self) -> None:
|
||||
"""
|
||||
Update attributes of an AntaDevice instance.
|
||||
"""Update attributes of an AntaDevice instance.
|
||||
|
||||
This coroutine must update the following attributes of AntaDevice:
|
||||
- `is_online`: When the device IP is reachable and a port can be open
|
||||
|
@ -192,63 +187,71 @@ class AntaDevice(ABC):
|
|||
"""
|
||||
|
||||
async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None:
|
||||
"""
|
||||
Copy files to and from the device, usually through SCP.
|
||||
"""Copy files to and from the device, usually through SCP.
|
||||
|
||||
It is not mandatory to implement this for a valid AntaDevice subclass.
|
||||
|
||||
Args:
|
||||
----
|
||||
sources: List of files to copy to or from the device.
|
||||
destination: Local or remote destination when copying the files. Can be a folder.
|
||||
direction: Defines if this coroutine copies files to or from the device.
|
||||
|
||||
"""
|
||||
raise NotImplementedError(f"copy() method has not been implemented in {self.__class__.__name__} definition")
|
||||
_ = (sources, destination, direction)
|
||||
msg = f"copy() method has not been implemented in {self.__class__.__name__} definition"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
|
||||
class AsyncEOSDevice(AntaDevice):
|
||||
"""
|
||||
Implementation of AntaDevice for EOS using aio-eapi.
|
||||
"""Implementation of AntaDevice for EOS using aio-eapi.
|
||||
|
||||
Attributes:
|
||||
Attributes
|
||||
----------
|
||||
name: Device name
|
||||
is_online: True if the device IP is reachable and a port can be open
|
||||
established: True if remote command execution succeeds
|
||||
hw_model: Hardware model of the device
|
||||
tags: List of tags for this device
|
||||
tags: Tags for this device
|
||||
|
||||
"""
|
||||
|
||||
def __init__( # pylint: disable=R0913
|
||||
# pylint: disable=R0913
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
username: str,
|
||||
password: str,
|
||||
name: Optional[str] = None,
|
||||
enable: bool = False,
|
||||
enable_password: Optional[str] = None,
|
||||
port: Optional[int] = None,
|
||||
ssh_port: Optional[int] = 22,
|
||||
tags: Optional[list[str]] = None,
|
||||
timeout: Optional[float] = None,
|
||||
insecure: bool = False,
|
||||
name: str | None = None,
|
||||
enable_password: str | None = None,
|
||||
port: int | None = None,
|
||||
ssh_port: int | None = 22,
|
||||
tags: set[str] | None = None,
|
||||
timeout: float | None = None,
|
||||
proto: Literal["http", "https"] = "https",
|
||||
*,
|
||||
enable: bool = False,
|
||||
insecure: bool = False,
|
||||
disable_cache: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Constructor of AsyncEOSDevice
|
||||
"""Instantiate an AsyncEOSDevice.
|
||||
|
||||
Args:
|
||||
host: Device FQDN or IP
|
||||
username: Username to connect to eAPI and SSH
|
||||
password: Password to connect to eAPI and SSH
|
||||
name: Device name
|
||||
enable: Device needs privileged access
|
||||
enable_password: Password used to gain privileged access on EOS
|
||||
----
|
||||
host: Device FQDN or IP.
|
||||
username: Username to connect to eAPI and SSH.
|
||||
password: Password to connect to eAPI and SSH.
|
||||
name: Device name.
|
||||
enable: Collect commands using privileged mode.
|
||||
enable_password: Password used to gain privileged access on EOS.
|
||||
port: eAPI port. Defaults to 80 is proto is 'http' or 443 if proto is 'https'.
|
||||
ssh_port: SSH port
|
||||
tags: List of tags for this device
|
||||
timeout: Timeout value in seconds for outgoing connections. Default to 10 secs.
|
||||
insecure: Disable SSH Host Key validation
|
||||
proto: eAPI protocol. Value can be 'http' or 'https'
|
||||
disable_cache: Disable caching for all commands for this device. Defaults to False.
|
||||
ssh_port: SSH port.
|
||||
tags: Tags for this device.
|
||||
timeout: Timeout value in seconds for outgoing API calls.
|
||||
insecure: Disable SSH Host Key validation.
|
||||
proto: eAPI protocol. Value can be 'http' or 'https'.
|
||||
disable_cache: Disable caching for all commands for this device.
|
||||
|
||||
"""
|
||||
if host is None:
|
||||
message = "'host' is required to create an AsyncEOSDevice"
|
||||
|
@ -256,7 +259,7 @@ class AsyncEOSDevice(AntaDevice):
|
|||
raise ValueError(message)
|
||||
if name is None:
|
||||
name = f"{host}{f':{port}' if port else ''}"
|
||||
super().__init__(name, tags, disable_cache)
|
||||
super().__init__(name, tags, disable_cache=disable_cache)
|
||||
if username is None:
|
||||
message = f"'username' is required to instantiate device '{self.name}'"
|
||||
logger.error(message)
|
||||
|
@ -271,12 +274,14 @@ class AsyncEOSDevice(AntaDevice):
|
|||
ssh_params: dict[str, Any] = {}
|
||||
if insecure:
|
||||
ssh_params["known_hosts"] = None
|
||||
self._ssh_opts: SSHClientConnectionOptions = SSHClientConnectionOptions(host=host, port=ssh_port, username=username, password=password, **ssh_params)
|
||||
self._ssh_opts: SSHClientConnectionOptions = SSHClientConnectionOptions(
|
||||
host=host, port=ssh_port, username=username, password=password, client_keys=CLIENT_KEYS, **ssh_params
|
||||
)
|
||||
|
||||
def __rich_repr__(self) -> Iterator[tuple[str, Any]]:
|
||||
"""
|
||||
Implements Rich Repr Protocol
|
||||
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
|
||||
"""Implement Rich Repr Protocol.
|
||||
|
||||
https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol.
|
||||
"""
|
||||
yield from super().__rich_repr__()
|
||||
yield ("host", self._session.host)
|
||||
|
@ -286,107 +291,123 @@ class AsyncEOSDevice(AntaDevice):
|
|||
yield ("insecure", self._ssh_opts.known_hosts is None)
|
||||
if __DEBUG__:
|
||||
_ssh_opts = vars(self._ssh_opts).copy()
|
||||
PASSWORD_VALUE = "<removed>"
|
||||
_ssh_opts["password"] = PASSWORD_VALUE
|
||||
_ssh_opts["kwargs"]["password"] = PASSWORD_VALUE
|
||||
removed_pw = "<removed>"
|
||||
_ssh_opts["password"] = removed_pw
|
||||
_ssh_opts["kwargs"]["password"] = removed_pw
|
||||
yield ("_session", vars(self._session))
|
||||
yield ("_ssh_opts", _ssh_opts)
|
||||
|
||||
@property
|
||||
def _keys(self) -> tuple[Any, ...]:
|
||||
"""
|
||||
Two AsyncEOSDevice objects are equal if the hostname and the port are the same.
|
||||
"""Two AsyncEOSDevice objects are equal if the hostname and the port are the same.
|
||||
|
||||
This covers the use case of port forwarding when the host is localhost and the devices have different ports.
|
||||
"""
|
||||
return (self._session.host, self._session.port)
|
||||
|
||||
async def _collect(self, command: AntaCommand) -> None:
|
||||
"""
|
||||
Collect device command output from EOS using aio-eapi.
|
||||
async def _collect(self, command: AntaCommand) -> None: # noqa: C901 function is too complex - because of many required except blocks
|
||||
"""Collect device command output from EOS using aio-eapi.
|
||||
|
||||
Supports outformat `json` and `text` as output structure.
|
||||
Gain privileged access using the `enable_password` attribute
|
||||
of the `AntaDevice` instance if populated.
|
||||
|
||||
Args:
|
||||
command: the command to collect
|
||||
----
|
||||
command: the AntaCommand to collect.
|
||||
"""
|
||||
commands = []
|
||||
commands: list[dict[str, Any]] = []
|
||||
if self.enable and self._enable_password is not None:
|
||||
commands.append(
|
||||
{
|
||||
"cmd": "enable",
|
||||
"input": str(self._enable_password),
|
||||
}
|
||||
},
|
||||
)
|
||||
elif self.enable:
|
||||
# No password
|
||||
commands.append({"cmd": "enable"})
|
||||
if command.revision:
|
||||
commands.append({"cmd": command.command, "revision": command.revision})
|
||||
else:
|
||||
commands.append({"cmd": command.command})
|
||||
commands += [{"cmd": command.command, "revision": command.revision}] if command.revision else [{"cmd": command.command}]
|
||||
try:
|
||||
response: list[dict[str, Any]] = await self._session.cli(
|
||||
commands=commands,
|
||||
ofmt=command.ofmt,
|
||||
version=command.version,
|
||||
)
|
||||
except aioeapi.EapiCommandError as e:
|
||||
command.errors = e.errors
|
||||
if self.supports(command):
|
||||
message = f"Command '{command.command}' failed on {self.name}"
|
||||
logger.error(message)
|
||||
except (HTTPError, ConnectError) as e:
|
||||
command.errors = [str(e)]
|
||||
message = f"Cannot connect to device {self.name}"
|
||||
logger.error(message)
|
||||
else:
|
||||
# selecting only our command output
|
||||
# Do not keep response of 'enable' command
|
||||
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:
|
||||
"""
|
||||
Update attributes of an AsyncEOSDevice instance.
|
||||
"""Update attributes of an AsyncEOSDevice instance.
|
||||
|
||||
This coroutine must update the following attributes of AsyncEOSDevice:
|
||||
- is_online: When a device IP is reachable and a port can be open
|
||||
- established: When a command execution succeeds
|
||||
- hw_model: The hardware model of the device
|
||||
"""
|
||||
logger.debug(f"Refreshing device {self.name}")
|
||||
logger.debug("Refreshing device %s", self.name)
|
||||
self.is_online = await self._session.check_connection()
|
||||
if self.is_online:
|
||||
COMMAND: str = "show version"
|
||||
HW_MODEL_KEY: str = "modelName"
|
||||
try:
|
||||
response = await self._session.cli(command=COMMAND)
|
||||
except aioeapi.EapiCommandError as e:
|
||||
logger.warning(f"Cannot get hardware information from device {self.name}: {e.errmsg}")
|
||||
|
||||
except (HTTPError, ConnectError) as e:
|
||||
logger.warning(f"Cannot get hardware information from device {self.name}: {exc_to_str(e)}")
|
||||
|
||||
show_version = AntaCommand(command="show version")
|
||||
await self._collect(show_version)
|
||||
if not show_version.collected:
|
||||
logger.warning("Cannot get hardware information from device %s", self.name)
|
||||
else:
|
||||
if HW_MODEL_KEY in response:
|
||||
self.hw_model = response[HW_MODEL_KEY]
|
||||
else:
|
||||
logger.warning(f"Cannot get hardware information from device {self.name}: cannot parse '{COMMAND}'")
|
||||
|
||||
self.hw_model = show_version.json_output.get("modelName", None)
|
||||
if self.hw_model is None:
|
||||
logger.critical("Cannot parse 'show version' returned by device %s", self.name)
|
||||
else:
|
||||
logger.warning(f"Could not connect to device {self.name}: cannot open eAPI port")
|
||||
logger.warning("Could not connect to device %s: cannot open eAPI port", self.name)
|
||||
|
||||
self.established = bool(self.is_online and self.hw_model)
|
||||
|
||||
async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None:
|
||||
"""
|
||||
Copy files to and from the device using asyncssh.scp().
|
||||
"""Copy files to and from the device using asyncssh.scp().
|
||||
|
||||
Args:
|
||||
----
|
||||
sources: List of files to copy to or from the device.
|
||||
destination: Local or remote destination when copying the files. Can be a folder.
|
||||
direction: Defines if this coroutine copies files to or from the device.
|
||||
|
||||
"""
|
||||
async with asyncssh.connect(
|
||||
host=self._ssh_opts.host,
|
||||
|
@ -396,22 +417,24 @@ class AsyncEOSDevice(AntaDevice):
|
|||
local_addr=self._ssh_opts.local_addr,
|
||||
options=self._ssh_opts,
|
||||
) as conn:
|
||||
src: Union[list[tuple[SSHClientConnection, Path]], list[Path]]
|
||||
dst: Union[tuple[SSHClientConnection, Path], Path]
|
||||
src: list[tuple[SSHClientConnection, Path]] | list[Path]
|
||||
dst: tuple[SSHClientConnection, Path] | Path
|
||||
if direction == "from":
|
||||
src = [(conn, file) for file in sources]
|
||||
dst = destination
|
||||
for file in sources:
|
||||
logger.info(f"Copying '{file}' from device {self.name} to '{destination}' locally")
|
||||
message = f"Copying '{file}' from device {self.name} to '{destination}' locally"
|
||||
logger.info(message)
|
||||
|
||||
elif direction == "to":
|
||||
src = sources
|
||||
dst = conn, destination
|
||||
for file in src:
|
||||
logger.info(f"Copying '{file}' to device {self.name} to '{destination}' remotely")
|
||||
message = f"Copying '{file}' to device {self.name} to '{destination}' remotely"
|
||||
logger.info(message)
|
||||
|
||||
else:
|
||||
logger.critical(f"'direction' argument to copy() fonction is invalid: {direction}")
|
||||
logger.critical("'direction' argument to copy() function is invalid: %s", direction)
|
||||
|
||||
return
|
||||
await asyncssh.scp(src, dst)
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Inventory Module for ANTA.
|
||||
"""
|
||||
"""Inventory module for ANTA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
@ -11,32 +9,29 @@ import asyncio
|
|||
import logging
|
||||
from ipaddress import ip_address, ip_network
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pydantic import ValidationError
|
||||
from yaml import YAMLError, safe_load
|
||||
|
||||
from anta.device import AntaDevice, AsyncEOSDevice
|
||||
from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError
|
||||
from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError
|
||||
from anta.inventory.models import AntaInventoryInput
|
||||
from anta.logger import anta_log_exception
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AntaInventory(dict): # type: ignore
|
||||
# dict[str, AntaDevice] - not working in python 3.8 hence the ignore
|
||||
"""
|
||||
Inventory abstraction for ANTA framework.
|
||||
"""
|
||||
class AntaInventory(dict[str, AntaDevice]):
|
||||
"""Inventory abstraction for ANTA framework."""
|
||||
|
||||
# Root key of inventory part of the inventory file
|
||||
INVENTORY_ROOT_KEY = "anta_inventory"
|
||||
# Supported Output format
|
||||
INVENTORY_OUTPUT_FORMAT = ["native", "json"]
|
||||
INVENTORY_OUTPUT_FORMAT: ClassVar[list[str]] = ["native", "json"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Human readable string representing the inventory"""
|
||||
"""Human readable string representing the inventory."""
|
||||
devs = {}
|
||||
for dev in self.values():
|
||||
if (dev_type := dev.__class__.__name__) not in devs:
|
||||
|
@ -46,80 +41,106 @@ class AntaInventory(dict): # type: ignore
|
|||
return f"ANTA Inventory contains {' '.join([f'{n} devices ({t})' for t, n in devs.items()])}"
|
||||
|
||||
@staticmethod
|
||||
def _update_disable_cache(inventory_disable_cache: bool, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Return new dictionary, replacing kwargs with added disable_cache value from inventory_value
|
||||
if disable_cache has not been set by CLI.
|
||||
def _update_disable_cache(kwargs: dict[str, Any], *, inventory_disable_cache: bool) -> dict[str, Any]:
|
||||
"""Return new dictionary, replacing kwargs with added disable_cache value from inventory_value if disable_cache has not been set by CLI.
|
||||
|
||||
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
|
||||
|
||||
"""
|
||||
updated_kwargs = kwargs.copy()
|
||||
updated_kwargs["disable_cache"] = inventory_disable_cache or kwargs.get("disable_cache")
|
||||
return updated_kwargs
|
||||
|
||||
@staticmethod
|
||||
def _parse_hosts(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None:
|
||||
"""
|
||||
Parses the host section of an AntaInventoryInput and add the devices to the inventory
|
||||
def _parse_hosts(
|
||||
inventory_input: AntaInventoryInput,
|
||||
inventory: AntaInventory,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> None:
|
||||
"""Parse the host section of an AntaInventoryInput and add the devices to the inventory.
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
for host in inventory_input.hosts:
|
||||
updated_kwargs = AntaInventory._update_disable_cache(host.disable_cache, kwargs)
|
||||
device = AsyncEOSDevice(name=host.name, host=str(host.host), port=host.port, tags=host.tags, **updated_kwargs)
|
||||
updated_kwargs = AntaInventory._update_disable_cache(kwargs, inventory_disable_cache=host.disable_cache)
|
||||
device = AsyncEOSDevice(
|
||||
name=host.name,
|
||||
host=str(host.host),
|
||||
port=host.port,
|
||||
tags=host.tags,
|
||||
**updated_kwargs,
|
||||
)
|
||||
inventory.add_device(device)
|
||||
|
||||
@staticmethod
|
||||
def _parse_networks(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None:
|
||||
"""
|
||||
Parses the network section of an AntaInventoryInput and add the devices to the inventory.
|
||||
def _parse_networks(
|
||||
inventory_input: AntaInventoryInput,
|
||||
inventory: AntaInventory,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> None:
|
||||
"""Parse the network section of an AntaInventoryInput and add the devices to the inventory.
|
||||
|
||||
Args:
|
||||
inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices
|
||||
inventory (AntaInventory): AntaInventory to add the parsed devices to
|
||||
----
|
||||
inventory_input: AntaInventoryInput used to parse the devices
|
||||
inventory: AntaInventory to add the parsed devices to
|
||||
**kwargs: Additional keyword arguments to pass to the device constructor
|
||||
|
||||
Raises
|
||||
------
|
||||
InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema.
|
||||
|
||||
Raises:
|
||||
InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema.
|
||||
"""
|
||||
if inventory_input.networks is None:
|
||||
return
|
||||
|
||||
for network in inventory_input.networks:
|
||||
try:
|
||||
updated_kwargs = AntaInventory._update_disable_cache(network.disable_cache, kwargs)
|
||||
try:
|
||||
for network in inventory_input.networks:
|
||||
updated_kwargs = AntaInventory._update_disable_cache(kwargs, inventory_disable_cache=network.disable_cache)
|
||||
for host_ip in ip_network(str(network.network)):
|
||||
device = AsyncEOSDevice(host=str(host_ip), tags=network.tags, **updated_kwargs)
|
||||
inventory.add_device(device)
|
||||
except ValueError as e:
|
||||
message = "Could not parse network {network.network} in the inventory"
|
||||
anta_log_exception(e, message, logger)
|
||||
raise InventoryIncorrectSchema(message) from e
|
||||
except ValueError as e:
|
||||
message = "Could not parse the network section in the inventory"
|
||||
anta_log_exception(e, message, logger)
|
||||
raise InventoryIncorrectSchemaError(message) from e
|
||||
|
||||
@staticmethod
|
||||
def _parse_ranges(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None:
|
||||
"""
|
||||
Parses the range section of an AntaInventoryInput and add the devices to the inventory.
|
||||
def _parse_ranges(
|
||||
inventory_input: AntaInventoryInput,
|
||||
inventory: AntaInventory,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> None:
|
||||
"""Parse the range section of an AntaInventoryInput and add the devices to the inventory.
|
||||
|
||||
Args:
|
||||
inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices
|
||||
inventory (AntaInventory): AntaInventory to add the parsed devices to
|
||||
----
|
||||
inventory_input: AntaInventoryInput used to parse the devices
|
||||
inventory: AntaInventory to add the parsed devices to
|
||||
**kwargs: Additional keyword arguments to pass to the device constructor
|
||||
|
||||
Raises
|
||||
------
|
||||
InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema.
|
||||
|
||||
Raises:
|
||||
InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema.
|
||||
"""
|
||||
if inventory_input.ranges is None:
|
||||
return
|
||||
|
||||
for range_def in inventory_input.ranges:
|
||||
try:
|
||||
updated_kwargs = AntaInventory._update_disable_cache(range_def.disable_cache, kwargs)
|
||||
try:
|
||||
for range_def in inventory_input.ranges:
|
||||
updated_kwargs = AntaInventory._update_disable_cache(kwargs, inventory_disable_cache=range_def.disable_cache)
|
||||
range_increment = ip_address(str(range_def.start))
|
||||
range_stop = ip_address(str(range_def.end))
|
||||
while range_increment <= range_stop: # type: ignore[operator]
|
||||
|
@ -128,46 +149,49 @@ class AntaInventory(dict): # type: ignore
|
|||
device = AsyncEOSDevice(host=str(range_increment), tags=range_def.tags, **updated_kwargs)
|
||||
inventory.add_device(device)
|
||||
range_increment += 1
|
||||
except ValueError as e:
|
||||
message = f"Could not parse the following range in the inventory: {range_def.start} - {range_def.end}"
|
||||
anta_log_exception(e, message, logger)
|
||||
raise InventoryIncorrectSchema(message) from e
|
||||
except TypeError as e:
|
||||
message = f"A range in the inventory has different address families for start and end: {range_def.start} - {range_def.end}"
|
||||
anta_log_exception(e, message, logger)
|
||||
raise InventoryIncorrectSchema(message) from e
|
||||
except ValueError as e:
|
||||
message = "Could not parse the range section in the inventory"
|
||||
anta_log_exception(e, message, logger)
|
||||
raise InventoryIncorrectSchemaError(message) from e
|
||||
except TypeError as e:
|
||||
message = "A range in the inventory has different address families (IPv4 vs IPv6)"
|
||||
anta_log_exception(e, message, logger)
|
||||
raise InventoryIncorrectSchemaError(message) from e
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
@staticmethod
|
||||
def parse(
|
||||
filename: str | Path,
|
||||
username: str,
|
||||
password: str,
|
||||
enable_password: str | None = None,
|
||||
timeout: float | None = None,
|
||||
*,
|
||||
enable: bool = False,
|
||||
enable_password: Optional[str] = None,
|
||||
timeout: Optional[float] = None,
|
||||
insecure: bool = False,
|
||||
disable_cache: bool = False,
|
||||
) -> AntaInventory:
|
||||
# pylint: disable=too-many-arguments
|
||||
"""
|
||||
Create an AntaInventory instance from an inventory file.
|
||||
"""Create an AntaInventory instance from an inventory file.
|
||||
|
||||
The inventory devices are AsyncEOSDevice instances.
|
||||
|
||||
Args:
|
||||
filename (str): Path to device inventory YAML file
|
||||
username (str): Username to use to connect to devices
|
||||
password (str): Password to use to connect to devices
|
||||
enable (bool): Whether or not the commands need to be run in enable mode towards the devices
|
||||
enable_password (str, optional): Enable password to use if required
|
||||
timeout (float, optional): timeout in seconds for every API call.
|
||||
insecure (bool): Disable SSH Host Key validation
|
||||
disable_cache (bool): Disable cache globally
|
||||
----
|
||||
filename: Path to device inventory YAML file.
|
||||
username: Username to use to connect to devices.
|
||||
password: Password to use to connect to devices.
|
||||
enable_password: Enable password to use if required.
|
||||
timeout: Timeout value in seconds for outgoing API calls.
|
||||
enable: Whether or not the commands need to be run in enable mode towards the devices.
|
||||
insecure: Disable SSH Host Key validation.
|
||||
disable_cache: Disable cache globally.
|
||||
|
||||
Raises:
|
||||
Raises
|
||||
------
|
||||
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()
|
||||
kwargs: dict[str, Any] = {
|
||||
"username": username,
|
||||
|
@ -188,7 +212,8 @@ class AntaInventory(dict): # type: ignore
|
|||
raise ValueError(message)
|
||||
|
||||
try:
|
||||
with open(file=filename, mode="r", encoding="UTF-8") as file:
|
||||
filename = Path(filename)
|
||||
with filename.open(encoding="UTF-8") as file:
|
||||
data = safe_load(file)
|
||||
except (TypeError, YAMLError, OSError) as e:
|
||||
message = f"Unable to parse ANTA Device Inventory file '{filename}'"
|
||||
|
@ -213,6 +238,11 @@ class AntaInventory(dict): # type: ignore
|
|||
|
||||
return inventory
|
||||
|
||||
@property
|
||||
def devices(self) -> list[AntaDevice]:
|
||||
"""List of AntaDevice in this inventory."""
|
||||
return list(self.values())
|
||||
|
||||
###########################################################################
|
||||
# Public methods
|
||||
###########################################################################
|
||||
|
@ -221,30 +251,31 @@ class AntaInventory(dict): # type: ignore
|
|||
# GET methods
|
||||
###########################################################################
|
||||
|
||||
def get_inventory(self, established_only: bool = False, tags: Optional[list[str]] = None) -> AntaInventory:
|
||||
"""
|
||||
Returns a filtered inventory.
|
||||
def get_inventory(self, *, established_only: bool = False, tags: set[str] | None = None, devices: set[str] | None = None) -> AntaInventory:
|
||||
"""Return a filtered inventory.
|
||||
|
||||
Args:
|
||||
established_only: Whether or not to include only established devices. Default False.
|
||||
tags: List of tags to filter devices.
|
||||
----
|
||||
established_only: Whether or not to include only established devices.
|
||||
tags: Tags to filter devices.
|
||||
devices: Names to filter devices.
|
||||
|
||||
Returns:
|
||||
AntaInventory: An inventory with filtered AntaDevice objects.
|
||||
Returns
|
||||
-------
|
||||
An inventory with filtered AntaDevice objects.
|
||||
"""
|
||||
|
||||
def _filter_devices(device: AntaDevice) -> bool:
|
||||
"""
|
||||
Helper function to select the devices based on the input tags
|
||||
and the requirement for an established connection.
|
||||
"""
|
||||
"""Select the devices based on the inputs `tags`, `devices` and `established_only`."""
|
||||
if tags is not None and all(tag not in tags for tag in device.tags):
|
||||
return False
|
||||
return bool(not established_only or device.established)
|
||||
if devices is None or device.name in devices:
|
||||
return bool(not established_only or device.established)
|
||||
return False
|
||||
|
||||
devices: list[AntaDevice] = list(filter(_filter_devices, self.values()))
|
||||
filtered_devices: list[AntaDevice] = list(filter(_filter_devices, self.values()))
|
||||
result = AntaInventory()
|
||||
for device in devices:
|
||||
for device in filtered_devices:
|
||||
result.add_device(device)
|
||||
return result
|
||||
|
||||
|
@ -253,15 +284,19 @@ class AntaInventory(dict): # type: ignore
|
|||
###########################################################################
|
||||
|
||||
def __setitem__(self, key: str, value: AntaDevice) -> None:
|
||||
"""Set a device in the inventory."""
|
||||
if key != value.name:
|
||||
raise RuntimeError(f"The key must be the device name for device '{value.name}'. Use AntaInventory.add_device().")
|
||||
msg = f"The key must be the device name for device '{value.name}'. Use AntaInventory.add_device()."
|
||||
raise RuntimeError(msg)
|
||||
return super().__setitem__(key, value)
|
||||
|
||||
def add_device(self, device: AntaDevice) -> None:
|
||||
"""Add a device to final inventory.
|
||||
|
||||
Args:
|
||||
----
|
||||
device: Device object to be added
|
||||
|
||||
"""
|
||||
self[device.name] = device
|
||||
|
||||
|
|
|
@ -8,5 +8,5 @@ class InventoryRootKeyError(Exception):
|
|||
"""Error raised when inventory root key is not found."""
|
||||
|
||||
|
||||
class InventoryIncorrectSchema(Exception):
|
||||
class InventoryIncorrectSchemaError(Exception):
|
||||
"""Error when user data does not follow ANTA schema."""
|
||||
|
|
|
@ -6,87 +6,79 @@
|
|||
from __future__ import annotations
|
||||
|
||||
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, conint, constr
|
||||
from pydantic import BaseModel, ConfigDict, IPvAnyAddress, IPvAnyNetwork
|
||||
|
||||
from anta.custom_types import Hostname, Port
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pydantic models for input validation
|
||||
|
||||
RFC_1123_REGEX = r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$"
|
||||
|
||||
|
||||
class AntaInventoryHost(BaseModel):
|
||||
"""
|
||||
Host definition for user's inventory.
|
||||
"""Host entry of AntaInventoryInput.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
host: 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")
|
||||
|
||||
name: Optional[str] = None
|
||||
host: Union[constr(pattern=RFC_1123_REGEX), IPvAnyAddress] # type: ignore
|
||||
port: Optional[conint(gt=1, lt=65535)] = None # type: ignore
|
||||
tags: Optional[List[str]] = None
|
||||
name: str | None = None
|
||||
host: Hostname | IPvAnyAddress
|
||||
port: Port | None = None
|
||||
tags: set[str] | None = None
|
||||
disable_cache: bool = False
|
||||
|
||||
|
||||
class AntaInventoryNetwork(BaseModel):
|
||||
"""
|
||||
Network definition for user's inventory.
|
||||
"""Network entry of AntaInventoryInput.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
network: 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")
|
||||
|
||||
network: IPvAnyNetwork
|
||||
tags: Optional[List[str]] = None
|
||||
tags: set[str] | None = None
|
||||
disable_cache: bool = False
|
||||
|
||||
|
||||
class AntaInventoryRange(BaseModel):
|
||||
"""
|
||||
IP Range definition for user's inventory.
|
||||
"""IP Range entry of AntaInventoryInput.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
start: 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")
|
||||
|
||||
start: IPvAnyAddress
|
||||
end: IPvAnyAddress
|
||||
tags: Optional[List[str]] = None
|
||||
tags: set[str] | None = None
|
||||
disable_cache: bool = False
|
||||
|
||||
|
||||
class AntaInventoryInput(BaseModel):
|
||||
"""
|
||||
User's inventory model.
|
||||
|
||||
Attributes:
|
||||
networks (list[AntaInventoryNetwork],Optional): List of AntaInventoryNetwork objects for networks.
|
||||
hosts (list[AntaInventoryHost],Optional): List of AntaInventoryHost objects for hosts.
|
||||
range (list[AntaInventoryRange],Optional): List of AntaInventoryRange objects for ranges.
|
||||
"""
|
||||
"""Device inventory input model."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
networks: Optional[List[AntaInventoryNetwork]] = None
|
||||
hosts: Optional[List[AntaInventoryHost]] = None
|
||||
ranges: Optional[List[AntaInventoryRange]] = None
|
||||
networks: list[AntaInventoryNetwork] | None = None
|
||||
hosts: list[AntaInventoryHost] | None = None
|
||||
ranges: list[AntaInventoryRange] | None = None
|
||||
|
|
|
@ -1,26 +1,27 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Configure logging for ANTA
|
||||
"""
|
||||
"""Configure logging for ANTA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Literal, Optional
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from rich.logging import RichHandler
|
||||
|
||||
from anta import __DEBUG__
|
||||
from anta.tools.misc import exc_to_str
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Log(str, Enum):
|
||||
"""Represent log levels from logging module as immutable strings"""
|
||||
"""Represent log levels from logging module as immutable strings."""
|
||||
|
||||
CRITICAL = logging.getLevelName(logging.CRITICAL)
|
||||
ERROR = logging.getLevelName(logging.ERROR)
|
||||
|
@ -33,8 +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:
|
||||
"""
|
||||
Configure logging for ANTA.
|
||||
"""Configure logging for ANTA.
|
||||
|
||||
By default, the logging level is INFO for all loggers except for httpx and asyncssh which are too verbose:
|
||||
their logging level is WARNING.
|
||||
|
||||
|
@ -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.
|
||||
|
||||
Args:
|
||||
----
|
||||
level: ANTA logging level
|
||||
file: Send logs to a file
|
||||
|
||||
"""
|
||||
# Init root logger
|
||||
root = logging.getLogger()
|
||||
# In ANTA debug mode, level is overriden to DEBUG
|
||||
# In ANTA debug mode, level is overridden to DEBUG
|
||||
loglevel = logging.DEBUG if __DEBUG__ else getattr(logging, level.upper())
|
||||
root.setLevel(loglevel)
|
||||
# Silence the logging of chatty Python modules when level is INFO
|
||||
|
@ -64,44 +67,51 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None:
|
|||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
# Add RichHandler for stdout
|
||||
richHandler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False)
|
||||
# In ANTA debug mode, show Python module in stdout
|
||||
if __DEBUG__:
|
||||
fmt_string = r"[grey58]\[%(name)s][/grey58] %(message)s"
|
||||
else:
|
||||
fmt_string = "%(message)s"
|
||||
rich_handler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False)
|
||||
# Show Python module in stdout at DEBUG level
|
||||
fmt_string = "[grey58]\\[%(name)s][/grey58] %(message)s" if loglevel == logging.DEBUG else "%(message)s"
|
||||
formatter = logging.Formatter(fmt=fmt_string, datefmt="[%X]")
|
||||
richHandler.setFormatter(formatter)
|
||||
root.addHandler(richHandler)
|
||||
rich_handler.setFormatter(formatter)
|
||||
root.addHandler(rich_handler)
|
||||
# Add FileHandler if file is provided
|
||||
if file:
|
||||
fileHandler = logging.FileHandler(file)
|
||||
file_handler = logging.FileHandler(file)
|
||||
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
fileHandler.setFormatter(formatter)
|
||||
root.addHandler(fileHandler)
|
||||
file_handler.setFormatter(formatter)
|
||||
root.addHandler(file_handler)
|
||||
# If level is DEBUG and file is provided, do not send DEBUG level to stdout
|
||||
if loglevel == logging.DEBUG:
|
||||
richHandler.setLevel(logging.INFO)
|
||||
rich_handler.setLevel(logging.INFO)
|
||||
|
||||
if __DEBUG__:
|
||||
logger.debug("ANTA Debug Mode enabled")
|
||||
|
||||
|
||||
def anta_log_exception(exception: BaseException, message: Optional[str] = None, calling_logger: Optional[logging.Logger] = None) -> None:
|
||||
"""
|
||||
Helper function to help log exceptions:
|
||||
* if anta.__DEBUG__ is True then the logger.exception method is called to get the traceback
|
||||
* otherwise logger.error is called
|
||||
def exc_to_str(exception: BaseException) -> str:
|
||||
"""Return a human readable string from an BaseException object."""
|
||||
return f"{type(exception).__name__}{f': {exception}' if str(exception) else ''}"
|
||||
|
||||
|
||||
def anta_log_exception(exception: BaseException, message: str | None = None, calling_logger: logging.Logger | None = None) -> None:
|
||||
"""Log exception.
|
||||
|
||||
If `anta.__DEBUG__` is True then the `logger.exception` method is called to get the traceback, otherwise `logger.error` is called.
|
||||
|
||||
Args:
|
||||
exception (BAseException): The Exception being logged
|
||||
message (str): An optional message
|
||||
calling_logger (logging.Logger): A logger to which the exception should be logged
|
||||
if not present, the logger in this file is used.
|
||||
----
|
||||
exception: The Exception being logged.
|
||||
message: An optional message.
|
||||
calling_logger: A logger to which the exception should be logged. If not present, the logger in this file is used.
|
||||
|
||||
"""
|
||||
if calling_logger is None:
|
||||
calling_logger = logger
|
||||
calling_logger.critical(f"{message}\n{exc_to_str(exception)}" if message else exc_to_str(exception))
|
||||
if __DEBUG__:
|
||||
calling_logger.exception(f"[ANTA Debug Mode]{f' {message}' if message else ''}", exc_info=exception)
|
||||
msg = f"[ANTA Debug Mode]{f' {message}' if message else ''}"
|
||||
calling_logger.exception(msg, exc_info=exception)
|
||||
|
||||
|
||||
def tb_to_str(exception: BaseException) -> str:
|
||||
"""Return a traceback string from an BaseException object."""
|
||||
return "Traceback (most recent call last):\n" + "".join(traceback.format_tb(exception.__traceback__))
|
||||
|
|
335
anta/models.py
335
anta/models.py
|
@ -1,9 +1,8 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Models to define a TestStructure
|
||||
"""
|
||||
"""Models to define a TestStructure."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
@ -14,77 +13,99 @@ from abc import ABC, abstractmethod
|
|||
from copy import deepcopy
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
from string import Formatter
|
||||
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, TypeVar
|
||||
|
||||
# Need to keep Dict and List for pydantic in python 3.8
|
||||
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Coroutine, Dict, List, Literal, Optional, TypeVar, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError, conint
|
||||
from rich.progress import Progress, TaskID
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError, create_model
|
||||
|
||||
from anta import GITHUB_SUGGESTION
|
||||
from anta.logger import anta_log_exception
|
||||
from anta.custom_types import Revision
|
||||
from anta.logger import anta_log_exception, exc_to_str
|
||||
from anta.result_manager.models import TestResult
|
||||
from anta.tools.misc import exc_to_str
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Coroutine
|
||||
|
||||
from rich.progress import Progress, TaskID
|
||||
|
||||
from anta.device import AntaDevice
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
# Proper way to type input class - revisit this later if we get any issue @gmuloc
|
||||
# This would imply overhead to define classes
|
||||
# https://stackoverflow.com/questions/74103528/type-hinting-an-instance-of-a-nested-class
|
||||
# N = TypeVar("N", bound="AntaTest.Input")
|
||||
|
||||
|
||||
# TODO - make this configurable - with an env var maybe?
|
||||
# TODO: make this configurable - with an env var maybe?
|
||||
BLACKLIST_REGEX = [r"^reload.*", r"^conf\w*\s*(terminal|session)*", r"^wr\w*\s*\w+"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AntaMissingParamException(Exception):
|
||||
"""
|
||||
This Exception should be used when an expected key in an AntaCommand.params dictionary
|
||||
was not found.
|
||||
class AntaParamsBaseModel(BaseModel):
|
||||
"""Extends BaseModel and overwrite __getattr__ to return None on missing attribute."""
|
||||
|
||||
This Exception should in general never be raised in normal usage of ANTA.
|
||||
"""
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
self.message = "\n".join([message, GITHUB_SUGGESTION])
|
||||
super().__init__(self.message)
|
||||
if not TYPE_CHECKING:
|
||||
# Following pydantic declaration and keeping __getattr__ only when TYPE_CHECKING is false.
|
||||
# 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 to define a command template as Python f-string.
|
||||
|
||||
Can render a command from parameters.
|
||||
|
||||
Attributes:
|
||||
Attributes
|
||||
----------
|
||||
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.
|
||||
ofmt: eAPI output - json or text - default is json
|
||||
use_cache: Enable or disable caching for this AntaTemplate if the AntaDevice supports it - default is True
|
||||
ofmt: eAPI output - json or text.
|
||||
use_cache: Enable or disable caching for this AntaTemplate if the AntaDevice supports it.
|
||||
|
||||
"""
|
||||
|
||||
template: str
|
||||
version: Literal[1, "latest"] = "latest"
|
||||
revision: Optional[conint(ge=1, le=99)] = None # type: ignore
|
||||
revision: Revision | None = None
|
||||
ofmt: Literal["json", "text"] = "json"
|
||||
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.
|
||||
|
||||
Keep the parameters used in the AntaTemplate instance.
|
||||
|
||||
Args:
|
||||
----
|
||||
params: dictionary of variables with string values to render the Python f-string
|
||||
|
||||
Returns:
|
||||
Returns
|
||||
-------
|
||||
command: The rendered AntaCommand.
|
||||
This AntaCommand instance have a template attribute that references this
|
||||
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:
|
||||
return AntaCommand(
|
||||
command=self.template.format(**params),
|
||||
|
@ -92,7 +113,7 @@ class AntaTemplate(BaseModel):
|
|||
version=self.version,
|
||||
revision=self.revision,
|
||||
template=self,
|
||||
params=params,
|
||||
params=ParamsSchema(**params),
|
||||
use_cache=self.use_cache,
|
||||
)
|
||||
except KeyError as e:
|
||||
|
@ -113,70 +134,115 @@ class AntaCommand(BaseModel):
|
|||
|
||||
__Revision has precedence over version.__
|
||||
|
||||
Attributes:
|
||||
Attributes
|
||||
----------
|
||||
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.
|
||||
ofmt: eAPI output - json or text - default is json
|
||||
output: Output of the command populated by the collect() function
|
||||
template: AntaTemplate object used to render this command
|
||||
params: Dictionary of variables with string values to render the template
|
||||
errors: If the command execution fails, eAPI returns a list of strings detailing the error
|
||||
use_cache: Enable or disable caching for this AntaCommand if the AntaDevice supports it - default is True
|
||||
ofmt: eAPI output - json or text.
|
||||
output: Output of the command. Only defined if there was no errors.
|
||||
template: AntaTemplate object used to render this command.
|
||||
errors: If the command execution fails, eAPI returns a list of strings detailing the error(s).
|
||||
params: Pydantic Model containing the variables values used to render the template.
|
||||
use_cache: Enable or disable caching for this AntaCommand if the AntaDevice supports it.
|
||||
|
||||
"""
|
||||
|
||||
command: str
|
||||
version: Literal[1, "latest"] = "latest"
|
||||
revision: Optional[conint(ge=1, le=99)] = None # type: ignore
|
||||
revision: Revision | None = None
|
||||
ofmt: Literal["json", "text"] = "json"
|
||||
output: Optional[Union[Dict[str, Any], str]] = None
|
||||
template: Optional[AntaTemplate] = None
|
||||
errors: List[str] = []
|
||||
params: Dict[str, Any] = {}
|
||||
output: dict[str, Any] | str | None = None
|
||||
template: AntaTemplate | None = None
|
||||
errors: list[str] = []
|
||||
params: AntaParamsBaseModel = AntaParamsBaseModel()
|
||||
use_cache: bool = True
|
||||
|
||||
@property
|
||||
def uid(self) -> str:
|
||||
"""Generate a unique identifier for this command"""
|
||||
"""Generate a unique identifier for this command."""
|
||||
uid_str = f"{self.command}_{self.version}_{self.revision or 'NA'}_{self.ofmt}"
|
||||
return hashlib.sha1(uid_str.encode()).hexdigest()
|
||||
# Ignoring S324 probable use of insecure hash function - sha1 is enough for our needs.
|
||||
return hashlib.sha1(uid_str.encode()).hexdigest() # noqa: S324
|
||||
|
||||
@property
|
||||
def json_output(self) -> dict[str, Any]:
|
||||
"""Get the command output as JSON"""
|
||||
"""Get the command output as JSON."""
|
||||
if self.output is None:
|
||||
raise RuntimeError(f"There is no output for command {self.command}")
|
||||
msg = f"There is no output for command '{self.command}'"
|
||||
raise RuntimeError(msg)
|
||||
if self.ofmt != "json" or not isinstance(self.output, dict):
|
||||
raise RuntimeError(f"Output of command {self.command} is invalid")
|
||||
msg = f"Output of command '{self.command}' is invalid"
|
||||
raise RuntimeError(msg)
|
||||
return dict(self.output)
|
||||
|
||||
@property
|
||||
def text_output(self) -> str:
|
||||
"""Get the command output as a string"""
|
||||
"""Get the command output as a string."""
|
||||
if self.output is None:
|
||||
raise RuntimeError(f"There is no output for command {self.command}")
|
||||
msg = f"There is no output for command '{self.command}'"
|
||||
raise RuntimeError(msg)
|
||||
if self.ofmt != "text" or not isinstance(self.output, str):
|
||||
raise RuntimeError(f"Output of command {self.command} is invalid")
|
||||
msg = f"Output of command '{self.command}' is invalid"
|
||||
raise RuntimeError(msg)
|
||||
return str(self.output)
|
||||
|
||||
@property
|
||||
def error(self) -> bool:
|
||||
"""Return True if the command returned an error, False otherwise."""
|
||||
return len(self.errors) > 0
|
||||
|
||||
@property
|
||||
def collected(self) -> bool:
|
||||
"""Return True if the command has been collected"""
|
||||
return self.output is not None and not self.errors
|
||||
"""Return True if the command has been collected, False otherwise.
|
||||
|
||||
A command that has not been collected could have returned an error.
|
||||
See error property.
|
||||
"""
|
||||
return not self.error and self.output is not None
|
||||
|
||||
@property
|
||||
def requires_privileges(self) -> bool:
|
||||
"""Return True if the command requires privileged mode, False otherwise.
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError
|
||||
If the command has not been collected and has not returned an error.
|
||||
AntaDevice.collect() must be called before this property.
|
||||
"""
|
||||
if not self.collected and not self.error:
|
||||
msg = f"Command '{self.command}' has not been collected and has not returned an error. Call AntaDevice.collect()."
|
||||
raise RuntimeError(msg)
|
||||
return any("privileged mode required" in e for e in self.errors)
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""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):
|
||||
"""
|
||||
Raised when an AntaTemplate object could not be rendered
|
||||
because of missing parameters
|
||||
"""
|
||||
"""Raised when an AntaTemplate object could not be rendered because of missing parameters."""
|
||||
|
||||
def __init__(self, template: AntaTemplate, key: str):
|
||||
"""Constructor for AntaTemplateRenderError
|
||||
def __init__(self, template: AntaTemplate, key: str) -> None:
|
||||
"""Initialize an AntaTemplateRenderError.
|
||||
|
||||
Args:
|
||||
----
|
||||
template: The AntaTemplate instance that failed to render
|
||||
key: Key that has not been provided to render the template
|
||||
|
||||
"""
|
||||
self.template = template
|
||||
self.key = key
|
||||
|
@ -184,12 +250,13 @@ class AntaTemplateRenderError(RuntimeError):
|
|||
|
||||
|
||||
class AntaTest(ABC):
|
||||
"""Abstract class defining a test in ANTA
|
||||
"""Abstract class defining a test in ANTA.
|
||||
|
||||
The goal of this class is to handle the heavy lifting and make
|
||||
writing a test as simple as possible.
|
||||
|
||||
Examples:
|
||||
Examples
|
||||
--------
|
||||
The following is an example of an AntaTest subclass implementation:
|
||||
```python
|
||||
class VerifyReachability(AntaTest):
|
||||
|
@ -227,22 +294,24 @@ class AntaTest(ABC):
|
|||
instance_commands: List of AntaCommand instances of this test
|
||||
result: TestResult instance representing the result of this test
|
||||
logger: Python logger for this test instance
|
||||
|
||||
"""
|
||||
|
||||
# Mandatory class attributes
|
||||
# TODO - find a way to tell mypy these are mandatory for child classes - maybe Protocol
|
||||
# TODO: find a way to tell mypy these are mandatory for child classes - maybe Protocol
|
||||
name: ClassVar[str]
|
||||
description: ClassVar[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
|
||||
progress: Optional[Progress] = None
|
||||
nrfu_task: Optional[TaskID] = None
|
||||
progress: Progress | None = None
|
||||
nrfu_task: TaskID | None = None
|
||||
|
||||
class Input(BaseModel):
|
||||
"""Class defining inputs for a test in ANTA.
|
||||
|
||||
Examples:
|
||||
Examples
|
||||
--------
|
||||
A valid test catalog will look like the following:
|
||||
```yaml
|
||||
<Python module>:
|
||||
|
@ -255,72 +324,85 @@ class AntaTest(ABC):
|
|||
```
|
||||
Attributes:
|
||||
result_overwrite: Define fields to overwrite in the TestResult object
|
||||
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
result_overwrite: Optional[ResultOverwrite] = None
|
||||
filters: Optional[Filters] = None
|
||||
result_overwrite: ResultOverwrite | None = None
|
||||
filters: Filters | None = None
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""
|
||||
Implement generic hashing for AntaTest.Input.
|
||||
"""Implement generic hashing for AntaTest.Input.
|
||||
|
||||
This will work in most cases but this does not consider 2 lists with different ordering as equal.
|
||||
"""
|
||||
return hash(self.model_dump_json())
|
||||
|
||||
class ResultOverwrite(BaseModel):
|
||||
"""Test inputs model to overwrite result fields
|
||||
"""Test inputs model to overwrite result fields.
|
||||
|
||||
Attributes:
|
||||
Attributes
|
||||
----------
|
||||
description: overwrite TestResult.description
|
||||
categories: overwrite TestResult.categories
|
||||
custom_field: a free string that will be included in the TestResult object
|
||||
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
description: Optional[str] = None
|
||||
categories: Optional[List[str]] = None
|
||||
custom_field: Optional[str] = None
|
||||
description: str | None = None
|
||||
categories: list[str] | None = None
|
||||
custom_field: str | None = None
|
||||
|
||||
class Filters(BaseModel):
|
||||
"""Runtime filters to map tests with list of tags or devices
|
||||
"""Runtime filters to map tests with list of tags or devices.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
tags: Tag of devices on which to run the test.
|
||||
|
||||
Attributes:
|
||||
tags: List of device's tags for the test.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
tags: Optional[List[str]] = None
|
||||
tags: set[str] | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: AntaDevice,
|
||||
inputs: dict[str, Any] | AntaTest.Input | None = None,
|
||||
eos_data: list[dict[Any, Any] | str] | None = None,
|
||||
):
|
||||
"""AntaTest Constructor
|
||||
) -> None:
|
||||
"""AntaTest Constructor.
|
||||
|
||||
Args:
|
||||
----
|
||||
device: AntaDevice instance on which the test will be run
|
||||
inputs: dictionary of attributes used to instantiate the AntaTest.Input instance
|
||||
eos_data: Populate outputs of the test commands instead of collecting from devices.
|
||||
This list must have the same length and order than the `instance_commands` instance attribute.
|
||||
|
||||
"""
|
||||
self.logger: logging.Logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}")
|
||||
self.device: AntaDevice = device
|
||||
self.inputs: AntaTest.Input
|
||||
self.instance_commands: list[AntaCommand] = []
|
||||
self.result: TestResult = TestResult(name=device.name, test=self.name, categories=self.categories, description=self.description)
|
||||
self.result: TestResult = TestResult(
|
||||
name=device.name,
|
||||
test=self.name,
|
||||
categories=self.categories,
|
||||
description=self.description,
|
||||
)
|
||||
self._init_inputs(inputs)
|
||||
if self.result.result == "unset":
|
||||
self._init_commands(eos_data)
|
||||
|
||||
def _init_inputs(self, inputs: dict[str, Any] | AntaTest.Input | None) -> None:
|
||||
"""Instantiate the `inputs` instance attribute with an `AntaTest.Input` instance
|
||||
to validate test inputs from defined model.
|
||||
"""Instantiate the `inputs` instance attribute with an `AntaTest.Input` instance to validate test inputs using the model.
|
||||
|
||||
Overwrite result fields based on `ResultOverwrite` input definition.
|
||||
|
||||
Any input validation error will set this test result status as 'error'."""
|
||||
Any input validation error will set this test result status as 'error'.
|
||||
"""
|
||||
try:
|
||||
if inputs is None:
|
||||
self.inputs = self.Input()
|
||||
|
@ -340,10 +422,11 @@ class AntaTest(ABC):
|
|||
self.result.description = res_ow.description
|
||||
self.result.custom_field = res_ow.custom_field
|
||||
|
||||
def _init_commands(self, eos_data: Optional[list[dict[Any, Any] | str]]) -> None:
|
||||
def _init_commands(self, eos_data: list[dict[Any, Any] | str] | None) -> None:
|
||||
"""Instantiate the `instance_commands` instance attribute from the `commands` class attribute.
|
||||
|
||||
- Copy of the `AntaCommand` instances
|
||||
- Render all `AntaTemplate` instances using the `render()` method
|
||||
- Render all `AntaTemplate` instances using the `render()` method.
|
||||
|
||||
Any template rendering error will set this test result status as 'error'.
|
||||
Any exception in user code in `render()` will set this test result status as 'error'.
|
||||
|
@ -371,11 +454,11 @@ class AntaTest(ABC):
|
|||
return
|
||||
|
||||
if eos_data is not None:
|
||||
self.logger.debug(f"Test {self.name} initialized with input data")
|
||||
self.logger.debug("Test %s initialized with input data", self.name)
|
||||
self.save_commands_data(eos_data)
|
||||
|
||||
def save_commands_data(self, eos_data: list[dict[str, Any] | str]) -> None:
|
||||
"""Populate output of all AntaCommand instances in `instance_commands`"""
|
||||
"""Populate output of all AntaCommand instances in `instance_commands`."""
|
||||
if len(eos_data) > len(self.instance_commands):
|
||||
self.result.is_error(message="Test initialization error: Trying to save more data than there are commands for the test")
|
||||
return
|
||||
|
@ -386,11 +469,12 @@ class AntaTest(ABC):
|
|||
self.instance_commands[index].output = data
|
||||
|
||||
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"]
|
||||
for attr in mandatory_attributes:
|
||||
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
|
||||
def collected(self) -> bool:
|
||||
|
@ -400,15 +484,17 @@ class AntaTest(ABC):
|
|||
@property
|
||||
def failed_commands(self) -> list[AntaCommand]:
|
||||
"""Returns a list of all the commands that have failed."""
|
||||
return [command for command in self.instance_commands if command.errors]
|
||||
return [command for command in self.instance_commands if command.error]
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render an AntaTemplate instance of this AntaTest using the provided
|
||||
AntaTest.Input instance at self.inputs.
|
||||
"""Render an AntaTemplate instance of this AntaTest using the provided AntaTest.Input instance at self.inputs.
|
||||
|
||||
This is not an abstract method because it does not need to be implemented if there is
|
||||
no AntaTemplate for this test."""
|
||||
raise NotImplementedError(f"AntaTemplate are provided but render() method has not been implemented for {self.__module__}.{self.name}")
|
||||
no AntaTemplate for this test.
|
||||
"""
|
||||
_ = template
|
||||
msg = f"AntaTemplate are provided but render() method has not been implemented for {self.__module__}.{self.name}"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
@property
|
||||
def blocked(self) -> bool:
|
||||
|
@ -417,15 +503,17 @@ class AntaTest(ABC):
|
|||
for command in self.instance_commands:
|
||||
for pattern in BLACKLIST_REGEX:
|
||||
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")
|
||||
state = True
|
||||
return state
|
||||
|
||||
async def collect(self) -> None:
|
||||
"""
|
||||
Method used to collect outputs of all commands of this test class from the device of this test instance.
|
||||
"""
|
||||
"""Collect outputs of all commands of this test class from the device of this test instance."""
|
||||
try:
|
||||
if self.blocked is False:
|
||||
await self.device.collect_commands(self.instance_commands)
|
||||
|
@ -439,8 +527,7 @@ class AntaTest(ABC):
|
|||
|
||||
@staticmethod
|
||||
def anta_test(function: F) -> Callable[..., Coroutine[Any, Any, TestResult]]:
|
||||
"""
|
||||
Decorator for the `test()` method.
|
||||
"""Decorate the `test()` method in child classes.
|
||||
|
||||
This decorator implements (in this order):
|
||||
|
||||
|
@ -454,15 +541,21 @@ class AntaTest(ABC):
|
|||
async def wrapper(
|
||||
self: AntaTest,
|
||||
eos_data: list[dict[Any, Any] | str] | None = None,
|
||||
**kwargs: Any,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> TestResult:
|
||||
"""
|
||||
"""Inner function for the anta_test decorator.
|
||||
|
||||
Args:
|
||||
----
|
||||
self: The test instance.
|
||||
eos_data: Populate outputs of the test commands instead of collecting from devices.
|
||||
This list must have the same length and order than the `instance_commands` instance attribute.
|
||||
kwargs: Any keyword argument to pass to the test.
|
||||
|
||||
Returns:
|
||||
Returns
|
||||
-------
|
||||
result: TestResult instance attribute populated with error status if any
|
||||
|
||||
"""
|
||||
|
||||
def format_td(seconds: float, digits: int = 3) -> str:
|
||||
|
@ -476,7 +569,7 @@ class AntaTest(ABC):
|
|||
# Data
|
||||
if eos_data is not None:
|
||||
self.save_commands_data(eos_data)
|
||||
self.logger.debug(f"Test {self.name} initialized with input data {eos_data}")
|
||||
self.logger.debug("Test %s initialized with input data %s", self.name, eos_data)
|
||||
|
||||
# If some data is missing, try to collect
|
||||
if not self.collected:
|
||||
|
@ -485,11 +578,10 @@ class AntaTest(ABC):
|
|||
return self.result
|
||||
|
||||
if cmds := self.failed_commands:
|
||||
self.logger.debug(self.device.supports)
|
||||
unsupported_commands = [f"Skipped because {c.command} is not supported on {self.device.hw_model}" for c in cmds if not self.device.supports(c)]
|
||||
self.logger.debug(unsupported_commands)
|
||||
unsupported_commands = [f"'{c.command}' is not supported on {self.device.hw_model}" for c in cmds if not c.supported]
|
||||
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))
|
||||
return self.result
|
||||
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))
|
||||
|
||||
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()
|
||||
return self.result
|
||||
|
@ -514,21 +607,20 @@ class AntaTest(ABC):
|
|||
return wrapper
|
||||
|
||||
@classmethod
|
||||
def update_progress(cls) -> None:
|
||||
"""
|
||||
Update progress bar for all AntaTest objects if it exists
|
||||
"""
|
||||
def update_progress(cls: type[AntaTest]) -> None:
|
||||
"""Update progress bar for all AntaTest objects if it exists."""
|
||||
if cls.progress and (cls.nrfu_task is not None):
|
||||
cls.progress.update(cls.nrfu_task, advance=1)
|
||||
|
||||
@abstractmethod
|
||||
def test(self) -> Coroutine[Any, Any, TestResult]:
|
||||
"""
|
||||
This abstract method is the core of the test logic.
|
||||
It must set the correct status of the `result` instance attribute
|
||||
with the appropriate outcome of the test.
|
||||
"""Core of the test logic.
|
||||
|
||||
Examples:
|
||||
This is an abstractmethod that must be implemented by child classes.
|
||||
It must set the correct status of the `result` instance attribute with the appropriate outcome of the test.
|
||||
|
||||
Examples
|
||||
--------
|
||||
It must be implemented using the `AntaTest.anta_test` decorator:
|
||||
```python
|
||||
@AntaTest.anta_test
|
||||
|
@ -536,6 +628,7 @@ class AntaTest(ABC):
|
|||
self.result.is_success()
|
||||
for command in self.instance_commands:
|
||||
if not self._test_command(command): # _test_command() is an arbitrary test logic
|
||||
self.result.is_failure("Failure reson")
|
||||
self.result.is_failure("Failure reason")
|
||||
```
|
||||
|
||||
"""
|
||||
|
|
|
@ -1,23 +1,25 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Report management for ANTA.
|
||||
"""
|
||||
"""Report management for ANTA."""
|
||||
|
||||
# pylint: disable = too-few-public-methods
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os.path
|
||||
import pathlib
|
||||
from typing import Any, Optional
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from jinja2 import Template
|
||||
from rich.table import Table
|
||||
|
||||
from anta import RICH_COLOR_PALETTE, RICH_COLOR_THEME
|
||||
from anta.custom_types import TestStatus
|
||||
from anta.result_manager import ResultManager
|
||||
|
||||
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__)
|
||||
|
||||
|
@ -25,33 +27,37 @@ logger = logging.getLogger(__name__)
|
|||
class ReportTable:
|
||||
"""TableReport Generate a Table based on TestResult."""
|
||||
|
||||
def _split_list_to_txt_list(self, usr_list: list[str], delimiter: Optional[str] = None) -> str:
|
||||
"""
|
||||
Split list to multi-lines string
|
||||
def _split_list_to_txt_list(self, usr_list: list[str], delimiter: str | None = None) -> str:
|
||||
"""Split list to multi-lines string.
|
||||
|
||||
Args:
|
||||
----
|
||||
usr_list (list[str]): List of string to concatenate
|
||||
delimiter (str, optional): A delimiter to use to start string. Defaults to None.
|
||||
|
||||
Returns:
|
||||
Returns
|
||||
-------
|
||||
str: Multi-lines string
|
||||
|
||||
"""
|
||||
if delimiter is not None:
|
||||
return "\n".join(f"{delimiter} {line}" for line in usr_list)
|
||||
return "\n".join(f"{line}" for line in usr_list)
|
||||
|
||||
def _build_headers(self, headers: list[str], table: Table) -> Table:
|
||||
"""
|
||||
Create headers for a table.
|
||||
"""Create headers for a table.
|
||||
|
||||
First key is considered as header and is colored using RICH_COLOR_PALETTE.HEADER
|
||||
|
||||
Args:
|
||||
headers (list[str]): List of headers
|
||||
table (Table): A rich Table instance
|
||||
----
|
||||
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):
|
||||
if idx == 0:
|
||||
|
@ -64,72 +70,69 @@ class ReportTable:
|
|||
return table
|
||||
|
||||
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:
|
||||
status (TestStatus): status value to color
|
||||
----
|
||||
status (TestStatus): status value to color.
|
||||
|
||||
Returns:
|
||||
Returns
|
||||
-------
|
||||
str: the colored string
|
||||
|
||||
"""
|
||||
color = RICH_COLOR_THEME.get(status, "")
|
||||
return f"[{color}]{status}" if color != "" else str(status)
|
||||
|
||||
def report_all(
|
||||
self,
|
||||
result_manager: ResultManager,
|
||||
host: Optional[str] = None,
|
||||
testcase: Optional[str] = None,
|
||||
title: str = "All tests results",
|
||||
) -> Table:
|
||||
"""
|
||||
Create a table report with all tests for one or all devices.
|
||||
def report_all(self, manager: ResultManager, title: str = "All tests results") -> Table:
|
||||
"""Create a table report with all tests for one or all devices.
|
||||
|
||||
Create table with full output: Host / Test / Status / Message
|
||||
|
||||
Args:
|
||||
result_manager (ResultManager): A manager with a list of tests.
|
||||
host (str, optional): IP Address of a host to search for. Defaults to None.
|
||||
testcase (str, optional): A test name to search for. Defaults to None.
|
||||
title (str, optional): Title for the report. Defaults to 'All tests results'.
|
||||
----
|
||||
manager: A ResultManager instance.
|
||||
title: 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)
|
||||
headers = ["Device", "Test Name", "Test Status", "Message(s)", "Test description", "Test category"]
|
||||
table = self._build_headers(headers=headers, table=table)
|
||||
|
||||
for result in result_manager.get_results():
|
||||
# pylint: disable=R0916
|
||||
if (host is None and testcase is None) or (host is not None and str(result.name) == host) or (testcase is not None and testcase == str(result.test)):
|
||||
state = self._color_result(result.result)
|
||||
message = self._split_list_to_txt_list(result.messages) if len(result.messages) > 0 else ""
|
||||
categories = ", ".join(result.categories)
|
||||
table.add_row(str(result.name), result.test, state, message, result.description, categories)
|
||||
def add_line(result: TestResult) -> None:
|
||||
state = self._color_result(result.result)
|
||||
message = self._split_list_to_txt_list(result.messages) if len(result.messages) > 0 else ""
|
||||
categories = ", ".join(result.categories)
|
||||
table.add_row(str(result.name), result.test, state, message, result.description, categories)
|
||||
|
||||
for result in manager.results:
|
||||
add_line(result)
|
||||
return table
|
||||
|
||||
def report_summary_tests(
|
||||
self,
|
||||
result_manager: ResultManager,
|
||||
testcase: Optional[str] = None,
|
||||
title: str = "Summary per test case",
|
||||
manager: ResultManager,
|
||||
tests: list[str] | None = None,
|
||||
title: str = "Summary per test",
|
||||
) -> Table:
|
||||
"""
|
||||
Create a table report with result agregated per test.
|
||||
"""Create a table report with result aggregated per test.
|
||||
|
||||
Create table with full output: Test / Number of success / Number of failure / Number of error / List of nodes in error or failure
|
||||
Create table with full output: Test | Number of success | Number of failure | Number of error | List of nodes in error or failure
|
||||
|
||||
Args:
|
||||
result_manager (ResultManager): A manager with a list of tests.
|
||||
testcase (str, optional): A test name to search for. Defaults to None.
|
||||
title (str, optional): Title for the report. Defaults to 'All tests results'.
|
||||
----
|
||||
manager: A ResultManager instance.
|
||||
tests: List of test names to include. None to select all tests.
|
||||
title: Title of the report.
|
||||
|
||||
Returns:
|
||||
Table: A fully populated rich Table
|
||||
Returns
|
||||
-------
|
||||
A fully populated rich `Table`.
|
||||
"""
|
||||
# sourcery skip: class-extract-method
|
||||
table = Table(title=title, show_lines=True)
|
||||
headers = [
|
||||
"Test Case",
|
||||
|
@ -140,16 +143,16 @@ class ReportTable:
|
|||
"List of failed or error nodes",
|
||||
]
|
||||
table = self._build_headers(headers=headers, table=table)
|
||||
for testcase_read in result_manager.get_testcases():
|
||||
if testcase is None or str(testcase_read) == testcase:
|
||||
results = result_manager.get_result_by_test(testcase_read)
|
||||
for test in manager.get_tests():
|
||||
if tests is None or test in tests:
|
||||
results = manager.filter_by_tests({test}).results
|
||||
nb_failure = len([result for result in results if result.result == "failure"])
|
||||
nb_error = len([result for result in results if result.result == "error"])
|
||||
list_failure = [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_skipped = len([result for result in results if result.result == "skipped"])
|
||||
table.add_row(
|
||||
testcase_read,
|
||||
test,
|
||||
str(nb_success),
|
||||
str(nb_skipped),
|
||||
str(nb_failure),
|
||||
|
@ -158,24 +161,25 @@ class ReportTable:
|
|||
)
|
||||
return table
|
||||
|
||||
def report_summary_hosts(
|
||||
def report_summary_devices(
|
||||
self,
|
||||
result_manager: ResultManager,
|
||||
host: Optional[str] = None,
|
||||
title: str = "Summary per host",
|
||||
manager: ResultManager,
|
||||
devices: list[str] | None = None,
|
||||
title: str = "Summary per device",
|
||||
) -> Table:
|
||||
"""
|
||||
Create a table report with result agregated per host.
|
||||
"""Create a table report with result aggregated per device.
|
||||
|
||||
Create table with full output: Host / Number of success / Number of failure / Number of error / List of nodes in error or failure
|
||||
Create table with full output: Host | Number of success | Number of failure | Number of error | List of nodes in error or failure
|
||||
|
||||
Args:
|
||||
result_manager (ResultManager): A manager with a list of tests.
|
||||
host (str, optional): IP Address of a host to search for. Defaults to None.
|
||||
title (str, optional): Title for the report. Defaults to 'All tests results'.
|
||||
----
|
||||
manager: A ResultManager instance.
|
||||
devices: List of device names to include. None to select all devices.
|
||||
title: Title of the report.
|
||||
|
||||
Returns:
|
||||
Table: A fully populated rich Table
|
||||
Returns
|
||||
-------
|
||||
A fully populated rich `Table`.
|
||||
"""
|
||||
table = Table(title=title, show_lines=True)
|
||||
headers = [
|
||||
|
@ -187,18 +191,16 @@ class ReportTable:
|
|||
"List of failed or error test cases",
|
||||
]
|
||||
table = self._build_headers(headers=headers, table=table)
|
||||
for host_read in result_manager.get_hosts():
|
||||
if host is None or str(host_read) == host:
|
||||
results = result_manager.get_result_by_host(host_read)
|
||||
logger.debug("data to use for computation")
|
||||
logger.debug(f"{host}: {results}")
|
||||
for device in manager.get_devices():
|
||||
if devices is None or device in devices:
|
||||
results = manager.filter_by_devices({device}).results
|
||||
nb_failure = len([result for result in results if result.result == "failure"])
|
||||
nb_error = len([result for result in results if result.result == "error"])
|
||||
list_failure = [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_skipped = len([result for result in results if result.result == "skipped"])
|
||||
table.add_row(
|
||||
str(host_read),
|
||||
device,
|
||||
str(nb_success),
|
||||
str(nb_skipped),
|
||||
str(nb_failure),
|
||||
|
@ -212,20 +214,20 @@ class ReportJinja:
|
|||
"""Report builder based on a Jinja2 template."""
|
||||
|
||||
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
|
||||
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:
|
||||
"""
|
||||
Build a report based on a Jinja2 template
|
||||
def render(self, data: list[dict[str, Any]], *, trim_blocks: bool = True, lstrip_blocks: bool = True) -> str:
|
||||
"""Build a report based on a Jinja2 template.
|
||||
|
||||
Report is built based on a J2 template provided by user.
|
||||
Data structure sent to template is:
|
||||
|
||||
>>> data = ResultManager.get_json_results()
|
||||
>>> print(data)
|
||||
>>> print(ResultManager.json)
|
||||
[
|
||||
{
|
||||
name: ...,
|
||||
|
@ -238,14 +240,17 @@ class ReportJinja:
|
|||
]
|
||||
|
||||
Args:
|
||||
data (list[dict[str, Any]]): List of results from ResultManager.get_results
|
||||
trim_blocks (bool, optional): enable trim_blocks for J2 rendering. Defaults to True.
|
||||
lstrip_blocks (bool, optional): enable lstrip_blocks for J2 rendering. Defaults to True.
|
||||
----
|
||||
data: List of results from ResultManager.results
|
||||
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)
|
||||
|
||||
return template.render({"data": data})
|
||||
|
|
|
@ -1,35 +1,32 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Result Manager Module for ANTA.
|
||||
"""
|
||||
"""Result Manager module for ANTA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
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:
|
||||
"""
|
||||
Helper to manage Test Results and generate reports.
|
||||
|
||||
Examples:
|
||||
"""Helper to manage Test Results and generate reports.
|
||||
|
||||
Examples
|
||||
--------
|
||||
Create Inventory:
|
||||
|
||||
inventory_anta = AntaInventory.parse(
|
||||
filename='examples/inventory.yml',
|
||||
username='ansible',
|
||||
password='ansible',
|
||||
timeout=0.5
|
||||
)
|
||||
|
||||
Create Result Manager:
|
||||
|
@ -38,17 +35,17 @@ class ResultManager:
|
|||
|
||||
Run tests for all connected devices:
|
||||
|
||||
for device in inventory_anta.get_inventory():
|
||||
manager.add_test_result(
|
||||
for device in inventory_anta.get_inventory().devices:
|
||||
manager.add(
|
||||
VerifyNTP(device=device).test()
|
||||
)
|
||||
manager.add_test_result(
|
||||
manager.add(
|
||||
VerifyEOSVersion(device=device).test(version='4.28.3M')
|
||||
)
|
||||
|
||||
Print result in native format:
|
||||
|
||||
manager.get_results()
|
||||
manager.results
|
||||
[
|
||||
TestResult(
|
||||
host=IPv4Address('192.168.0.10'),
|
||||
|
@ -63,11 +60,11 @@ class ResultManager:
|
|||
message=None
|
||||
),
|
||||
]
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
Class constructor.
|
||||
"""Class constructor.
|
||||
|
||||
The status of the class is initialized to "unset"
|
||||
|
||||
|
@ -88,124 +85,116 @@ class ResultManager:
|
|||
error_status is set to True.
|
||||
"""
|
||||
self._result_entries: list[TestResult] = []
|
||||
# Initialize status
|
||||
self.status: TestStatus = "unset"
|
||||
self.error_status = False
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""
|
||||
Implement __len__ method to count number of results.
|
||||
"""
|
||||
"""Implement __len__ method to count number of results."""
|
||||
return len(self._result_entries)
|
||||
|
||||
def _update_status(self, test_status: TestStatus) -> None:
|
||||
"""
|
||||
Update ResultManager status based on the table above.
|
||||
"""
|
||||
ResultValidator = TypeAdapter(TestStatus)
|
||||
ResultValidator.validate_python(test_status)
|
||||
if test_status == "error":
|
||||
self.error_status = True
|
||||
return
|
||||
if self.status == "unset":
|
||||
self.status = test_status
|
||||
elif self.status == "skipped" and test_status in {"success", "failure"}:
|
||||
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.
|
||||
"""
|
||||
@property
|
||||
def results(self) -> list[TestResult]:
|
||||
"""Get the list of TestResult."""
|
||||
return self._result_entries
|
||||
|
||||
def get_json_results(self) -> str:
|
||||
"""
|
||||
Expose list of all test results in JSON
|
||||
@results.setter
|
||||
def results(self, value: list[TestResult]) -> None:
|
||||
self._result_entries = []
|
||||
self.status = "unset"
|
||||
self.error_status = False
|
||||
for e in value:
|
||||
self.add(e)
|
||||
|
||||
Returns:
|
||||
str: JSON dumps of the list of results
|
||||
"""
|
||||
result = [result.model_dump() for result in self._result_entries]
|
||||
return json.dumps(result, indent=4)
|
||||
@property
|
||||
def json(self) -> str:
|
||||
"""Get a JSON representation of the results."""
|
||||
return json.dumps([result.model_dump() for result in self._result_entries], indent=4)
|
||||
|
||||
def get_result_by_test(self, test_name: str) -> list[TestResult]:
|
||||
"""
|
||||
Get list of test result for a given test.
|
||||
def add(self, result: TestResult) -> None:
|
||||
"""Add a result to the ResultManager instance.
|
||||
|
||||
Args:
|
||||
test_name (str): Test name to use to filter results
|
||||
output_format (str, optional): format selector. Can be either native/list. Defaults to 'native'.
|
||||
|
||||
Returns:
|
||||
list[TestResult]: List of results related to the test.
|
||||
----
|
||||
result: TestResult to add to the ResultManager instance.
|
||||
"""
|
||||
return [result for result in self._result_entries if str(result.test) == test_name]
|
||||
|
||||
def get_result_by_host(self, host_ip: str) -> list[TestResult]:
|
||||
"""
|
||||
Get list of test result for a given host.
|
||||
def _update_status(test_status: TestStatus) -> None:
|
||||
result_validator = TypeAdapter(TestStatus)
|
||||
result_validator.validate_python(test_status)
|
||||
if test_status == "error":
|
||||
self.error_status = True
|
||||
return
|
||||
if self.status == "unset" or self.status == "skipped" and test_status in {"success", "failure"}:
|
||||
self.status = test_status
|
||||
elif self.status == "success" and test_status == "failure":
|
||||
self.status = "failure"
|
||||
|
||||
self._result_entries.append(result)
|
||||
_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:
|
||||
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:
|
||||
list[TestResult]: List of results related to the host.
|
||||
Returns
|
||||
-------
|
||||
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]:
|
||||
"""
|
||||
Get list of name of all test cases in current manager.
|
||||
def filter_by_tests(self, tests: set[str]) -> ResultManager:
|
||||
"""Get a filtered ResultManager that only contains specific tests.
|
||||
|
||||
Returns:
|
||||
list[str]: List of names for all tests.
|
||||
"""
|
||||
result_list = []
|
||||
for testcase in self._result_entries:
|
||||
if str(testcase.test) not in result_list:
|
||||
result_list.append(str(testcase.test))
|
||||
return result_list
|
||||
Args:
|
||||
----
|
||||
tests: Set of test names to filter the results.
|
||||
|
||||
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:
|
||||
list[str]: List of IP addresses.
|
||||
def filter_by_devices(self, devices: set[str]) -> ResultManager:
|
||||
"""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 = []
|
||||
for testcase in self._result_entries:
|
||||
if str(testcase.name) not in result_list:
|
||||
result_list.append(str(testcase.name))
|
||||
return result_list
|
||||
manager = ResultManager()
|
||||
manager.results = [result for result in self._result_entries if result.name in devices]
|
||||
return manager
|
||||
|
||||
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
|
||||
# that can be found in the LICENSE file.
|
||||
"""Models related to anta.result_manager module."""
|
||||
from __future__ import annotations
|
||||
|
||||
# Need to keep List for pydantic in 3.8
|
||||
from typing import List, Optional
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
@ -13,10 +11,10 @@ from anta.custom_types import TestStatus
|
|||
|
||||
|
||||
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.
|
||||
test: Test name runs on the device.
|
||||
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".
|
||||
messages: Message to report after the test if any.
|
||||
custom_field: Custom field to store a string for flexibility in integrating with ANTA
|
||||
|
||||
"""
|
||||
|
||||
name: str
|
||||
test: str
|
||||
categories: List[str]
|
||||
categories: list[str]
|
||||
description: str
|
||||
result: TestStatus = "unset"
|
||||
messages: List[str] = []
|
||||
custom_field: Optional[str] = None
|
||||
messages: list[str] = []
|
||||
custom_field: str | None = None
|
||||
|
||||
def is_success(self, message: str | None = None) -> None:
|
||||
"""
|
||||
Helper to set status to success
|
||||
"""Set status to success.
|
||||
|
||||
Args:
|
||||
----
|
||||
message: Optional message related to the test
|
||||
|
||||
"""
|
||||
self._set_status("success", message)
|
||||
|
||||
def is_failure(self, message: str | None = None) -> None:
|
||||
"""
|
||||
Helper to set status to failure
|
||||
"""Set status to failure.
|
||||
|
||||
Args:
|
||||
----
|
||||
message: Optional message related to the test
|
||||
|
||||
"""
|
||||
self._set_status("failure", message)
|
||||
|
||||
def is_skipped(self, message: str | None = None) -> None:
|
||||
"""
|
||||
Helper to set status to skipped
|
||||
"""Set status to skipped.
|
||||
|
||||
Args:
|
||||
----
|
||||
message: Optional message related to the test
|
||||
|
||||
"""
|
||||
self._set_status("skipped", message)
|
||||
|
||||
def is_error(self, message: str | None = None) -> None:
|
||||
"""
|
||||
Helper to set status to error
|
||||
"""Set status to error.
|
||||
|
||||
Args:
|
||||
----
|
||||
message: Optional message related to the test
|
||||
|
||||
"""
|
||||
self._set_status("error", message)
|
||||
|
||||
def _set_status(self, status: TestStatus, message: str | None = None) -> None:
|
||||
"""
|
||||
Set status and insert optional message
|
||||
"""Set status and insert optional message.
|
||||
|
||||
Args:
|
||||
----
|
||||
status: status of the test
|
||||
message: optional message
|
||||
|
||||
"""
|
||||
self.result = status
|
||||
if message is not None:
|
||||
self.messages.append(message)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
Returns a human readable string of this TestResult
|
||||
"""
|
||||
"""Return a human readable string of this TestResult."""
|
||||
return f"Test '{self.test}' (on '{self.name}'): Result '{self.result}'\nMessages: {self.messages}"
|
||||
|
|
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
|
||||
# that can be found in the LICENSE file.
|
||||
# pylint: disable=too-many-branches
|
||||
"""
|
||||
ANTA runner function
|
||||
"""
|
||||
"""ANTA runner function."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Tuple
|
||||
import os
|
||||
import resource
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from anta import GITHUB_SUGGESTION
|
||||
from anta.catalog import AntaCatalog, AntaTestDefinition
|
||||
from anta.device import AntaDevice
|
||||
from anta.inventory import AntaInventory
|
||||
from anta.logger import anta_log_exception
|
||||
from anta.logger import anta_log_exception, exc_to_str
|
||||
from anta.models import AntaTest
|
||||
from anta.result_manager import ResultManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.inventory import AntaInventory
|
||||
from anta.result_manager import ResultManager
|
||||
|
||||
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:
|
||||
"""
|
||||
Main coroutine to run ANTA.
|
||||
Use this as an entrypoint to the test framwork in your script.
|
||||
def log_cache_statistics(devices: list[AntaDevice]) -> None:
|
||||
"""Log cache statistics for each device in the inventory.
|
||||
|
||||
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.
|
||||
inventory: AntaInventory object that includes the device(s).
|
||||
catalog: AntaCatalog object that includes the list of tests.
|
||||
tags: List of tags to filter devices from the inventory. Defaults to None.
|
||||
established_only: Include only established device(s). Defaults to True.
|
||||
|
||||
Returns:
|
||||
any: ResultManager object gets updated with the test results.
|
||||
devices: devices on which to run tests. None means all devices.
|
||||
tests: tests to run against devices. None means all tests.
|
||||
tags: Tags to filter devices from the inventory.
|
||||
established_only: Include only established device(s).
|
||||
"""
|
||||
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:
|
||||
logger.info("The list of tests is empty, exiting")
|
||||
return
|
||||
if len(inventory) == 0:
|
||||
logger.info("The inventory is empty, exiting")
|
||||
return
|
||||
await inventory.connect_inventory()
|
||||
devices: list[AntaDevice] = list(inventory.get_inventory(established_only=established_only, tags=tags).values())
|
||||
|
||||
if not devices:
|
||||
logger.info(
|
||||
f"No device in the established state '{established_only}' "
|
||||
f"{f'matching the tags {tags} ' if tags else ''}was found. There is no device to run tests against, exiting"
|
||||
)
|
||||
# Filter the inventory based on tags and devices parameters
|
||||
selected_inventory = inventory.get_inventory(
|
||||
tags=tags,
|
||||
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
|
||||
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
|
||||
tests_set: set[AntaTestRunner] = set()
|
||||
for device in devices:
|
||||
selected_tests: set[AntaTestRunner] = set()
|
||||
|
||||
# Create AntaTestRunner tuples from the tags
|
||||
for device in inventory.devices:
|
||||
if tags:
|
||||
# If there are CLI tags, only execute tests with matching tags
|
||||
tests_set.update((test, device) for test in catalog.get_tests_by_tags(tags))
|
||||
selected_tests.update((test, device) for test in catalog.get_tests_by_tags(tags))
|
||||
else:
|
||||
# If there is no CLI tags, execute all tests without filters
|
||||
tests_set.update((t, device) for t in catalog.tests if t.inputs.filters is None or t.inputs.filters.tags is None)
|
||||
# If there is no CLI tags, execute all tests that do not have any filters
|
||||
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
|
||||
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 tests:
|
||||
logger.info(f"There is no tests{f' matching the tags {tags} ' if tags else ' '}to run on current inventory. " "Exiting...")
|
||||
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."
|
||||
logger.warning(msg)
|
||||
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:
|
||||
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"If this is not a custom test implementation: {GITHUB_SUGGESTION}",
|
||||
]
|
||||
],
|
||||
)
|
||||
anta_log_exception(e, message, logger)
|
||||
|
||||
if AntaTest.progress is not None:
|
||||
AntaTest.nrfu_task = AntaTest.progress.add_task("Running NRFU Tests...", total=len(coros))
|
||||
|
||||
logger.info("Running ANTA tests...")
|
||||
test_results = await asyncio.gather(*coros)
|
||||
for r in test_results:
|
||||
manager.add_test_result(r)
|
||||
for device in devices:
|
||||
if device.cache_statistics is not None:
|
||||
logger.info(
|
||||
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}")
|
||||
manager.add(r)
|
||||
|
||||
log_cache_statistics(inventory.devices)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""Module related to all ANTA tests."""
|
||||
|
|
|
@ -1,44 +1,56 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to the EOS various AAA settings
|
||||
"""
|
||||
"""Module related to the EOS various AAA tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
# Need to keep List and Set for pydantic in python 3.8
|
||||
from typing import List, Literal, Set
|
||||
from typing import TYPE_CHECKING, ClassVar, Literal
|
||||
|
||||
from anta.custom_types import AAAAuthMethod
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyTacacsSourceIntf(AntaTest):
|
||||
"""
|
||||
Verifies TACACS source-interface for a specified VRF.
|
||||
"""Verifies TACACS source-interface for a specified VRF.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided TACACS source-interface is configured in the specified VRF.
|
||||
* failure: The test will fail if the provided TACACS source-interface is NOT configured in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided TACACS source-interface is configured in the specified VRF.
|
||||
* Failure: The test will fail if the provided TACACS source-interface is NOT configured in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.aaa:
|
||||
- VerifyTacacsSourceIntf:
|
||||
intf: Management0
|
||||
vrf: MGMT
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTacacsSourceIntf"
|
||||
description = "Verifies TACACS source-interface for a specified VRF."
|
||||
categories = ["aaa"]
|
||||
commands = [AntaCommand(command="show tacacs")]
|
||||
categories: ClassVar[list[str]] = ["aaa"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyTacacsSourceIntf test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
intf: str
|
||||
"""Source-interface to use as source IP of TACACS messages"""
|
||||
"""Source-interface to use as source IP of TACACS messages."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF to transport TACACS messages"""
|
||||
"""The name of the VRF to transport TACACS messages. Defaults to `default`."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTacacsSourceIntf."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
try:
|
||||
if command_output["srcIntf"][self.inputs.vrf] == self.inputs.intf:
|
||||
|
@ -50,27 +62,41 @@ class VerifyTacacsSourceIntf(AntaTest):
|
|||
|
||||
|
||||
class VerifyTacacsServers(AntaTest):
|
||||
"""
|
||||
Verifies TACACS servers are configured for a specified VRF.
|
||||
"""Verifies TACACS servers are configured for a specified VRF.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided TACACS servers are configured in the specified VRF.
|
||||
* failure: The test will fail if the provided TACACS servers are NOT configured in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided TACACS servers are configured in the specified VRF.
|
||||
* Failure: The test will fail if the provided TACACS servers are NOT configured in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.aaa:
|
||||
- VerifyTacacsServers:
|
||||
servers:
|
||||
- 10.10.10.21
|
||||
- 10.10.10.22
|
||||
vrf: MGMT
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTacacsServers"
|
||||
description = "Verifies TACACS servers are configured for a specified VRF."
|
||||
categories = ["aaa"]
|
||||
commands = [AntaCommand(command="show tacacs")]
|
||||
categories: ClassVar[list[str]] = ["aaa"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
servers: List[IPv4Address]
|
||||
"""List of TACACS servers"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyTacacsServers test."""
|
||||
|
||||
servers: list[IPv4Address]
|
||||
"""List of TACACS servers."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF to transport TACACS messages"""
|
||||
"""The name of the VRF to transport TACACS messages. Defaults to `default`."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTacacsServers."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
tacacs_servers = command_output["tacacsServers"]
|
||||
if not tacacs_servers:
|
||||
|
@ -90,25 +116,38 @@ class VerifyTacacsServers(AntaTest):
|
|||
|
||||
|
||||
class VerifyTacacsServerGroups(AntaTest):
|
||||
"""
|
||||
Verifies if the provided TACACS server group(s) are configured.
|
||||
"""Verifies if the provided TACACS server group(s) are configured.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided TACACS server group(s) are configured.
|
||||
* failure: The test will fail if one or all the provided TACACS server group(s) are NOT configured.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided TACACS server group(s) are configured.
|
||||
* Failure: The test will fail if one or all the provided TACACS server group(s) are NOT configured.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.aaa:
|
||||
- VerifyTacacsServerGroups:
|
||||
groups:
|
||||
- TACACS-GROUP1
|
||||
- TACACS-GROUP2
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTacacsServerGroups"
|
||||
description = "Verifies if the provided TACACS server group(s) are configured."
|
||||
categories = ["aaa"]
|
||||
commands = [AntaCommand(command="show tacacs")]
|
||||
categories: ClassVar[list[str]] = ["aaa"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
groups: List[str]
|
||||
"""List of TACACS server group"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyTacacsServerGroups test."""
|
||||
|
||||
groups: list[str]
|
||||
"""List of TACACS server groups."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTacacsServerGroups."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
tacacs_groups = command_output["groups"]
|
||||
if not tacacs_groups:
|
||||
|
@ -122,29 +161,47 @@ class VerifyTacacsServerGroups(AntaTest):
|
|||
|
||||
|
||||
class VerifyAuthenMethods(AntaTest):
|
||||
"""
|
||||
Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x).
|
||||
"""Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided AAA authentication method list is matching in the configured authentication types.
|
||||
* failure: The test will fail if the provided AAA authentication method list is NOT matching in the configured authentication types.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided AAA authentication method list is matching in the configured authentication types.
|
||||
* Failure: The test will fail if the provided AAA authentication method list is NOT matching in the configured authentication types.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.aaa:
|
||||
- VerifyAuthenMethods:
|
||||
methods:
|
||||
- local
|
||||
- none
|
||||
- logging
|
||||
types:
|
||||
- login
|
||||
- enable
|
||||
- dot1x
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAuthenMethods"
|
||||
description = "Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x)."
|
||||
categories = ["aaa"]
|
||||
commands = [AntaCommand(command="show aaa methods authentication")]
|
||||
categories: ClassVar[list[str]] = ["aaa"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authentication", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
methods: List[AAAAuthMethod]
|
||||
"""List of AAA authentication methods. Methods should be in the right order"""
|
||||
types: Set[Literal["login", "enable", "dot1x"]]
|
||||
"""List of authentication types to verify"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyAuthenMethods test."""
|
||||
|
||||
methods: list[AAAAuthMethod]
|
||||
"""List of AAA authentication methods. Methods should be in the right order."""
|
||||
types: set[Literal["login", "enable", "dot1x"]]
|
||||
"""List of authentication types to verify."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAuthenMethods."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
not_matching = []
|
||||
not_matching: list[str] = []
|
||||
for k, v in command_output.items():
|
||||
auth_type = k.replace("AuthenMethods", "")
|
||||
if auth_type not in self.inputs.types:
|
||||
|
@ -157,9 +214,8 @@ class VerifyAuthenMethods(AntaTest):
|
|||
if v["login"]["methods"] != self.inputs.methods:
|
||||
self.result.is_failure(f"AAA authentication methods {self.inputs.methods} are not matching for login console")
|
||||
return
|
||||
for methods in v.values():
|
||||
if methods["methods"] != self.inputs.methods:
|
||||
not_matching.append(auth_type)
|
||||
not_matching.extend(auth_type for methods in v.values() if methods["methods"] != self.inputs.methods)
|
||||
|
||||
if not not_matching:
|
||||
self.result.is_success()
|
||||
else:
|
||||
|
@ -167,37 +223,53 @@ class VerifyAuthenMethods(AntaTest):
|
|||
|
||||
|
||||
class VerifyAuthzMethods(AntaTest):
|
||||
"""
|
||||
Verifies the AAA authorization method lists for different authorization types (commands, exec).
|
||||
"""Verifies the AAA authorization method lists for different authorization types (commands, exec).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided AAA authorization method list is matching in the configured authorization types.
|
||||
* failure: The test will fail if the provided AAA authorization method list is NOT matching in the configured authorization types.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided AAA authorization method list is matching in the configured authorization types.
|
||||
* Failure: The test will fail if the provided AAA authorization method list is NOT matching in the configured authorization types.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.aaa:
|
||||
- VerifyAuthzMethods:
|
||||
methods:
|
||||
- local
|
||||
- none
|
||||
- logging
|
||||
types:
|
||||
- commands
|
||||
- exec
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAuthzMethods"
|
||||
description = "Verifies the AAA authorization method lists for different authorization types (commands, exec)."
|
||||
categories = ["aaa"]
|
||||
commands = [AntaCommand(command="show aaa methods authorization")]
|
||||
categories: ClassVar[list[str]] = ["aaa"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authorization", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
methods: List[AAAAuthMethod]
|
||||
"""List of AAA authorization methods. Methods should be in the right order"""
|
||||
types: Set[Literal["commands", "exec"]]
|
||||
"""List of authorization types to verify"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyAuthzMethods test."""
|
||||
|
||||
methods: list[AAAAuthMethod]
|
||||
"""List of AAA authorization methods. Methods should be in the right order."""
|
||||
types: set[Literal["commands", "exec"]]
|
||||
"""List of authorization types to verify."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAuthzMethods."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
not_matching = []
|
||||
not_matching: list[str] = []
|
||||
for k, v in command_output.items():
|
||||
authz_type = k.replace("AuthzMethods", "")
|
||||
if authz_type not in self.inputs.types:
|
||||
# We do not need to verify this accounting type
|
||||
continue
|
||||
for methods in v.values():
|
||||
if methods["methods"] != self.inputs.methods:
|
||||
not_matching.append(authz_type)
|
||||
not_matching.extend(authz_type for methods in v.values() if methods["methods"] != self.inputs.methods)
|
||||
|
||||
if not not_matching:
|
||||
self.result.is_success()
|
||||
else:
|
||||
|
@ -205,27 +277,46 @@ class VerifyAuthzMethods(AntaTest):
|
|||
|
||||
|
||||
class VerifyAcctDefaultMethods(AntaTest):
|
||||
"""
|
||||
Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x).
|
||||
"""Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided AAA accounting default method list is matching in the configured accounting types.
|
||||
* failure: The test will fail if the provided AAA accounting default method list is NOT matching in the configured accounting types.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided AAA accounting default method list is matching in the configured accounting types.
|
||||
* Failure: The test will fail if the provided AAA accounting default method list is NOT matching in the configured accounting types.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.aaa:
|
||||
- VerifyAcctDefaultMethods:
|
||||
methods:
|
||||
- local
|
||||
- none
|
||||
- logging
|
||||
types:
|
||||
- system
|
||||
- exec
|
||||
- commands
|
||||
- dot1x
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAcctDefaultMethods"
|
||||
description = "Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x)."
|
||||
categories = ["aaa"]
|
||||
commands = [AntaCommand(command="show aaa methods accounting")]
|
||||
categories: ClassVar[list[str]] = ["aaa"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
methods: List[AAAAuthMethod]
|
||||
"""List of AAA accounting methods. Methods should be in the right order"""
|
||||
types: Set[Literal["commands", "exec", "system", "dot1x"]]
|
||||
"""List of accounting types to verify"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyAcctDefaultMethods test."""
|
||||
|
||||
methods: list[AAAAuthMethod]
|
||||
"""List of AAA accounting methods. Methods should be in the right order."""
|
||||
types: set[Literal["commands", "exec", "system", "dot1x"]]
|
||||
"""List of accounting types to verify."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAcctDefaultMethods."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
not_matching = []
|
||||
not_configured = []
|
||||
|
@ -249,27 +340,46 @@ class VerifyAcctDefaultMethods(AntaTest):
|
|||
|
||||
|
||||
class VerifyAcctConsoleMethods(AntaTest):
|
||||
"""
|
||||
Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x).
|
||||
"""Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided AAA accounting console method list is matching in the configured accounting types.
|
||||
* failure: The test will fail if the provided AAA accounting console method list is NOT matching in the configured accounting types.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided AAA accounting console method list is matching in the configured accounting types.
|
||||
* Failure: The test will fail if the provided AAA accounting console method list is NOT matching in the configured accounting types.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.aaa:
|
||||
- VerifyAcctConsoleMethods:
|
||||
methods:
|
||||
- local
|
||||
- none
|
||||
- logging
|
||||
types:
|
||||
- system
|
||||
- exec
|
||||
- commands
|
||||
- dot1x
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAcctConsoleMethods"
|
||||
description = "Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x)."
|
||||
categories = ["aaa"]
|
||||
commands = [AntaCommand(command="show aaa methods accounting")]
|
||||
categories: ClassVar[list[str]] = ["aaa"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
methods: List[AAAAuthMethod]
|
||||
"""List of AAA accounting console methods. Methods should be in the right order"""
|
||||
types: Set[Literal["commands", "exec", "system", "dot1x"]]
|
||||
"""List of accounting console types to verify"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyAcctConsoleMethods test."""
|
||||
|
||||
methods: list[AAAAuthMethod]
|
||||
"""List of AAA accounting console methods. Methods should be in the right order."""
|
||||
types: set[Literal["commands", "exec", "system", "dot1x"]]
|
||||
"""List of accounting console types to verify."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAcctConsoleMethods."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
not_matching = []
|
||||
not_configured = []
|
||||
|
|
|
@ -1,65 +1,80 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
BFD test functions
|
||||
"""
|
||||
"""Module related to BFD tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from ipaddress import IPv4Address
|
||||
from typing import Any, List, Optional
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from anta.custom_types import BfdInterval, BfdMultiplier
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
from anta.tools.get_value import get_value
|
||||
from anta.tools import get_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyBFDSpecificPeers(AntaTest):
|
||||
"""
|
||||
This class verifies if the IPv4 BFD peer's sessions are UP and remote disc is non-zero in the specified VRF.
|
||||
"""Verifies if the IPv4 BFD peer's sessions are UP and remote disc is non-zero in the specified VRF.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.bfd:
|
||||
- VerifyBFDSpecificPeers:
|
||||
bfd_peers:
|
||||
- peer_address: 192.0.255.8
|
||||
vrf: default
|
||||
- peer_address: 192.0.255.7
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyBFDSpecificPeers"
|
||||
description = "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF."
|
||||
categories = ["bfd"]
|
||||
commands = [AntaCommand(command="show bfd peers")]
|
||||
categories: ClassVar[list[str]] = ["bfd"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers", revision=4)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""
|
||||
This class defines the input parameters of the test case.
|
||||
"""
|
||||
"""Input model for the VerifyBFDSpecificPeers test."""
|
||||
|
||||
bfd_peers: List[BFDPeers]
|
||||
"""List of IPv4 BFD peers"""
|
||||
bfd_peers: list[BFDPeer]
|
||||
"""List of IPv4 BFD peers."""
|
||||
|
||||
class BFDPeers(BaseModel):
|
||||
"""
|
||||
This class defines the details of an IPv4 BFD peer.
|
||||
"""
|
||||
class BFDPeer(BaseModel):
|
||||
"""Model for an IPv4 BFD peer."""
|
||||
|
||||
peer_address: IPv4Address
|
||||
"""IPv4 address of a BFD peer"""
|
||||
"""IPv4 address of a BFD peer."""
|
||||
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
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyBFDSpecificPeers."""
|
||||
failures: dict[Any, Any] = {}
|
||||
|
||||
# Iterating over BFD peers
|
||||
for bfd_peer in self.inputs.bfd_peers:
|
||||
peer = str(bfd_peer.peer_address)
|
||||
vrf = bfd_peer.vrf
|
||||
bfd_output = get_value(self.instance_commands[0].json_output, f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..", separator="..")
|
||||
bfd_output = get_value(
|
||||
self.instance_commands[0].json_output,
|
||||
f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..",
|
||||
separator="..",
|
||||
)
|
||||
|
||||
# Check if BFD peer configured
|
||||
if not bfd_output:
|
||||
|
@ -68,7 +83,12 @@ class VerifyBFDSpecificPeers(AntaTest):
|
|||
|
||||
# Check BFD peer status and remote disc
|
||||
if not (bfd_output.get("status") == "up" and bfd_output.get("remoteDisc") != 0):
|
||||
failures[peer] = {vrf: {"status": bfd_output.get("status"), "remote_disc": bfd_output.get("remoteDisc")}}
|
||||
failures[peer] = {
|
||||
vrf: {
|
||||
"status": bfd_output.get("status"),
|
||||
"remote_disc": bfd_output.get("remoteDisc"),
|
||||
}
|
||||
}
|
||||
|
||||
if not failures:
|
||||
self.result.is_success()
|
||||
|
@ -77,45 +97,60 @@ class VerifyBFDSpecificPeers(AntaTest):
|
|||
|
||||
|
||||
class VerifyBFDPeersIntervals(AntaTest):
|
||||
"""
|
||||
This class verifies the timers of the IPv4 BFD peers in the specified VRF.
|
||||
"""Verifies the timers of the IPv4 BFD peers in the specified VRF.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.bfd:
|
||||
- VerifyBFDPeersIntervals:
|
||||
bfd_peers:
|
||||
- peer_address: 192.0.255.8
|
||||
vrf: default
|
||||
tx_interval: 1200
|
||||
rx_interval: 1200
|
||||
multiplier: 3
|
||||
- peer_address: 192.0.255.7
|
||||
vrf: default
|
||||
tx_interval: 1200
|
||||
rx_interval: 1200
|
||||
multiplier: 3
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyBFDPeersIntervals"
|
||||
description = "Verifies the timers of the IPv4 BFD peers in the specified VRF."
|
||||
categories = ["bfd"]
|
||||
commands = [AntaCommand(command="show bfd peers detail")]
|
||||
categories: ClassVar[list[str]] = ["bfd"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=4)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""
|
||||
This class defines the input parameters of the test case.
|
||||
"""
|
||||
"""Input model for the VerifyBFDPeersIntervals test."""
|
||||
|
||||
bfd_peers: List[BFDPeers]
|
||||
"""List of BFD peers"""
|
||||
bfd_peers: list[BFDPeer]
|
||||
"""List of BFD peers."""
|
||||
|
||||
class BFDPeers(BaseModel):
|
||||
"""
|
||||
This class defines the details of an IPv4 BFD peer.
|
||||
"""
|
||||
class BFDPeer(BaseModel):
|
||||
"""Model for an IPv4 BFD peer."""
|
||||
|
||||
peer_address: IPv4Address
|
||||
"""IPv4 address of a BFD peer"""
|
||||
"""IPv4 address of a BFD peer."""
|
||||
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 of BFD peer in milliseconds"""
|
||||
"""Tx interval of BFD peer in milliseconds."""
|
||||
rx_interval: BfdInterval
|
||||
"""Rx interval of BFD peer in milliseconds"""
|
||||
"""Rx interval of BFD peer in milliseconds."""
|
||||
multiplier: BfdMultiplier
|
||||
"""Multiplier of BFD peer"""
|
||||
"""Multiplier of BFD peer."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyBFDPeersIntervals."""
|
||||
failures: dict[Any, Any] = {}
|
||||
|
||||
# Iterating over BFD peers
|
||||
|
@ -127,7 +162,11 @@ class VerifyBFDPeersIntervals(AntaTest):
|
|||
tx_interval = bfd_peers.tx_interval * 1000
|
||||
rx_interval = bfd_peers.rx_interval * 1000
|
||||
multiplier = bfd_peers.multiplier
|
||||
bfd_output = get_value(self.instance_commands[0].json_output, f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..", separator="..")
|
||||
bfd_output = get_value(
|
||||
self.instance_commands[0].json_output,
|
||||
f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..",
|
||||
separator="..",
|
||||
)
|
||||
|
||||
# Check if BFD peer configured
|
||||
if not bfd_output:
|
||||
|
@ -157,35 +196,46 @@ class VerifyBFDPeersIntervals(AntaTest):
|
|||
|
||||
|
||||
class VerifyBFDPeersHealth(AntaTest):
|
||||
"""
|
||||
This class verifies the health of IPv4 BFD peers across all VRFs.
|
||||
"""Verifies the health of IPv4 BFD peers across all VRFs.
|
||||
|
||||
It checks that no BFD peer is in the down state and that the discriminator value of the remote system is not zero.
|
||||
|
||||
Optionally, it can also verify that BFD peers have not been down before a specified threshold of hours.
|
||||
|
||||
Expected results:
|
||||
* Success: The test will pass if all IPv4 BFD peers are up, the discriminator value of each remote system is non-zero,
|
||||
and the last downtime of each peer is above the defined threshold.
|
||||
* Failure: The test will fail if any IPv4 BFD peer is down, the discriminator value of any remote system is zero,
|
||||
or the last downtime of any peer is below the defined threshold.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all IPv4 BFD peers are up, the discriminator value of each remote system is non-zero,
|
||||
and the last downtime of each peer is above the defined threshold.
|
||||
* Failure: The test will fail if any IPv4 BFD peer is down, the discriminator value of any remote system is zero,
|
||||
or the last downtime of any peer is below the defined threshold.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.bfd:
|
||||
- VerifyBFDPeersHealth:
|
||||
down_threshold: 2
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyBFDPeersHealth"
|
||||
description = "Verifies the health of all IPv4 BFD peers."
|
||||
categories = ["bfd"]
|
||||
categories: ClassVar[list[str]] = ["bfd"]
|
||||
# revision 1 as later revision introduces additional nesting for type
|
||||
commands = [AntaCommand(command="show bfd peers", revision=1), AntaCommand(command="show clock")]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaCommand(command="show bfd peers", revision=1),
|
||||
AntaCommand(command="show clock", revision=1),
|
||||
]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""
|
||||
This class defines the input parameters of the test case.
|
||||
"""
|
||||
"""Input model for the VerifyBFDPeersHealth test."""
|
||||
|
||||
down_threshold: Optional[int] = Field(default=None, gt=0)
|
||||
down_threshold: int | None = Field(default=None, gt=0)
|
||||
"""Optional down threshold in hours to check if a BFD peer was down before those hours or not."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyBFDPeersHealth."""
|
||||
# Initialize failure strings
|
||||
down_failures = []
|
||||
up_failures = []
|
||||
|
@ -212,7 +262,9 @@ class VerifyBFDPeersHealth(AntaTest):
|
|||
remote_disc = peer_data["remoteDisc"]
|
||||
remote_disc_info = f" with remote disc {remote_disc}" if remote_disc == 0 else ""
|
||||
last_down = peer_data["lastDown"]
|
||||
hours_difference = (datetime.fromtimestamp(current_timestamp) - datetime.fromtimestamp(last_down)).total_seconds() / 3600
|
||||
hours_difference = (
|
||||
datetime.fromtimestamp(current_timestamp, tz=timezone.utc) - datetime.fromtimestamp(last_down, tz=timezone.utc)
|
||||
).total_seconds() / 3600
|
||||
|
||||
# Check if peer status is not up
|
||||
if peer_status != "up":
|
||||
|
|
|
@ -1,30 +1,45 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to the device configuration
|
||||
"""
|
||||
"""Module related to the device configuration tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyZeroTouch(AntaTest):
|
||||
"""
|
||||
Verifies ZeroTouch is disabled
|
||||
"""Verifies ZeroTouch is disabled.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if ZeroTouch is disabled.
|
||||
* Failure: The test will fail if ZeroTouch is enabled.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.configuration:
|
||||
- VerifyZeroTouch:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyZeroTouch"
|
||||
description = "Verifies ZeroTouch is disabled"
|
||||
categories = ["configuration"]
|
||||
commands = [AntaCommand(command="show zerotouch")]
|
||||
categories: ClassVar[list[str]] = ["configuration"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show zerotouch", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
command_output = self.instance_commands[0].output
|
||||
assert isinstance(command_output, dict)
|
||||
"""Main test function for VerifyZeroTouch."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["mode"] == "disabled":
|
||||
self.result.is_success()
|
||||
else:
|
||||
|
@ -32,20 +47,31 @@ class VerifyZeroTouch(AntaTest):
|
|||
|
||||
|
||||
class VerifyRunningConfigDiffs(AntaTest):
|
||||
"""
|
||||
Verifies there is no difference between the running-config and the startup-config
|
||||
"""Verifies there is no difference between the running-config and the startup-config.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if there is no difference between the running-config and the startup-config.
|
||||
* Failure: The test will fail if there is a difference between the running-config and the startup-config.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.configuration:
|
||||
- VerifyRunningConfigDiffs:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyRunningConfigDiffs"
|
||||
description = "Verifies there is no difference between the running-config and the startup-config"
|
||||
categories = ["configuration"]
|
||||
commands = [AntaCommand(command="show running-config diffs", ofmt="text")]
|
||||
categories: ClassVar[list[str]] = ["configuration"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config diffs", ofmt="text")]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
command_output = self.instance_commands[0].output
|
||||
if command_output is None or command_output == "":
|
||||
"""Main test function for VerifyRunningConfigDiffs."""
|
||||
command_output = self.instance_commands[0].text_output
|
||||
if command_output == "":
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure()
|
||||
self.result.is_failure(str(command_output))
|
||||
self.result.is_failure(command_output)
|
||||
|
|
|
@ -1,67 +1,79 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to various connectivity checks
|
||||
"""
|
||||
"""Module related to various connectivity tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
# Need to keep List for pydantic in python 3.8
|
||||
from typing import List, Union
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from anta.custom_types import Interface
|
||||
from anta.models import AntaCommand, AntaMissingParamException, AntaTemplate, AntaTest
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
|
||||
|
||||
class VerifyReachability(AntaTest):
|
||||
"""
|
||||
Test network reachability to one or many destination IP(s).
|
||||
"""Test network reachability to one or many destination IP(s).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if all destination IP(s) are reachable.
|
||||
* failure: The test will fail if one or many destination IP(s) are unreachable.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all destination IP(s) are reachable.
|
||||
* Failure: The test will fail if one or many destination IP(s) are unreachable.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.connectivity:
|
||||
- VerifyReachability:
|
||||
hosts:
|
||||
- source: Management0
|
||||
destination: 1.1.1.1
|
||||
vrf: MGMT
|
||||
- source: Management0
|
||||
destination: 8.8.8.8
|
||||
vrf: MGMT
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyReachability"
|
||||
description = "Test the network reachability to one or many destination IP(s)."
|
||||
categories = ["connectivity"]
|
||||
commands = [AntaTemplate(template="ping vrf {vrf} {destination} source {source} repeat {repeat}")]
|
||||
categories: ClassVar[list[str]] = ["connectivity"]
|
||||
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
|
||||
hosts: List[Host]
|
||||
"""List of hosts to ping"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyReachability test."""
|
||||
|
||||
hosts: list[Host]
|
||||
"""List of host to ping."""
|
||||
|
||||
class Host(BaseModel):
|
||||
"""Remote host to ping"""
|
||||
"""Model for a remote host to ping."""
|
||||
|
||||
destination: IPv4Address
|
||||
"""IPv4 address to ping"""
|
||||
source: Union[IPv4Address, Interface]
|
||||
"""IPv4 address source IP or Egress interface to use"""
|
||||
"""IPv4 address to ping."""
|
||||
source: IPv4Address | Interface
|
||||
"""IPv4 address source IP or egress interface to use."""
|
||||
vrf: str = "default"
|
||||
"""VRF context"""
|
||||
"""VRF context. Defaults to `default`."""
|
||||
repeat: int = 2
|
||||
"""Number of ping repetition (default=2)"""
|
||||
"""Number of ping repetition. Defaults to 2."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render the template for each host in the input list."""
|
||||
return [template.render(destination=host.destination, source=host.source, vrf=host.vrf, repeat=host.repeat) for host in self.inputs.hosts]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyReachability."""
|
||||
failures = []
|
||||
for command in self.instance_commands:
|
||||
src = command.params.get("source")
|
||||
dst = command.params.get("destination")
|
||||
repeat = command.params.get("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}")
|
||||
src = command.params.source
|
||||
dst = command.params.destination
|
||||
repeat = command.params.repeat
|
||||
|
||||
if f"{repeat} received" not in command.json_output["messages"][0]:
|
||||
failures.append((str(src), str(dst)))
|
||||
|
@ -73,53 +85,84 @@ class VerifyReachability(AntaTest):
|
|||
|
||||
|
||||
class VerifyLLDPNeighbors(AntaTest):
|
||||
"""
|
||||
This test verifies that the provided LLDP neighbors are present and connected with the correct configuration.
|
||||
"""Verifies that the provided LLDP neighbors are present and connected with the correct configuration.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if each of the provided LLDP neighbors is present and connected to the specified port and device.
|
||||
* failure: The test will fail if any of the following conditions are met:
|
||||
- The provided LLDP neighbor is not found.
|
||||
- The system name or port of the LLDP neighbor does not match the provided information.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if each of the provided LLDP neighbors is present and connected to the specified port and device.
|
||||
* Failure: The test will fail if any of the following conditions are met:
|
||||
- The provided LLDP neighbor is not found.
|
||||
- The system name or port of the LLDP neighbor does not match the provided information.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.connectivity:
|
||||
- VerifyLLDPNeighbors:
|
||||
neighbors:
|
||||
- port: Ethernet1
|
||||
neighbor_device: DC1-SPINE1
|
||||
neighbor_port: Ethernet1
|
||||
- port: Ethernet2
|
||||
neighbor_device: DC1-SPINE2
|
||||
neighbor_port: Ethernet1
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLLDPNeighbors"
|
||||
description = "Verifies that the provided LLDP neighbors are connected properly."
|
||||
categories = ["connectivity"]
|
||||
commands = [AntaCommand(command="show lldp neighbors detail")]
|
||||
categories: ClassVar[list[str]] = ["connectivity"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lldp neighbors detail", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
neighbors: List[Neighbor]
|
||||
"""List of LLDP neighbors"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyLLDPNeighbors test."""
|
||||
|
||||
neighbors: list[Neighbor]
|
||||
"""List of LLDP neighbors."""
|
||||
|
||||
class Neighbor(BaseModel):
|
||||
"""LLDP neighbor"""
|
||||
"""Model for an LLDP neighbor."""
|
||||
|
||||
port: Interface
|
||||
"""LLDP port"""
|
||||
"""LLDP port."""
|
||||
neighbor_device: str
|
||||
"""LLDP neighbor device"""
|
||||
"""LLDP neighbor device."""
|
||||
neighbor_port: Interface
|
||||
"""LLDP neighbor port"""
|
||||
"""LLDP neighbor port."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
"""Main test function for VerifyLLDPNeighbors."""
|
||||
failures: dict[str, list[str]] = {}
|
||||
|
||||
output = self.instance_commands[0].json_output["lldpNeighbors"]
|
||||
|
||||
for neighbor in self.inputs.neighbors:
|
||||
if neighbor.port not in command_output["lldpNeighbors"]:
|
||||
failures.setdefault("port_not_configured", []).append(neighbor.port)
|
||||
elif len(lldp_neighbor_info := command_output["lldpNeighbors"][neighbor.port]["lldpNeighborInfo"]) == 0:
|
||||
failures.setdefault("no_lldp_neighbor", []).append(neighbor.port)
|
||||
elif (
|
||||
lldp_neighbor_info[0]["systemName"] != neighbor.neighbor_device
|
||||
or lldp_neighbor_info[0]["neighborInterfaceInfo"]["interfaceId_v2"] != neighbor.neighbor_port
|
||||
if neighbor.port not in output:
|
||||
failures.setdefault("Port(s) not configured", []).append(neighbor.port)
|
||||
continue
|
||||
|
||||
if len(lldp_neighbor_info := output[neighbor.port]["lldpNeighborInfo"]) == 0:
|
||||
failures.setdefault("No LLDP neighbor(s) on port(s)", []).append(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:
|
||||
self.result.is_success()
|
||||
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.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions to flag field notices
|
||||
"""
|
||||
"""Module related to field notices tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.decorators import skip_on_platforms
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyFieldNotice44Resolution(AntaTest):
|
||||
"""
|
||||
Verifies the device is using an Aboot version that fix the bug discussed
|
||||
in the field notice 44 (Aboot manages system settings prior to EOS initialization).
|
||||
"""Verifies if the device is using an Aboot version that fixes the bug discussed in the Field Notice 44.
|
||||
|
||||
https://www.arista.com/en/support/advisories-notices/field-notice/8756-field-notice-44
|
||||
Aboot manages system settings prior to EOS initialization.
|
||||
|
||||
Reference: https://www.arista.com/en/support/advisories-notices/field-notice/8756-field-notice-44
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the device is using an Aboot version that fixes the bug discussed in the Field Notice 44.
|
||||
* Failure: The test will fail if the device is not using an Aboot version that fixes the bug discussed in the Field Notice 44.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.field_notices:
|
||||
- VerifyFieldNotice44Resolution:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyFieldNotice44Resolution"
|
||||
description = (
|
||||
"Verifies the device is using an Aboot version that fix the bug discussed in the field notice 44 (Aboot manages system settings prior to EOS initialization)"
|
||||
)
|
||||
categories = ["field notices", "software"]
|
||||
commands = [AntaCommand(command="show version detail")]
|
||||
description = "Verifies that the device is using the correct Aboot version per FN0044."
|
||||
categories: ClassVar[list[str]] = ["field notices"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)]
|
||||
|
||||
# TODO maybe implement ONLY ON PLATFORMS instead
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyFieldNotice44Resolution."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
devices = [
|
||||
|
@ -79,7 +94,6 @@ class VerifyFieldNotice44Resolution(AntaTest):
|
|||
variants = ["-SSD-F", "-SSD-R", "-M-F", "-M-R", "-F", "-R"]
|
||||
|
||||
model = command_output["modelName"]
|
||||
# TODO this list could be a regex
|
||||
for variant in variants:
|
||||
model = model.replace(variant, "")
|
||||
if model not in devices:
|
||||
|
@ -90,32 +104,49 @@ class VerifyFieldNotice44Resolution(AntaTest):
|
|||
if component["name"] == "Aboot":
|
||||
aboot_version = component["version"].split("-")[2]
|
||||
self.result.is_success()
|
||||
if aboot_version.startswith("4.0.") and int(aboot_version.split(".")[2]) < 7:
|
||||
self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
|
||||
elif aboot_version.startswith("4.1.") and int(aboot_version.split(".")[2]) < 1:
|
||||
self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
|
||||
elif aboot_version.startswith("6.0.") and int(aboot_version.split(".")[2]) < 9:
|
||||
self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
|
||||
elif aboot_version.startswith("6.1.") and int(aboot_version.split(".")[2]) < 7:
|
||||
incorrect_aboot_version = (
|
||||
aboot_version.startswith("4.0.")
|
||||
and int(aboot_version.split(".")[2]) < 7
|
||||
or aboot_version.startswith("4.1.")
|
||||
and int(aboot_version.split(".")[2]) < 1
|
||||
or (
|
||||
aboot_version.startswith("6.0.")
|
||||
and int(aboot_version.split(".")[2]) < 9
|
||||
or aboot_version.startswith("6.1.")
|
||||
and int(aboot_version.split(".")[2]) < 7
|
||||
)
|
||||
)
|
||||
if incorrect_aboot_version:
|
||||
self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
|
||||
|
||||
|
||||
class VerifyFieldNotice72Resolution(AntaTest):
|
||||
"""
|
||||
Checks if the device is potentially exposed to Field Notice 72, and if the issue has been mitigated.
|
||||
"""Verifies if the device is potentially exposed to Field Notice 72, and if the issue has been mitigated.
|
||||
|
||||
https://www.arista.com/en/support/advisories-notices/field-notice/17410-field-notice-0072
|
||||
Reference: https://www.arista.com/en/support/advisories-notices/field-notice/17410-field-notice-0072
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the device is not exposed to FN72 and the issue has been mitigated.
|
||||
* Failure: The test will fail if the device is exposed to FN72 and the issue has not been mitigated.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.field_notices:
|
||||
- VerifyFieldNotice72Resolution:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyFieldNotice72Resolution"
|
||||
description = "Verifies if the device has exposeure to FN72, and if the issue has been mitigated"
|
||||
categories = ["field notices", "software"]
|
||||
commands = [AntaCommand(command="show version detail")]
|
||||
description = "Verifies if the device is exposed to FN0072, and if the issue has been mitigated."
|
||||
categories: ClassVar[list[str]] = ["field notices"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)]
|
||||
|
||||
# TODO maybe implement ONLY ON PLATFORMS instead
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyFieldNotice72Resolution."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
devices = ["DCS-7280SR3-48YC8", "DCS-7280SR3K-48YC8"]
|
||||
|
@ -151,8 +182,7 @@ class VerifyFieldNotice72Resolution(AntaTest):
|
|||
self.result.is_skipped("Device not exposed")
|
||||
return
|
||||
|
||||
# Because each of the if checks above will return if taken, we only run the long
|
||||
# check if we get this far
|
||||
# Because each of the if checks above will return if taken, we only run the long check if we get this far
|
||||
for entry in command_output["details"]["components"]:
|
||||
if entry["name"] == "FixedSystemvrm1":
|
||||
if int(entry["version"]) < 7:
|
||||
|
@ -161,5 +191,5 @@ class VerifyFieldNotice72Resolution(AntaTest):
|
|||
self.result.is_success("FN72 is mitigated")
|
||||
return
|
||||
# We should never hit this point
|
||||
self.result.is_error(message="Error in running test - FixedSystemvrm1 not found")
|
||||
self.result.is_error("Error in running test - FixedSystemvrm1 not found")
|
||||
return
|
||||
|
|
|
@ -1,60 +1,79 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to GreenT (Postcard Telemetry) in EOS
|
||||
"""
|
||||
"""Module related to GreenT (Postcard Telemetry) tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyGreenTCounters(AntaTest):
|
||||
"""
|
||||
Verifies whether GRE packets are sent.
|
||||
"""Verifies if the GreenT (GRE Encapsulated Telemetry) counters are incremented.
|
||||
|
||||
Expected Results:
|
||||
* success: if >0 gre packets are sent
|
||||
* failure: if no gre packets are sent
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the GreenT counters are incremented.
|
||||
* Failure: The test will fail if the GreenT counters are not incremented.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.greent:
|
||||
- VerifyGreenT:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyGreenTCounters"
|
||||
description = "Verifies if the greent counters are incremented."
|
||||
categories = ["greent"]
|
||||
commands = [AntaCommand(command="show monitor telemetry postcard counters")]
|
||||
description = "Verifies if the GreenT counters are incremented."
|
||||
categories: ClassVar[list[str]] = ["greent"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard counters", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyGreenTCounters."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
if command_output["grePktSent"] > 0:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure("GRE packets are not sent")
|
||||
self.result.is_failure("GreenT counters are not incremented")
|
||||
|
||||
|
||||
class VerifyGreenT(AntaTest):
|
||||
"""
|
||||
Verifies whether GreenT policy is created.
|
||||
"""Verifies if a GreenT (GRE Encapsulated Telemetry) policy other than the default is created.
|
||||
|
||||
Expected Results:
|
||||
* success: if there exists any policy other than "default" policy.
|
||||
* failure: if no policy is created.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if a GreenT policy is created other than the default one.
|
||||
* Failure: The test will fail if no other GreenT policy is created.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.greent:
|
||||
- VerifyGreenTCounters:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyGreenT"
|
||||
description = "Verifies whether greent policy is created."
|
||||
categories = ["greent"]
|
||||
commands = [AntaCommand(command="show monitor telemetry postcard policy profile")]
|
||||
description = "Verifies if a GreenT policy is created."
|
||||
categories: ClassVar[list[str]] = ["greent"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard policy profile", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyGreenT."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
out = [f"{i} policy is created" for i in command_output["profiles"].keys() if "default" not in i]
|
||||
profiles = [profile for profile in command_output["profiles"] if profile != "default"]
|
||||
|
||||
if len(out) > 0:
|
||||
for i in out:
|
||||
self.result.is_success(f"{i} policy is created")
|
||||
if profiles:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure("policy is not created")
|
||||
self.result.is_failure("No GreenT policy is created")
|
||||
|
|
|
@ -1,41 +1,56 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to the hardware or environment
|
||||
"""
|
||||
"""Module related to the hardware or environment tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
# Need to keep List for pydantic in python 3.8
|
||||
from typing import List
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.decorators import skip_on_platforms
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyTransceiversManufacturers(AntaTest):
|
||||
"""
|
||||
This test verifies if all the transceivers come from approved manufacturers.
|
||||
"""Verifies if all the transceivers come from approved manufacturers.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if all transceivers are from approved manufacturers.
|
||||
* failure: The test will fail if some transceivers are from unapproved manufacturers.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all transceivers are from approved manufacturers.
|
||||
* Failure: The test will fail if some transceivers are from unapproved manufacturers.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.hardware:
|
||||
- VerifyTransceiversManufacturers:
|
||||
manufacturers:
|
||||
- Not Present
|
||||
- Arista Networks
|
||||
- Arastra, Inc.
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTransceiversManufacturers"
|
||||
description = "Verifies if all transceivers come from approved manufacturers."
|
||||
categories = ["hardware"]
|
||||
commands = [AntaCommand(command="show inventory", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["hardware"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show inventory", revision=2)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
manufacturers: List[str]
|
||||
"""List of approved transceivers manufacturers"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyTransceiversManufacturers test."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
manufacturers: list[str]
|
||||
"""List of approved transceivers manufacturers."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTransceiversManufacturers."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
wrong_manufacturers = {
|
||||
interface: value["mfgName"] for interface, value in command_output["xcvrSlots"].items() if value["mfgName"] not in self.inputs.manufacturers
|
||||
|
@ -47,24 +62,32 @@ class VerifyTransceiversManufacturers(AntaTest):
|
|||
|
||||
|
||||
class VerifyTemperature(AntaTest):
|
||||
"""
|
||||
This test verifies if the device temperature is within acceptable limits.
|
||||
"""Verifies if the device temperature is within acceptable limits.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the device temperature is currently OK: 'temperatureOk'.
|
||||
* failure: The test will fail if the device temperature is NOT OK.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the device temperature is currently OK: 'temperatureOk'.
|
||||
* Failure: The test will fail if the device temperature is NOT OK.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.hardware:
|
||||
- VerifyTemperature:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTemperature"
|
||||
description = "Verifies the device temperature."
|
||||
categories = ["hardware"]
|
||||
commands = [AntaCommand(command="show system environment temperature", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["hardware"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature", revision=1)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTemperature."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
temperature_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else ""
|
||||
temperature_status = command_output.get("systemStatus", "")
|
||||
if temperature_status == "temperatureOk":
|
||||
self.result.is_success()
|
||||
else:
|
||||
|
@ -72,24 +95,32 @@ class VerifyTemperature(AntaTest):
|
|||
|
||||
|
||||
class VerifyTransceiversTemperature(AntaTest):
|
||||
"""
|
||||
This test verifies if all the transceivers are operating at an acceptable temperature.
|
||||
"""Verifies if all the transceivers are operating at an acceptable temperature.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if all transceivers status are OK: 'ok'.
|
||||
* failure: The test will fail if some transceivers are NOT OK.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all transceivers status are OK: 'ok'.
|
||||
* Failure: The test will fail if some transceivers are NOT OK.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.hardware:
|
||||
- VerifyTransceiversTemperature:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTransceiversTemperature"
|
||||
description = "Verifies the transceivers temperature."
|
||||
categories = ["hardware"]
|
||||
commands = [AntaCommand(command="show system environment temperature transceiver", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["hardware"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature transceiver", revision=1)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTransceiversTemperature."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
sensors = command_output["tempSensors"] if "tempSensors" in command_output.keys() else ""
|
||||
sensors = command_output.get("tempSensors", "")
|
||||
wrong_sensors = {
|
||||
sensor["name"]: {
|
||||
"hwStatus": sensor["hwStatus"],
|
||||
|
@ -105,50 +136,70 @@ class VerifyTransceiversTemperature(AntaTest):
|
|||
|
||||
|
||||
class VerifyEnvironmentSystemCooling(AntaTest):
|
||||
"""
|
||||
This test verifies the device's system cooling.
|
||||
"""Verifies the device's system cooling status.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the system cooling status is OK: 'coolingOk'.
|
||||
* failure: The test will fail if the system cooling status is NOT OK.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the system cooling status is OK: 'coolingOk'.
|
||||
* Failure: The test will fail if the system cooling status is NOT OK.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.hardware:
|
||||
- VerifyEnvironmentSystemCooling:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyEnvironmentSystemCooling"
|
||||
description = "Verifies the system cooling status."
|
||||
categories = ["hardware"]
|
||||
commands = [AntaCommand(command="show system environment cooling", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["hardware"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", revision=1)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyEnvironmentSystemCooling."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
sys_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else ""
|
||||
sys_status = command_output.get("systemStatus", "")
|
||||
self.result.is_success()
|
||||
if sys_status != "coolingOk":
|
||||
self.result.is_failure(f"Device system cooling is not OK: '{sys_status}'")
|
||||
|
||||
|
||||
class VerifyEnvironmentCooling(AntaTest):
|
||||
"""
|
||||
This test verifies the fans status.
|
||||
"""Verifies the status of power supply fans and all fan trays.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the fans status are within the accepted states list.
|
||||
* failure: The test will fail if some fans status is not within the accepted states list.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the fans status are within the accepted states list.
|
||||
* Failure: The test will fail if some fans status is not within the accepted states list.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.hardware:
|
||||
- VerifyEnvironmentCooling:
|
||||
states:
|
||||
- ok
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyEnvironmentCooling"
|
||||
description = "Verifies the status of power supply fans and all fan trays."
|
||||
categories = ["hardware"]
|
||||
commands = [AntaCommand(command="show system environment cooling", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["hardware"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
states: List[str]
|
||||
"""Accepted states list for fan status"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyEnvironmentCooling test."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
states: list[str]
|
||||
"""List of accepted states of fan status."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyEnvironmentCooling."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
self.result.is_success()
|
||||
# First go through power supplies fans
|
||||
|
@ -164,28 +215,40 @@ class VerifyEnvironmentCooling(AntaTest):
|
|||
|
||||
|
||||
class VerifyEnvironmentPower(AntaTest):
|
||||
"""
|
||||
This test verifies the power supplies status.
|
||||
"""Verifies the power supplies status.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the power supplies status are within the accepted states list.
|
||||
* failure: The test will fail if some power supplies status is not within the accepted states list.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the power supplies status are within the accepted states list.
|
||||
* Failure: The test will fail if some power supplies status is not within the accepted states list.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.hardware:
|
||||
- VerifyEnvironmentPower:
|
||||
states:
|
||||
- ok
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyEnvironmentPower"
|
||||
description = "Verifies the power supplies status."
|
||||
categories = ["hardware"]
|
||||
commands = [AntaCommand(command="show system environment power", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["hardware"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment power", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
states: List[str]
|
||||
"""Accepted states list for power supplies status"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyEnvironmentPower test."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
states: list[str]
|
||||
"""List of accepted states list of power supplies status."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyEnvironmentPower."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
power_supplies = command_output["powerSupplies"] if "powerSupplies" in command_output.keys() else "{}"
|
||||
power_supplies = command_output.get("powerSupplies", "{}")
|
||||
wrong_power_supplies = {
|
||||
powersupply: {"state": value["state"]} for powersupply, value in dict(power_supplies).items() if value["state"] not in self.inputs.states
|
||||
}
|
||||
|
@ -196,24 +259,32 @@ class VerifyEnvironmentPower(AntaTest):
|
|||
|
||||
|
||||
class VerifyAdverseDrops(AntaTest):
|
||||
"""
|
||||
This test verifies if there are no adverse drops on DCS7280E and DCS7500E.
|
||||
"""Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches (Arad/Jericho chips).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if there are no adverse drops.
|
||||
* failure: The test will fail if there are adverse drops.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if there are no adverse drops.
|
||||
* Failure: The test will fail if there are adverse drops.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.hardware:
|
||||
- VerifyAdverseDrops:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAdverseDrops"
|
||||
description = "Verifies there are no adverse drops on DCS7280E and DCS7500E"
|
||||
categories = ["hardware"]
|
||||
commands = [AntaCommand(command="show hardware counter drop", ofmt="json")]
|
||||
description = "Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches."
|
||||
categories: ClassVar[list[str]] = ["hardware"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware counter drop", revision=1)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAdverseDrops."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
total_adverse_drop = command_output["totalAdverseDrops"] if "totalAdverseDrops" in command_output.keys() else ""
|
||||
total_adverse_drop = command_output.get("totalAdverseDrops", "")
|
||||
if total_adverse_drop == 0:
|
||||
self.result.is_success()
|
||||
else:
|
||||
|
|
|
@ -1,78 +1,115 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to the device interfaces
|
||||
"""
|
||||
"""Module related to the device interfaces tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from ipaddress import IPv4Network
|
||||
from typing import Any, ClassVar, Literal
|
||||
|
||||
# Need to keep Dict and List for pydantic in python 3.8
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, conint
|
||||
from pydantic import BaseModel, Field
|
||||
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.models import AntaCommand, AntaTemplate, AntaTest
|
||||
from anta.tools.get_item import get_item
|
||||
from anta.tools.get_value import get_value
|
||||
from anta.tools import get_item, get_value
|
||||
|
||||
|
||||
class VerifyInterfaceUtilization(AntaTest):
|
||||
"""
|
||||
Verifies interfaces utilization is below 75%.
|
||||
"""Verifies that the utilization of interfaces is below a certain threshold.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if all interfaces have a usage below 75%.
|
||||
* failure: The test will fail if one or more interfaces have a usage above 75%.
|
||||
Load interval (default to 5 minutes) is defined in device configuration.
|
||||
This test has been implemented for full-duplex interfaces only.
|
||||
|
||||
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"
|
||||
description = "Verifies that all interfaces have a usage below 75%."
|
||||
categories = ["interfaces"]
|
||||
# TODO - move from text to json if possible
|
||||
commands = [AntaCommand(command="show interfaces counters rates", ofmt="text")]
|
||||
description = "Verifies that the utilization of interfaces is below a certain threshold."
|
||||
categories: ClassVar[list[str]] = ["interfaces"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
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
|
||||
def test(self) -> None:
|
||||
command_output = self.instance_commands[0].text_output
|
||||
wrong_interfaces = {}
|
||||
for line in command_output.split("\n")[1:]:
|
||||
if len(line) > 0:
|
||||
if line.split()[-5] == "-" or line.split()[-2] == "-":
|
||||
pass
|
||||
elif float(line.split()[-5].replace("%", "")) > 75.0:
|
||||
wrong_interfaces[line.split()[0]] = line.split()[-5]
|
||||
elif float(line.split()[-2].replace("%", "")) > 75.0:
|
||||
wrong_interfaces[line.split()[0]] = line.split()[-2]
|
||||
if not wrong_interfaces:
|
||||
"""Main test function for VerifyInterfaceUtilization."""
|
||||
duplex_full = "duplexFull"
|
||||
failed_interfaces: dict[str, dict[str, float]] = {}
|
||||
rates = self.instance_commands[0].json_output
|
||||
interfaces = self.instance_commands[1].json_output
|
||||
|
||||
for intf, rate in rates["interfaces"].items():
|
||||
# The utilization logic has been implemented for full-duplex interfaces only
|
||||
if ((duplex := (interface := interfaces["interfaces"][intf]).get("duplex", None)) is not None and duplex != duplex_full) or (
|
||||
(members := interface.get("memberInterfaces", None)) is not None and any(stats["duplex"] != duplex_full for stats in members.values())
|
||||
):
|
||||
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()
|
||||
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):
|
||||
"""
|
||||
This test verifies that interfaces error counters are equal to zero.
|
||||
"""Verifies that the interfaces error counters are equal to zero.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.interfaces:
|
||||
- VerifyInterfaceErrors:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyInterfaceErrors"
|
||||
description = "Verifies there are no interface error counters."
|
||||
categories = ["interfaces"]
|
||||
commands = [AntaCommand(command="show interfaces counters errors")]
|
||||
categories: ClassVar[list[str]] = ["interfaces"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces counters errors", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyInterfaceErrors."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
wrong_interfaces: list[dict[str, dict[str, int]]] = []
|
||||
for interface, counters in command_output["interfaceErrorCounters"].items():
|
||||
|
@ -85,25 +122,33 @@ class VerifyInterfaceErrors(AntaTest):
|
|||
|
||||
|
||||
class VerifyInterfaceDiscards(AntaTest):
|
||||
"""
|
||||
Verifies interfaces packet discard counters are equal to zero.
|
||||
"""Verifies that the interfaces packet discard counters are equal to zero.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.interfaces:
|
||||
- VerifyInterfaceDiscards:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyInterfaceDiscards"
|
||||
description = "Verifies there are no interface discard counters."
|
||||
categories = ["interfaces"]
|
||||
commands = [AntaCommand(command="show interfaces counters discards")]
|
||||
categories: ClassVar[list[str]] = ["interfaces"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces counters discards", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyInterfaceDiscards."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
wrong_interfaces: list[dict[str, dict[str, int]]] = []
|
||||
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:
|
||||
self.result.is_success()
|
||||
else:
|
||||
|
@ -111,21 +156,29 @@ class VerifyInterfaceDiscards(AntaTest):
|
|||
|
||||
|
||||
class VerifyInterfaceErrDisabled(AntaTest):
|
||||
"""
|
||||
Verifies there are no interfaces in errdisabled state.
|
||||
"""Verifies there are no interfaces in the errdisabled state.
|
||||
|
||||
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.
|
||||
Expected Results
|
||||
----------------
|
||||
* 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"
|
||||
description = "Verifies there are no interfaces in the errdisabled state."
|
||||
categories = ["interfaces"]
|
||||
commands = [AntaCommand(command="show interfaces status")]
|
||||
categories: ClassVar[list[str]] = ["interfaces"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces status", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyInterfaceErrDisabled."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
errdisabled_interfaces = [interface for interface, value in command_output["interfaceStatuses"].items() if value["linkStatus"] == "errdisabled"]
|
||||
if errdisabled_interfaces:
|
||||
|
@ -135,41 +188,58 @@ class VerifyInterfaceErrDisabled(AntaTest):
|
|||
|
||||
|
||||
class VerifyInterfacesStatus(AntaTest):
|
||||
"""
|
||||
This test verifies if the provided list of interfaces are all in the expected state.
|
||||
"""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 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
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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"
|
||||
description = "Verifies the status of the provided interfaces."
|
||||
categories = ["interfaces"]
|
||||
commands = [AntaCommand(command="show interfaces description")]
|
||||
categories: ClassVar[list[str]] = ["interfaces"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces description", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input for the VerifyInterfacesStatus test."""
|
||||
"""Input model for the VerifyInterfacesStatus test."""
|
||||
|
||||
interfaces: List[InterfaceState]
|
||||
"""List of interfaces to validate with the expected state."""
|
||||
interfaces: list[InterfaceState]
|
||||
"""List of interfaces with their expected state."""
|
||||
|
||||
class InterfaceState(BaseModel):
|
||||
"""Model for the interface state input."""
|
||||
"""Model for an interface state."""
|
||||
|
||||
name: Interface
|
||||
"""Interface to validate."""
|
||||
status: Literal["up", "down", "adminDown"]
|
||||
"""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."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyInterfacesStatus."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
self.result.is_success()
|
||||
|
@ -203,22 +273,30 @@ class VerifyInterfacesStatus(AntaTest):
|
|||
|
||||
|
||||
class VerifyStormControlDrops(AntaTest):
|
||||
"""
|
||||
Verifies the device did not drop packets due its to storm-control configuration.
|
||||
"""Verifies there are no interface storm-control drop counters.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.interfaces:
|
||||
- VerifyStormControlDrops:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyStormControlDrops"
|
||||
description = "Verifies there are no interface storm-control drop counters."
|
||||
categories = ["interfaces"]
|
||||
commands = [AntaCommand(command="show storm-control")]
|
||||
categories: ClassVar[list[str]] = ["interfaces"]
|
||||
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
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyStormControlDrops."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
storm_controlled_interfaces: dict[str, dict[str, Any]] = {}
|
||||
for interface, interface_dict in command_output["interfaces"].items():
|
||||
|
@ -233,49 +311,64 @@ class VerifyStormControlDrops(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:
|
||||
* 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.
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.interfaces:
|
||||
- VerifyPortChannels:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyPortChannels"
|
||||
description = "Verifies there are no inactive ports in all port channels."
|
||||
categories = ["interfaces"]
|
||||
commands = [AntaCommand(command="show port-channel")]
|
||||
categories: ClassVar[list[str]] = ["interfaces"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show port-channel", revision=1)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyPortChannels."""
|
||||
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():
|
||||
if len(portchannel_dict["inactivePorts"]) != 0:
|
||||
po_with_invactive_ports.extend({portchannel: portchannel_dict["inactivePorts"]})
|
||||
if not po_with_invactive_ports:
|
||||
po_with_inactive_ports.extend({portchannel: portchannel_dict["inactivePorts"]})
|
||||
if not po_with_inactive_ports:
|
||||
self.result.is_success()
|
||||
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):
|
||||
"""
|
||||
Verifies there are no illegal LACP packets received.
|
||||
"""Verifies there are no illegal LACP packets in all port channels.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.interfaces:
|
||||
- VerifyIllegalLACP:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyIllegalLACP"
|
||||
description = "Verifies there are no illegal LACP packets in all port channels."
|
||||
categories = ["interfaces"]
|
||||
commands = [AntaCommand(command="show lacp counters all-ports")]
|
||||
categories: ClassVar[list[str]] = ["interfaces"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lacp counters all-ports", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyIllegalLACP."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
po_with_illegal_lacp: list[dict[str, dict[str, int]]] = []
|
||||
for portchannel, portchannel_dict in command_output["portChannels"].items():
|
||||
|
@ -285,29 +378,40 @@ class VerifyIllegalLACP(AntaTest):
|
|||
if not po_with_illegal_lacp:
|
||||
self.result.is_success()
|
||||
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):
|
||||
"""
|
||||
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:
|
||||
* 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.
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.interfaces:
|
||||
- VerifyLoopbackCount:
|
||||
number: 3
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLoopbackCount"
|
||||
description = "Verifies the number of loopback interfaces and their status."
|
||||
categories = ["interfaces"]
|
||||
commands = [AntaCommand(command="show ip interface brief")]
|
||||
categories: ClassVar[list[str]] = ["interfaces"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface brief", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
number: conint(ge=0) # type: ignore
|
||||
"""Number of loopback interfaces expected to be present"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyLoopbackCount test."""
|
||||
|
||||
number: PositiveInteger
|
||||
"""Number of loopback interfaces expected to be present."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoopbackCount."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
loopback_count = 0
|
||||
down_loopback_interfaces = []
|
||||
|
@ -328,28 +432,35 @@ class VerifyLoopbackCount(AntaTest):
|
|||
|
||||
|
||||
class VerifySVI(AntaTest):
|
||||
"""
|
||||
Verifies the status of all SVIs.
|
||||
"""Verifies the status of all SVIs.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.interfaces:
|
||||
- VerifySVI:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySVI"
|
||||
description = "Verifies the status of all SVIs."
|
||||
categories = ["interfaces"]
|
||||
commands = [AntaCommand(command="show ip interface brief")]
|
||||
categories: ClassVar[list[str]] = ["interfaces"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface brief", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySVI."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
down_svis = []
|
||||
for interface in command_output["interfaces"]:
|
||||
interface_dict = command_output["interfaces"][interface]
|
||||
if "Vlan" in interface:
|
||||
if not (interface_dict["lineProtocolStatus"] == "up" and interface_dict["interfaceStatus"] == "connected"):
|
||||
down_svis.append(interface)
|
||||
if "Vlan" in interface and not (interface_dict["lineProtocolStatus"] == "up" and interface_dict["interfaceStatus"] == "connected"):
|
||||
down_svis.append(interface)
|
||||
if len(down_svis) == 0:
|
||||
self.result.is_success()
|
||||
else:
|
||||
|
@ -357,32 +468,48 @@ class VerifySVI(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.
|
||||
You can define a global MTU to check and also an MTU per interface and also ignored some interfaces.
|
||||
|
||||
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.
|
||||
You can define a global MTU to check, or an MTU per interface and you can also ignored some interfaces.
|
||||
|
||||
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"
|
||||
description = "Verifies the global L3 MTU of all L3 interfaces."
|
||||
categories = ["interfaces"]
|
||||
commands = [AntaCommand(command="show interfaces")]
|
||||
categories: ClassVar[list[str]] = ["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
|
||||
"""Default MTU we should have configured on all non-excluded interfaces"""
|
||||
ignored_interfaces: List[str] = ["Management", "Loopback", "Vxlan", "Tunnel"]
|
||||
"""Default MTU we should have configured on all non-excluded interfaces. Defaults to 1500."""
|
||||
ignored_interfaces: list[str] = Field(default=["Management", "Loopback", "Vxlan", "Tunnel"])
|
||||
"""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"""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyL3MTU."""
|
||||
# Parameter to save incorrect interface settings
|
||||
wrong_l3mtu_intf: list[dict[str, int]] = []
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
@ -405,32 +532,45 @@ class VerifyL3MTU(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:
|
||||
* 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).
|
||||
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).
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.interfaces:
|
||||
- VerifyIPProxyARP:
|
||||
interfaces:
|
||||
- Ethernet1
|
||||
- Ethernet2
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyIPProxyARP"
|
||||
description = "Verifies if Proxy ARP is enabled."
|
||||
categories = ["interfaces"]
|
||||
commands = [AntaTemplate(template="show ip interface {intf}")]
|
||||
categories: ClassVar[list[str]] = ["interfaces"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {intf}", revision=2)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
interfaces: List[str]
|
||||
"""list of interfaces to be tested"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyIPProxyARP test."""
|
||||
|
||||
interfaces: list[str]
|
||||
"""List of interfaces to be tested."""
|
||||
|
||||
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]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyIPProxyARP."""
|
||||
disabled_intf = []
|
||||
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"]:
|
||||
disabled_intf.append(intf)
|
||||
if disabled_intf:
|
||||
|
@ -440,32 +580,48 @@ class VerifyIPProxyARP(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.
|
||||
You can define a global MTU to check and also an MTU per interface and also ignored some interfaces.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.interfaces:
|
||||
- VerifyL2MTU:
|
||||
mtu: 1500
|
||||
ignored_interfaces:
|
||||
- Management1
|
||||
- Vxlan1
|
||||
specific_mtu:
|
||||
- Ethernet1/1: 1500
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyL2MTU"
|
||||
description = "Verifies the global L2 MTU of all L2 interfaces."
|
||||
categories = ["interfaces"]
|
||||
commands = [AntaCommand(command="show interfaces")]
|
||||
categories: ClassVar[list[str]] = ["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
|
||||
"""Default MTU we should have configured on all non-excluded interfaces"""
|
||||
ignored_interfaces: List[str] = ["Management", "Loopback", "Vxlan", "Tunnel"]
|
||||
"""A list of L2 interfaces to ignore"""
|
||||
specific_mtu: List[Dict[str, int]] = []
|
||||
"""Default MTU we should have configured on all non-excluded interfaces. Defaults to 9214."""
|
||||
ignored_interfaces: list[str] = Field(default=["Management", "Loopback", "Vxlan", "Tunnel"])
|
||||
"""A list of L2 interfaces to ignore. Defaults to ["Management", "Loopback", "Vxlan", "Tunnel"]"""
|
||||
specific_mtu: list[dict[str, int]] = Field(default=[])
|
||||
"""A list of dictionary of L2 interfaces with their specific MTU configured"""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyL2MTU."""
|
||||
# Parameter to save incorrect interface settings
|
||||
wrong_l2mtu_intf: list[dict[str, int]] = []
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
@ -475,7 +631,8 @@ class VerifyL2MTU(AntaTest):
|
|||
for d in self.inputs.specific_mtu:
|
||||
specific_interfaces.extend(d)
|
||||
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:
|
||||
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
|
||||
|
@ -488,47 +645,62 @@ class VerifyL2MTU(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:
|
||||
* 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.
|
||||
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.
|
||||
|
||||
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"
|
||||
description = "Verifies the interface IPv4 addresses."
|
||||
categories = ["interfaces"]
|
||||
commands = [AntaTemplate(template="show ip interface {interface}")]
|
||||
categories: ClassVar[list[str]] = ["interfaces"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {interface}", revision=2)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Inputs for the VerifyInterfaceIPv4 test."""
|
||||
"""Input model for the VerifyInterfaceIPv4 test."""
|
||||
|
||||
interfaces: List[InterfaceDetail]
|
||||
"""list of interfaces to be tested"""
|
||||
interfaces: list[InterfaceDetail]
|
||||
"""List of interfaces with their details."""
|
||||
|
||||
class InterfaceDetail(BaseModel):
|
||||
"""Detail of an interface"""
|
||||
"""Model for an interface detail."""
|
||||
|
||||
name: Interface
|
||||
"""Name of the interface"""
|
||||
"""Name of the interface."""
|
||||
primary_ip: IPv4Network
|
||||
"""Primary IPv4 address with subnet on interface"""
|
||||
secondary_ips: Optional[List[IPv4Network]] = None
|
||||
"""Optional list of secondary IPv4 addresses with subnet on interface"""
|
||||
"""Primary IPv4 address in CIDR notation."""
|
||||
secondary_ips: list[IPv4Network] | None = None
|
||||
"""Optional list of secondary IPv4 addresses in CIDR notation."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
# Render the template for each interface
|
||||
return [
|
||||
template.render(interface=interface.name, primary_ip=interface.primary_ip, secondary_ips=interface.secondary_ips) for interface in self.inputs.interfaces
|
||||
]
|
||||
"""Render the template for each interface in the input list."""
|
||||
return [template.render(interface=interface.name) for interface in self.inputs.interfaces]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyInterfaceIPv4."""
|
||||
self.result.is_success()
|
||||
for command in self.instance_commands:
|
||||
intf = command.params["interface"]
|
||||
input_primary_ip = str(command.params["primary_ip"])
|
||||
intf = command.params.interface
|
||||
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 = []
|
||||
|
||||
# Check if the interface has an IP address configured
|
||||
|
@ -545,8 +717,8 @@ class VerifyInterfaceIPv4(AntaTest):
|
|||
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}`.")
|
||||
|
||||
if command.params["secondary_ips"] is not None:
|
||||
input_secondary_ips = sorted([str(network) for network in command.params["secondary_ips"]])
|
||||
if (param_secondary_ips := input_interface_detail.secondary_ips) is not None:
|
||||
input_secondary_ips = sorted([str(network) for network in param_secondary_ips])
|
||||
secondary_ips = get_value(interface_output, "secondaryIpsOrderedList")
|
||||
|
||||
# Combine IP address and subnet for secondary IPs
|
||||
|
@ -569,27 +741,36 @@ class VerifyInterfaceIPv4(AntaTest):
|
|||
|
||||
|
||||
class VerifyIpVirtualRouterMac(AntaTest):
|
||||
"""
|
||||
Verifies the IP virtual router MAC address.
|
||||
"""Verifies the IP virtual router MAC address.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.interfaces:
|
||||
- VerifyIpVirtualRouterMac:
|
||||
mac_address: 00:1c:73:00:dc:01
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyIpVirtualRouterMac"
|
||||
description = "Verifies the IP virtual router MAC address."
|
||||
categories = ["interfaces"]
|
||||
commands = [AntaCommand(command="show ip virtual-router")]
|
||||
categories: ClassVar[list[str]] = ["interfaces"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip virtual-router", revision=2)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Inputs for the VerifyIpVirtualRouterMac test."""
|
||||
"""Input model for the VerifyIpVirtualRouterMac test."""
|
||||
|
||||
mac_address: MacAddress
|
||||
"""IP virtual router MAC address"""
|
||||
"""IP virtual router MAC address."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyIpVirtualRouterMac."""
|
||||
command_output = self.instance_commands[0].json_output["virtualMacs"]
|
||||
mac_address_found = get_item(command_output, "macAddress", self.inputs.mac_address)
|
||||
|
||||
|
|
|
@ -1,34 +1,47 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to LANZ
|
||||
"""
|
||||
"""Module related to LANZ tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.decorators import skip_on_platforms
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyLANZ(AntaTest):
|
||||
"""
|
||||
Verifies if LANZ is enabled
|
||||
"""Verifies if LANZ (Latency Analyzer) is enabled.
|
||||
|
||||
Expected results:
|
||||
* success: the test will pass if lanz is enabled
|
||||
* failure: the test will fail if lanz is disabled
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if LANZ is enabled.
|
||||
* Failure: The test will fail if LANZ is disabled.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.lanz:
|
||||
- VerifyLANZ:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLANZ"
|
||||
description = "Verifies if LANZ is enabled."
|
||||
categories = ["lanz"]
|
||||
commands = [AntaCommand(command="show queue-monitor length status")]
|
||||
categories: ClassVar[list[str]] = ["lanz"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show queue-monitor length status", revision=1)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLANZ."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
if command_output["lanzEnabled"] is not True:
|
||||
self.result.is_failure("LANZ is not enabled")
|
||||
else:
|
||||
self.result.is_success("LANZ is enabled")
|
||||
self.result.is_success()
|
||||
|
|
|
@ -1,57 +1,72 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to the EOS various logging settings
|
||||
"""Module related to the EOS various logging tests.
|
||||
|
||||
NOTE: 'show logging' does not support json output yet
|
||||
NOTE: The EOS command `show logging` does not support JSON output format.
|
||||
"""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
# Need to keep List for pydantic in python 3.8
|
||||
from typing import List
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import logging
|
||||
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
def _get_logging_states(logger: logging.Logger, command_output: str) -> str:
|
||||
"""
|
||||
Parse "show logging" output and gets operational logging states used
|
||||
in the tests in this module.
|
||||
"""Parse `show logging` output and gets operational logging states used in the tests in this module.
|
||||
|
||||
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]
|
||||
logger.debug(f"Device logging states:\n{log_states}")
|
||||
logger.debug("Device logging states:\n%s", log_states)
|
||||
return log_states
|
||||
|
||||
|
||||
class VerifyLoggingPersistent(AntaTest):
|
||||
"""
|
||||
Verifies if logging persistent is enabled and logs are saved in flash.
|
||||
"""Verifies if logging persistent is enabled and logs are saved in flash.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if logging persistent is enabled and logs are in flash.
|
||||
* failure: The test will fail if logging persistent is disabled or no logs are saved in flash.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if logging persistent is enabled and logs are in flash.
|
||||
* Failure: The test will fail if logging persistent is disabled or no logs are saved in flash.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.logging:
|
||||
- VerifyLoggingPersistent:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLoggingPersistent"
|
||||
description = "Verifies if logging persistent is enabled and logs are saved in flash."
|
||||
categories = ["logging"]
|
||||
commands = [
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaCommand(command="show logging", ofmt="text"),
|
||||
AntaCommand(command="dir flash:/persist/messages", ofmt="text"),
|
||||
]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoggingPersistent."""
|
||||
self.result.is_success()
|
||||
log_output = self.instance_commands[0].text_output
|
||||
dir_flash_output = self.instance_commands[1].text_output
|
||||
|
@ -65,27 +80,39 @@ class VerifyLoggingPersistent(AntaTest):
|
|||
|
||||
|
||||
class VerifyLoggingSourceIntf(AntaTest):
|
||||
"""
|
||||
Verifies logging source-interface for a specified VRF.
|
||||
"""Verifies logging source-interface for a specified VRF.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided logging source-interface is configured in the specified VRF.
|
||||
* failure: The test will fail if the provided logging source-interface is NOT configured in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided logging source-interface is configured in the specified VRF.
|
||||
* Failure: The test will fail if the provided logging source-interface is NOT configured in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.logging:
|
||||
- VerifyLoggingSourceIntf:
|
||||
interface: Management0
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLoggingSourceInt"
|
||||
description = "Verifies logging source-interface for a specified VRF."
|
||||
categories = ["logging"]
|
||||
commands = [AntaCommand(command="show logging", ofmt="text")]
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyLoggingSourceInt test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
interface: str
|
||||
"""Source-interface to use as source IP of log messages"""
|
||||
"""Source-interface to use as source IP of log messages."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF to transport log messages"""
|
||||
"""The name of the VRF to transport log messages. Defaults to `default`."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoggingSourceInt."""
|
||||
output = self.instance_commands[0].text_output
|
||||
pattern = rf"Logging source-interface '{self.inputs.interface}'.*VRF {self.inputs.vrf}"
|
||||
if re.search(pattern, _get_logging_states(self.logger, output)):
|
||||
|
@ -95,31 +122,45 @@ class VerifyLoggingSourceIntf(AntaTest):
|
|||
|
||||
|
||||
class VerifyLoggingHosts(AntaTest):
|
||||
"""
|
||||
Verifies logging hosts (syslog servers) for a specified VRF.
|
||||
"""Verifies logging hosts (syslog servers) for a specified VRF.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided syslog servers are configured in the specified VRF.
|
||||
* failure: The test will fail if the provided syslog servers are NOT configured in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided syslog servers are configured in the specified VRF.
|
||||
* Failure: The test will fail if the provided syslog servers are NOT configured in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.logging:
|
||||
- VerifyLoggingHosts:
|
||||
hosts:
|
||||
- 1.1.1.1
|
||||
- 2.2.2.2
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLoggingHosts"
|
||||
description = "Verifies logging hosts (syslog servers) for a specified VRF."
|
||||
categories = ["logging"]
|
||||
commands = [AntaCommand(command="show logging", ofmt="text")]
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
hosts: List[IPv4Address]
|
||||
"""List of hosts (syslog servers) IP addresses"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyLoggingHosts test."""
|
||||
|
||||
hosts: list[IPv4Address]
|
||||
"""List of hosts (syslog servers) IP addresses."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF to transport log messages"""
|
||||
"""The name of the VRF to transport log messages. Defaults to `default`."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoggingHosts."""
|
||||
output = self.instance_commands[0].text_output
|
||||
not_configured = []
|
||||
for host in self.inputs.hosts:
|
||||
pattern = rf"Logging to '{str(host)}'.*VRF {self.inputs.vrf}"
|
||||
pattern = rf"Logging to '{host!s}'.*VRF {self.inputs.vrf}"
|
||||
if not re.search(pattern, _get_logging_states(self.logger, output)):
|
||||
not_configured.append(str(host))
|
||||
|
||||
|
@ -130,24 +171,32 @@ class VerifyLoggingHosts(AntaTest):
|
|||
|
||||
|
||||
class VerifyLoggingLogsGeneration(AntaTest):
|
||||
"""
|
||||
Verifies if logs are generated.
|
||||
"""Verifies if logs are generated.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if logs are generated.
|
||||
* failure: The test will fail if logs are NOT generated.
|
||||
Expected Results
|
||||
----------------
|
||||
* 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"
|
||||
description = "Verifies if logs are generated."
|
||||
categories = ["logging"]
|
||||
commands = [
|
||||
AntaCommand(command="send log level informational message ANTA VerifyLoggingLogsGeneration validation"),
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaCommand(command="send log level informational message ANTA VerifyLoggingLogsGeneration validation", ofmt="text"),
|
||||
AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
|
||||
]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoggingLogsGeneration."""
|
||||
log_pattern = r"ANTA VerifyLoggingLogsGeneration validation"
|
||||
output = self.instance_commands[1].text_output
|
||||
lines = output.strip().split("\n")[::-1]
|
||||
|
@ -159,25 +208,33 @@ class VerifyLoggingLogsGeneration(AntaTest):
|
|||
|
||||
|
||||
class VerifyLoggingHostname(AntaTest):
|
||||
"""
|
||||
Verifies if logs are generated with the device FQDN.
|
||||
"""Verifies if logs are generated with the device FQDN.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if logs are generated with the device FQDN.
|
||||
* failure: The test will fail if logs are NOT generated with the device FQDN.
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.logging:
|
||||
- VerifyLoggingHostname:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLoggingHostname"
|
||||
description = "Verifies if logs are generated with the device FQDN."
|
||||
categories = ["logging"]
|
||||
commands = [
|
||||
AntaCommand(command="show hostname"),
|
||||
AntaCommand(command="send log level informational message ANTA VerifyLoggingHostname validation"),
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaCommand(command="show hostname", revision=1),
|
||||
AntaCommand(command="send log level informational message ANTA VerifyLoggingHostname validation", ofmt="text"),
|
||||
AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
|
||||
]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoggingHostname."""
|
||||
output_hostname = self.instance_commands[0].json_output
|
||||
output_logging = self.instance_commands[2].text_output
|
||||
fqdn = output_hostname["fqdn"]
|
||||
|
@ -195,24 +252,32 @@ class VerifyLoggingHostname(AntaTest):
|
|||
|
||||
|
||||
class VerifyLoggingTimestamp(AntaTest):
|
||||
"""
|
||||
Verifies if logs are generated with the approprate timestamp.
|
||||
"""Verifies if logs are generated with the appropriate timestamp.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if logs are generated with the appropriated timestamp.
|
||||
* failure: The test will fail if logs are NOT generated with the appropriated timestamp.
|
||||
Expected Results
|
||||
----------------
|
||||
* 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"
|
||||
description = "Verifies if logs are generated with the appropriate timestamp."
|
||||
categories = ["logging"]
|
||||
commands = [
|
||||
AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation"),
|
||||
description = "Verifies if logs are generated with the riate timestamp."
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation", ofmt="text"),
|
||||
AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
|
||||
]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoggingTimestamp."""
|
||||
log_pattern = r"ANTA VerifyLoggingTimestamp validation"
|
||||
timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}-\d{2}:\d{2}"
|
||||
output = self.instance_commands[1].text_output
|
||||
|
@ -229,21 +294,29 @@ class VerifyLoggingTimestamp(AntaTest):
|
|||
|
||||
|
||||
class VerifyLoggingAccounting(AntaTest):
|
||||
"""
|
||||
Verifies if AAA accounting logs are generated.
|
||||
"""Verifies if AAA accounting logs are generated.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if AAA accounting logs are generated.
|
||||
* failure: The test will fail if AAA accounting logs are NOT generated.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if AAA accounting logs are generated.
|
||||
* Failure: The test will fail if AAA accounting logs are NOT generated.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.logging:
|
||||
- VerifyLoggingAccounting:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLoggingAccounting"
|
||||
description = "Verifies if AAA accounting logs are generated."
|
||||
categories = ["logging"]
|
||||
commands = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")]
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoggingAccounting."""
|
||||
pattern = r"cmd=show aaa accounting logs"
|
||||
output = self.instance_commands[0].text_output
|
||||
if re.search(pattern, output):
|
||||
|
@ -253,24 +326,29 @@ class VerifyLoggingAccounting(AntaTest):
|
|||
|
||||
|
||||
class VerifyLoggingErrors(AntaTest):
|
||||
"""
|
||||
This test verifies there are no syslog messages with a severity of ERRORS or higher.
|
||||
"""Verifies there are no syslog messages with a severity of ERRORS or higher.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if there are NO syslog messages with a severity of ERRORS or higher.
|
||||
* failure: The test will fail if ERRORS or higher syslog messages are present.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if there are NO syslog messages with a severity of ERRORS or higher.
|
||||
* Failure: The test will fail if ERRORS or higher syslog messages are present.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.logging:
|
||||
- VerifyLoggingErrors:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLoggingWarning"
|
||||
description = "This test verifies there are no syslog messages with a severity of ERRORS or higher."
|
||||
categories = ["logging"]
|
||||
commands = [AntaCommand(command="show logging threshold errors", ofmt="text")]
|
||||
name = "VerifyLoggingErrors"
|
||||
description = "Verifies there are no syslog messages with a severity of ERRORS or higher."
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging threshold errors", ofmt="text")]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""
|
||||
Run VerifyLoggingWarning validation
|
||||
"""
|
||||
"""Main test function for VerifyLoggingErrors."""
|
||||
command_output = self.instance_commands[0].text_output
|
||||
|
||||
if len(command_output) == 0:
|
||||
|
|
|
@ -1,39 +1,49 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to Multi-chassis Link Aggregation (MLAG)
|
||||
"""
|
||||
"""Module related to Multi-chassis Link Aggregation (MLAG) tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import conint
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.custom_types import MlagPriority
|
||||
from anta.custom_types import MlagPriority, PositiveInteger
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
from anta.tools.get_value import get_value
|
||||
from anta.tools import get_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyMlagStatus(AntaTest):
|
||||
"""
|
||||
This test verifies the health status of the MLAG configuration.
|
||||
"""Verifies the health status of the MLAG configuration.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the MLAG state is 'active', negotiation status is 'connected',
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the MLAG state is 'active', negotiation status is 'connected',
|
||||
peer-link status and local interface status are 'up'.
|
||||
* failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected',
|
||||
* Failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected',
|
||||
peer-link status or local interface status are not 'up'.
|
||||
* skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
* Skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.mlag:
|
||||
- VerifyMlagStatus:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyMlagStatus"
|
||||
description = "Verifies the health status of the MLAG configuration."
|
||||
categories = ["mlag"]
|
||||
commands = [AntaCommand(command="show mlag", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["mlag"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyMlagStatus."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["state"] == "disabled":
|
||||
self.result.is_skipped("MLAG is disabled")
|
||||
|
@ -52,22 +62,30 @@ class VerifyMlagStatus(AntaTest):
|
|||
|
||||
|
||||
class VerifyMlagInterfaces(AntaTest):
|
||||
"""
|
||||
This test verifies there are no inactive or active-partial MLAG ports.
|
||||
"""Verifies there are no inactive or active-partial MLAG ports.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if there are NO inactive or active-partial MLAG ports.
|
||||
* failure: The test will fail if there are inactive or active-partial MLAG ports.
|
||||
* skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if there are NO inactive or active-partial MLAG ports.
|
||||
* Failure: The test will fail if there are inactive or active-partial MLAG ports.
|
||||
* Skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.mlag:
|
||||
- VerifyMlagInterfaces:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyMlagInterfaces"
|
||||
description = "Verifies there are no inactive or active-partial MLAG ports."
|
||||
categories = ["mlag"]
|
||||
commands = [AntaCommand(command="show mlag", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["mlag"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyMlagInterfaces."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["state"] == "disabled":
|
||||
self.result.is_skipped("MLAG is disabled")
|
||||
|
@ -79,23 +97,31 @@ class VerifyMlagInterfaces(AntaTest):
|
|||
|
||||
|
||||
class VerifyMlagConfigSanity(AntaTest):
|
||||
"""
|
||||
This test verifies there are no MLAG config-sanity inconsistencies.
|
||||
"""Verifies there are no MLAG config-sanity inconsistencies.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if there are NO MLAG config-sanity inconsistencies.
|
||||
* failure: The test will fail if there are MLAG config-sanity inconsistencies.
|
||||
* skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
* error: The test will give an error if 'mlagActive' is not found in the JSON response.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if there are NO MLAG config-sanity inconsistencies.
|
||||
* Failure: The test will fail if there are MLAG config-sanity inconsistencies.
|
||||
* Skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
* Error: The test will give an error if 'mlagActive' is not found in the JSON response.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.mlag:
|
||||
- VerifyMlagConfigSanity:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyMlagConfigSanity"
|
||||
description = "Verifies there are no MLAG config-sanity inconsistencies."
|
||||
categories = ["mlag"]
|
||||
commands = [AntaCommand(command="show mlag config-sanity", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["mlag"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag config-sanity", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyMlagConfigSanity."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if (mlag_status := get_value(command_output, "mlagActive")) is None:
|
||||
self.result.is_error(message="Incorrect JSON response - 'mlagActive' state was not found")
|
||||
|
@ -112,28 +138,40 @@ class VerifyMlagConfigSanity(AntaTest):
|
|||
|
||||
|
||||
class VerifyMlagReloadDelay(AntaTest):
|
||||
"""
|
||||
This test verifies the reload-delay parameters of the MLAG configuration.
|
||||
"""Verifies the reload-delay parameters of the MLAG configuration.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the reload-delay parameters are configured properly.
|
||||
* failure: The test will fail if the reload-delay parameters are NOT configured properly.
|
||||
* skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the reload-delay parameters are configured properly.
|
||||
* Failure: The test will fail if the reload-delay parameters are NOT configured properly.
|
||||
* Skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.mlag:
|
||||
- VerifyMlagReloadDelay:
|
||||
reload_delay: 300
|
||||
reload_delay_non_mlag: 330
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyMlagReloadDelay"
|
||||
description = "Verifies the MLAG reload-delay parameters."
|
||||
categories = ["mlag"]
|
||||
commands = [AntaCommand(command="show mlag", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["mlag"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
reload_delay: conint(ge=0) # type: ignore
|
||||
"""Delay (seconds) after reboot until non peer-link ports that are part of an MLAG are enabled"""
|
||||
reload_delay_non_mlag: conint(ge=0) # type: ignore
|
||||
"""Delay (seconds) after reboot until ports that are not part of an MLAG are enabled"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyMlagReloadDelay test."""
|
||||
|
||||
reload_delay: PositiveInteger
|
||||
"""Delay (seconds) after reboot until non peer-link ports that are part of an MLAG are enabled."""
|
||||
reload_delay_non_mlag: PositiveInteger
|
||||
"""Delay (seconds) after reboot until ports that are not part of an MLAG are enabled."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyMlagReloadDelay."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["state"] == "disabled":
|
||||
self.result.is_skipped("MLAG is disabled")
|
||||
|
@ -148,32 +186,46 @@ class VerifyMlagReloadDelay(AntaTest):
|
|||
|
||||
|
||||
class VerifyMlagDualPrimary(AntaTest):
|
||||
"""
|
||||
This test verifies the dual-primary detection and its parameters of the MLAG configuration.
|
||||
"""Verifies the dual-primary detection and its parameters of the MLAG configuration.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the dual-primary detection is enabled and its parameters are configured properly.
|
||||
* failure: The test will fail if the dual-primary detection is NOT enabled or its parameters are NOT configured properly.
|
||||
* skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the dual-primary detection is enabled and its parameters are configured properly.
|
||||
* Failure: The test will fail if the dual-primary detection is NOT enabled or its parameters are NOT configured properly.
|
||||
* Skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.mlag:
|
||||
- VerifyMlagDualPrimary:
|
||||
detection_delay: 200
|
||||
errdisabled: True
|
||||
recovery_delay: 60
|
||||
recovery_delay_non_mlag: 0
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyMlagDualPrimary"
|
||||
description = "Verifies the MLAG dual-primary detection parameters."
|
||||
categories = ["mlag"]
|
||||
commands = [AntaCommand(command="show mlag detail", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["mlag"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
detection_delay: conint(ge=0) # type: ignore
|
||||
"""Delay detection (seconds)"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyMlagDualPrimary test."""
|
||||
|
||||
detection_delay: PositiveInteger
|
||||
"""Delay detection (seconds)."""
|
||||
errdisabled: bool = False
|
||||
"""Errdisabled all interfaces when dual-primary is detected"""
|
||||
recovery_delay: conint(ge=0) # type: ignore
|
||||
"""Delay (seconds) after dual-primary detection resolves until non peer-link ports that are part of an MLAG are enabled"""
|
||||
recovery_delay_non_mlag: conint(ge=0) # type: ignore
|
||||
"""Delay (seconds) after dual-primary detection resolves until ports that are not part of an MLAG are enabled"""
|
||||
"""Errdisabled all interfaces when dual-primary is detected."""
|
||||
recovery_delay: PositiveInteger
|
||||
"""Delay (seconds) after dual-primary detection resolves until non peer-link ports that are part of an MLAG are enabled."""
|
||||
recovery_delay_non_mlag: PositiveInteger
|
||||
"""Delay (seconds) after dual-primary detection resolves until ports that are not part of an MLAG are enabled."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyMlagDualPrimary."""
|
||||
errdisabled_action = "errdisableAllInterfaces" if self.inputs.errdisabled else "none"
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["state"] == "disabled":
|
||||
|
@ -196,28 +248,37 @@ class VerifyMlagDualPrimary(AntaTest):
|
|||
|
||||
|
||||
class VerifyMlagPrimaryPriority(AntaTest):
|
||||
"""
|
||||
Test class to verify the MLAG (Multi-Chassis Link Aggregation) primary priority.
|
||||
"""Verify the MLAG (Multi-Chassis Link Aggregation) primary priority.
|
||||
|
||||
Expected Results:
|
||||
* Success: The test will pass if the MLAG state is set as 'primary' and the priority matches the input.
|
||||
* Failure: The test will fail if the MLAG state is not 'primary' or the priority doesn't match the input.
|
||||
* Skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the MLAG state is set as 'primary' and the priority matches the input.
|
||||
* Failure: The test will fail if the MLAG state is not 'primary' or the priority doesn't match the input.
|
||||
* Skipped: The test will be skipped if MLAG is 'disabled'.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.mlag:
|
||||
- VerifyMlagPrimaryPriority:
|
||||
primary_priority: 3276
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyMlagPrimaryPriority"
|
||||
description = "Verifies the configuration of the MLAG primary priority."
|
||||
categories = ["mlag"]
|
||||
commands = [AntaCommand(command="show mlag detail")]
|
||||
categories: ClassVar[list[str]] = ["mlag"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Inputs for the VerifyMlagPrimaryPriority test."""
|
||||
"""Input model for the VerifyMlagPrimaryPriority test."""
|
||||
|
||||
primary_priority: MlagPriority
|
||||
"""The expected MLAG primary priority."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyMlagPrimaryPriority."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
self.result.is_success()
|
||||
# Skip the test if MLAG is disabled
|
||||
|
@ -235,5 +296,5 @@ class VerifyMlagPrimaryPriority(AntaTest):
|
|||
# Check primary priority
|
||||
if primary_priority != self.inputs.primary_priority:
|
||||
self.result.is_failure(
|
||||
f"The primary priority does not match expected. Expected `{self.inputs.primary_priority}`, but found `{primary_priority}` instead."
|
||||
f"The primary priority does not match expected. Expected `{self.inputs.primary_priority}`, but found `{primary_priority}` instead.",
|
||||
)
|
||||
|
|
|
@ -1,36 +1,54 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to multicast
|
||||
"""
|
||||
"""Module related to multicast and IGMP tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
# Need to keep Dict for pydantic in python 3.8
|
||||
from typing import Dict
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.custom_types import Vlan
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyIGMPSnoopingVlans(AntaTest):
|
||||
"""
|
||||
Verifies the IGMP snooping configuration for some VLANs.
|
||||
"""Verifies the IGMP snooping status for the provided VLANs.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the IGMP snooping status matches the expected status for the provided VLANs.
|
||||
* Failure: The test will fail if the IGMP snooping status does not match the expected status for the provided VLANs.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.multicast:
|
||||
- VerifyIGMPSnoopingVlans:
|
||||
vlans:
|
||||
10: False
|
||||
12: False
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyIGMPSnoopingVlans"
|
||||
description = "Verifies the IGMP snooping configuration for some VLANs."
|
||||
categories = ["multicast", "igmp"]
|
||||
commands = [AntaCommand(command="show ip igmp snooping")]
|
||||
description = "Verifies the IGMP snooping status for the provided VLANs."
|
||||
categories: ClassVar[list[str]] = ["multicast"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
vlans: Dict[Vlan, bool]
|
||||
"""Dictionary of VLANs with associated IGMP configuration status (True=enabled, False=disabled)"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyIGMPSnoopingVlans test."""
|
||||
|
||||
vlans: dict[Vlan, bool]
|
||||
"""Dictionary with VLAN ID and whether IGMP snooping must be enabled (True) or disabled (False)."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyIGMPSnoopingVlans."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
self.result.is_success()
|
||||
for vlan, enabled in self.inputs.vlans.items():
|
||||
|
@ -44,21 +62,36 @@ class VerifyIGMPSnoopingVlans(AntaTest):
|
|||
|
||||
|
||||
class VerifyIGMPSnoopingGlobal(AntaTest):
|
||||
"""
|
||||
Verifies the IGMP snooping global configuration.
|
||||
"""Verifies the IGMP snooping global status.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the IGMP snooping global status matches the expected status.
|
||||
* Failure: The test will fail if the IGMP snooping global status does not match the expected status.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.multicast:
|
||||
- VerifyIGMPSnoopingGlobal:
|
||||
enabled: True
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyIGMPSnoopingGlobal"
|
||||
description = "Verifies the IGMP snooping global configuration."
|
||||
categories = ["multicast", "igmp"]
|
||||
commands = [AntaCommand(command="show ip igmp snooping")]
|
||||
categories: ClassVar[list[str]] = ["multicast"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyIGMPSnoopingGlobal test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
enabled: bool
|
||||
"""Expected global IGMP snooping configuration (True=enabled, False=disabled)"""
|
||||
"""Whether global IGMP snopping must be enabled (True) or disabled (False)."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyIGMPSnoopingGlobal."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
self.result.is_success()
|
||||
igmp_state = command_output["igmpSnoopingState"]
|
||||
|
|
|
@ -1,36 +1,53 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to ASIC profiles
|
||||
"""
|
||||
"""Module related to ASIC profile tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from typing import TYPE_CHECKING, ClassVar, Literal
|
||||
|
||||
from anta.decorators import skip_on_platforms
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyUnifiedForwardingTableMode(AntaTest):
|
||||
"""
|
||||
Verifies the device is using the expected Unified Forwarding Table mode.
|
||||
"""Verifies the device is using the expected UFT (Unified Forwarding Table) mode.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the device is using the expected UFT mode.
|
||||
* Failure: The test will fail if the device is not using the expected UFT mode.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.profiles:
|
||||
- VerifyUnifiedForwardingTableMode:
|
||||
mode: 3
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyUnifiedForwardingTableMode"
|
||||
description = ""
|
||||
categories = ["profiles"]
|
||||
commands = [AntaCommand(command="show platform trident forwarding-table partition", ofmt="json")]
|
||||
description = "Verifies the device is using the expected UFT mode."
|
||||
categories: ClassVar[list[str]] = ["profiles"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show platform trident forwarding-table partition", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyUnifiedForwardingTableMode test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
mode: Literal[0, 1, 2, 3, 4, "flexible"]
|
||||
"""Expected UFT mode"""
|
||||
"""Expected UFT mode. Valid values are 0, 1, 2, 3, 4, or "flexible"."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyUnifiedForwardingTableMode."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["uftMode"] == str(self.inputs.mode):
|
||||
self.result.is_success()
|
||||
|
@ -39,22 +56,37 @@ class VerifyUnifiedForwardingTableMode(AntaTest):
|
|||
|
||||
|
||||
class VerifyTcamProfile(AntaTest):
|
||||
"""
|
||||
Verifies the device is using the configured TCAM profile.
|
||||
"""Verifies that the device is using the provided Ternary Content-Addressable Memory (TCAM) profile.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided TCAM profile is actually running on the device.
|
||||
* Failure: The test will fail if the provided TCAM profile is not running on the device.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.profiles:
|
||||
- VerifyTcamProfile:
|
||||
profile: vxlan-routing
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTcamProfile"
|
||||
description = "Verify that the assigned TCAM profile is actually running on the device"
|
||||
categories = ["profiles"]
|
||||
commands = [AntaCommand(command="show hardware tcam profile", ofmt="json")]
|
||||
description = "Verifies the device TCAM profile."
|
||||
categories: ClassVar[list[str]] = ["profiles"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware tcam profile", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyTcamProfile test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
profile: str
|
||||
"""Expected TCAM profile"""
|
||||
"""Expected TCAM profile."""
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTcamProfile."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["pmfProfiles"]["FixedSystem"]["status"] == command_output["pmfProfiles"]["FixedSystem"]["config"] == self.inputs.profile:
|
||||
self.result.is_success()
|
||||
|
|
|
@ -1,33 +1,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
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to PTP (Precision Time Protocol) in EOS
|
||||
"""
|
||||
"""Module related to PTP tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.decorators import skip_on_platforms
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
class VerifyPtpStatus(AntaTest):
|
||||
"""
|
||||
Verifies whether the PTP agent is enabled globally.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the PTP agent is enabled globally.
|
||||
* failure: The test will fail if the PTP agent is enabled globally.
|
||||
class VerifyPtpModeStatus(AntaTest):
|
||||
"""Verifies that the device is configured as a Precision Time Protocol (PTP) Boundary Clock (BC).
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the device is a BC.
|
||||
* Failure: The test will fail if the device is not a BC.
|
||||
* Error: The test will error if the 'ptpMode' variable is not present in the command output.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.ptp:
|
||||
- VerifyPtpModeStatus:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyPtpStatus"
|
||||
description = "Verifies if the PTP agent is enabled."
|
||||
categories = ["ptp"]
|
||||
commands = [AntaCommand(command="show ptp")]
|
||||
name = "VerifyPtpModeStatus"
|
||||
description = "Verifies that the device is configured as a PTP Boundary Clock."
|
||||
categories: ClassVar[list[str]] = ["ptp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab", "cEOSCloudLab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyPtpModeStatus."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
if "ptpMode" in command_output.keys():
|
||||
if (ptp_mode := command_output.get("ptpMode")) is None:
|
||||
self.result.is_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()
|
||||
else:
|
||||
self.result.is_failure("PTP agent disabled")
|
||||
self.result.is_failure(f"The following interface(s) are not in a valid PTP state: '{invalid_interfaces}'")
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""Package related to routing tests."""
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,41 +1,52 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Generic routing test functions
|
||||
"""
|
||||
"""Module related to generic routing tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address, ip_interface
|
||||
|
||||
# Need to keep List for pydantic in python 3.8
|
||||
from typing import List, Literal
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
|
||||
|
||||
class VerifyRoutingProtocolModel(AntaTest):
|
||||
"""
|
||||
Verifies the configured routing protocol model is the one we expect.
|
||||
And if there is no mismatch between the configured and operating routing protocol model.
|
||||
"""Verifies the configured routing protocol model is the one we expect.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the configured routing protocol model is the one we expect.
|
||||
* Failure: The test will fail if the configured routing protocol model is not the one we expect.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
generic:
|
||||
- VerifyRoutingProtocolModel:
|
||||
model: multi-agent
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyRoutingProtocolModel"
|
||||
description = "Verifies the configured routing protocol model."
|
||||
categories = ["routing"]
|
||||
commands = [AntaCommand(command="show ip route summary", revision=3)]
|
||||
categories: ClassVar[list[str]] = ["routing"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyRoutingProtocolModel test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
model: Literal["multi-agent", "ribd"] = "multi-agent"
|
||||
"""Expected routing protocol model"""
|
||||
"""Expected routing protocol model. Defaults to `multi-agent`."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyRoutingProtocolModel."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
configured_model = command_output["protoModelStatus"]["configuredProtoModel"]
|
||||
operating_model = command_output["protoModelStatus"]["operatingProtoModel"]
|
||||
|
@ -46,31 +57,48 @@ class VerifyRoutingProtocolModel(AntaTest):
|
|||
|
||||
|
||||
class VerifyRoutingTableSize(AntaTest):
|
||||
"""
|
||||
Verifies the size of the IP routing table (default VRF).
|
||||
Should be between the two provided thresholds.
|
||||
"""Verifies the size of the IP routing table of the default VRF.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the routing table size is between the provided minimum and maximum values.
|
||||
* Failure: The test will fail if the routing table size is not between the provided minimum and maximum values.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
generic:
|
||||
- VerifyRoutingTableSize:
|
||||
minimum: 2
|
||||
maximum: 20
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyRoutingTableSize"
|
||||
description = "Verifies the size of the IP routing table (default VRF). Should be between the two provided thresholds."
|
||||
categories = ["routing"]
|
||||
commands = [AntaCommand(command="show ip route summary", revision=3)]
|
||||
description = "Verifies the size of the IP routing table of the default VRF."
|
||||
categories: ClassVar[list[str]] = ["routing"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyRoutingTableSize test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
minimum: int
|
||||
"""Expected minimum routing table (default VRF) size"""
|
||||
"""Expected minimum routing table size."""
|
||||
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:
|
||||
"""Validate that maximum is greater than minimum"""
|
||||
"""Validate that maximum is greater than minimum."""
|
||||
if self.minimum > self.maximum:
|
||||
raise ValueError(f"Minimum {self.minimum} is greater than maximum {self.maximum}")
|
||||
msg = f"Minimum {self.minimum} is greater than maximum {self.maximum}"
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyRoutingTableSize."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
total_routes = int(command_output["vrfs"]["default"]["totalRoutes"])
|
||||
if self.inputs.minimum <= total_routes <= self.inputs.maximum:
|
||||
|
@ -80,37 +108,52 @@ class VerifyRoutingTableSize(AntaTest):
|
|||
|
||||
|
||||
class VerifyRoutingTableEntry(AntaTest):
|
||||
"""
|
||||
This test verifies that the provided routes are present in the routing table of a specified VRF.
|
||||
"""Verifies that the provided routes are present in the routing table of a specified VRF.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the provided routes are present in the routing table.
|
||||
* failure: The test will fail if one or many provided routes are missing from the routing table.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided routes are present in the routing table.
|
||||
* Failure: The test will fail if one or many provided routes are missing from the routing table.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
generic:
|
||||
- VerifyRoutingTableEntry:
|
||||
vrf: default
|
||||
routes:
|
||||
- 10.1.0.1
|
||||
- 10.1.0.2
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyRoutingTableEntry"
|
||||
description = "Verifies that the provided routes are present in the routing table of a specified VRF."
|
||||
categories = ["routing"]
|
||||
commands = [AntaTemplate(template="show ip route vrf {vrf} {route}")]
|
||||
categories: ClassVar[list[str]] = ["routing"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip route vrf {vrf} {route}", revision=4)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyRoutingTableEntry test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
vrf: str = "default"
|
||||
"""VRF context"""
|
||||
routes: List[IPv4Address]
|
||||
"""Routes to verify"""
|
||||
"""VRF context. Defaults to `default` VRF."""
|
||||
routes: list[IPv4Address]
|
||||
"""List of routes to verify."""
|
||||
|
||||
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]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyRoutingTableEntry."""
|
||||
missing_routes = []
|
||||
|
||||
for command in self.instance_commands:
|
||||
if "vrf" in command.params and "route" in command.params:
|
||||
vrf, route = command.params["vrf"], command.params["route"]
|
||||
if len(routes := command.json_output["vrfs"][vrf]["routes"]) == 0 or route != ip_interface(list(routes)[0]).ip:
|
||||
missing_routes.append(str(route))
|
||||
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:
|
||||
missing_routes.append(str(route))
|
||||
|
||||
if not missing_routes:
|
||||
self.result.is_success()
|
||||
|
|
|
@ -1,61 +1,116 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
OSPF test functions
|
||||
"""
|
||||
"""Module related to OSPF tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int:
|
||||
"""
|
||||
Count the number of OSPF neighbors
|
||||
"""Count the number of OSPF neighbors.
|
||||
|
||||
Args:
|
||||
----
|
||||
ospf_neighbor_json: The JSON output of the `show ip ospf neighbor` command.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int: The number of OSPF neighbors.
|
||||
|
||||
"""
|
||||
count = 0
|
||||
for _, vrf_data in ospf_neighbor_json["vrfs"].items():
|
||||
for _, instance_data in vrf_data["instList"].items():
|
||||
for vrf_data in ospf_neighbor_json["vrfs"].values():
|
||||
for instance_data in vrf_data["instList"].values():
|
||||
count += len(instance_data.get("ospfNeighborEntries", []))
|
||||
return count
|
||||
|
||||
|
||||
def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return the OSPF neighbors whose adjacency state is not `full`.
|
||||
|
||||
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 = []
|
||||
for vrf, vrf_data in ospf_neighbor_json["vrfs"].items():
|
||||
for instance, instance_data in vrf_data["instList"].items():
|
||||
for neighbor_data in instance_data.get("ospfNeighborEntries", []):
|
||||
if (state := neighbor_data["adjacencyState"]) != "full":
|
||||
not_full_neighbors.append(
|
||||
{
|
||||
"vrf": vrf,
|
||||
"instance": instance,
|
||||
"neighbor": neighbor_data["routerId"],
|
||||
"state": state,
|
||||
}
|
||||
)
|
||||
return not_full_neighbors
|
||||
return [
|
||||
{
|
||||
"vrf": vrf,
|
||||
"instance": instance,
|
||||
"maxLsa": instance_data.get("maxLsaInformation", {}).get("maxLsa"),
|
||||
"maxLsaThreshold": instance_data.get("maxLsaInformation", {}).get("maxLsaThreshold"),
|
||||
"numLsa": instance_data.get("lsaInformation", {}).get("numLsa"),
|
||||
}
|
||||
for vrf, vrf_data in ospf_process_json.get("vrfs", {}).items()
|
||||
for instance, instance_data in vrf_data.get("instList", {}).items()
|
||||
]
|
||||
|
||||
|
||||
class VerifyOSPFNeighborState(AntaTest):
|
||||
"""
|
||||
Verifies all OSPF neighbors are in FULL state.
|
||||
"""Verifies all OSPF neighbors are in FULL state.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all OSPF neighbors are in FULL state.
|
||||
* Failure: The test will fail if some OSPF neighbors are not in FULL state.
|
||||
* Skipped: The test will be skipped if no OSPF neighbor is found.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
ospf:
|
||||
- VerifyOSPFNeighborState:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyOSPFNeighborState"
|
||||
description = "Verifies all OSPF neighbors are in FULL state."
|
||||
categories = ["ospf"]
|
||||
commands = [AntaCommand(command="show ip ospf neighbor")]
|
||||
categories: ClassVar[list[str]] = ["ospf"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyOSPFNeighborState."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if _count_ospf_neighbor(command_output) == 0:
|
||||
self.result.is_skipped("no OSPF neighbor found")
|
||||
|
@ -67,21 +122,38 @@ class VerifyOSPFNeighborState(AntaTest):
|
|||
|
||||
|
||||
class VerifyOSPFNeighborCount(AntaTest):
|
||||
"""
|
||||
Verifies the number of OSPF neighbors in FULL state is the one we expect.
|
||||
"""Verifies the number of OSPF neighbors in FULL state is the one we expect.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the number of OSPF neighbors in FULL state is the one we expect.
|
||||
* Failure: The test will fail if the number of OSPF neighbors in FULL state is not the one we expect.
|
||||
* Skipped: The test will be skipped if no OSPF neighbor is found.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
ospf:
|
||||
- VerifyOSPFNeighborCount:
|
||||
number: 3
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyOSPFNeighborCount"
|
||||
description = "Verifies the number of OSPF neighbors in FULL state is the one we expect."
|
||||
categories = ["ospf"]
|
||||
commands = [AntaCommand(command="show ip ospf neighbor")]
|
||||
categories: ClassVar[list[str]] = ["ospf"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyOSPFNeighborCount test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
number: int
|
||||
"""The expected number of OSPF neighbors in FULL state"""
|
||||
"""The expected number of OSPF neighbors in FULL state."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyOSPFNeighborCount."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if (neighbor_count := _count_ospf_neighbor(command_output)) == 0:
|
||||
self.result.is_skipped("no OSPF neighbor found")
|
||||
|
@ -90,6 +162,46 @@ class VerifyOSPFNeighborCount(AntaTest):
|
|||
if neighbor_count != self.inputs.number:
|
||||
self.result.is_failure(f"device has {neighbor_count} neighbors (expected {self.inputs.number})")
|
||||
not_full_neighbors = _get_not_full_ospf_neighbors(command_output)
|
||||
print(not_full_neighbors)
|
||||
if not_full_neighbors:
|
||||
self.result.is_failure(f"Some neighbors are not correctly configured: {not_full_neighbors}.")
|
||||
|
||||
|
||||
class VerifyOSPFMaxLSA(AntaTest):
|
||||
"""Verifies LSAs present in the OSPF link state database did not cross the maximum LSA Threshold.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all OSPF instances did not cross the maximum LSA Threshold.
|
||||
* Failure: The test will fail if some OSPF instances crossed the maximum LSA Threshold.
|
||||
* Skipped: The test will be skipped if no OSPF instance is found.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
ospf:
|
||||
- VerifyOSPFMaxLSA:
|
||||
```
|
||||
"""
|
||||
|
||||
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.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to the EOS various security settings
|
||||
"""
|
||||
"""Module related to the EOS various security tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from datetime import datetime
|
||||
from typing import List, Union
|
||||
from datetime import datetime, timezone
|
||||
from 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.tools.get_item import get_item
|
||||
from anta.tools.get_value import get_value
|
||||
from anta.tools.utils import get_failed_logs
|
||||
from anta.tools import get_failed_logs, get_item, get_value
|
||||
|
||||
|
||||
class VerifySSHStatus(AntaTest):
|
||||
"""
|
||||
Verifies if the SSHD agent is disabled in the default VRF.
|
||||
"""Verifies if the SSHD agent is disabled in the default VRF.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the SSHD agent is disabled in the default VRF.
|
||||
* failure: The test will fail if the SSHD agent is NOT disabled in the default VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SSHD agent is disabled in the default VRF.
|
||||
* Failure: The test will fail if the SSHD agent is NOT disabled in the default VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifySSHStatus:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySSHStatus"
|
||||
description = "Verifies if the SSHD agent is disabled in the default VRF."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management ssh", ofmt="text")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh", ofmt="text")]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySSHStatus."""
|
||||
command_output = self.instance_commands[0].text_output
|
||||
|
||||
line = [line for line in command_output.split("\n") if line.startswith("SSHD status")][0]
|
||||
line = next(line for line in command_output.split("\n") if line.startswith("SSHD status"))
|
||||
status = line.split("is ")[1]
|
||||
|
||||
if status == "disabled":
|
||||
|
@ -48,97 +54,127 @@ class VerifySSHStatus(AntaTest):
|
|||
|
||||
|
||||
class VerifySSHIPv4Acl(AntaTest):
|
||||
"""
|
||||
Verifies if the SSHD agent has the right number IPv4 ACL(s) configured for a specified VRF.
|
||||
"""Verifies if the SSHD agent has the right number IPv4 ACL(s) configured for a specified VRF.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the SSHD agent has the provided number of IPv4 ACL(s) in the specified VRF.
|
||||
* failure: The test will fail if the SSHD agent has not the right number of IPv4 ACL(s) in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SSHD agent has the provided number of IPv4 ACL(s) in the specified VRF.
|
||||
* Failure: The test will fail if the SSHD agent has not the right number of IPv4 ACL(s) in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifySSHIPv4Acl:
|
||||
number: 3
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySSHIPv4Acl"
|
||||
description = "Verifies if the SSHD agent has IPv4 ACL(s) configured."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management ssh ip access-list summary")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ip access-list summary", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
number: conint(ge=0) # type:ignore
|
||||
"""The number of expected IPv4 ACL(s)"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySSHIPv4Acl test."""
|
||||
|
||||
number: PositiveInteger
|
||||
"""The number of expected IPv4 ACL(s)."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF in which to check for the SSHD agent"""
|
||||
"""The name of the VRF in which to check for the SSHD agent. Defaults to `default` VRF."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySSHIPv4Acl."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
ipv4_acl_list = command_output["ipAclList"]["aclList"]
|
||||
ipv4_acl_number = len(ipv4_acl_list)
|
||||
not_configured_acl_list = []
|
||||
if ipv4_acl_number != self.inputs.number:
|
||||
self.result.is_failure(f"Expected {self.inputs.number} SSH IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
|
||||
return
|
||||
for ipv4_acl in ipv4_acl_list:
|
||||
if self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]:
|
||||
not_configured_acl_list.append(ipv4_acl["name"])
|
||||
if not_configured_acl_list:
|
||||
self.result.is_failure(f"SSH IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
|
||||
|
||||
not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
|
||||
|
||||
if not_configured_acl:
|
||||
self.result.is_failure(f"SSH IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifySSHIPv6Acl(AntaTest):
|
||||
"""
|
||||
Verifies if the SSHD agent has the right number IPv6 ACL(s) configured for a specified VRF.
|
||||
"""Verifies if the SSHD agent has the right number IPv6 ACL(s) configured for a specified VRF.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the SSHD agent has the provided number of IPv6 ACL(s) in the specified VRF.
|
||||
* failure: The test will fail if the SSHD agent has not the right number of IPv6 ACL(s) in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SSHD agent has the provided number of IPv6 ACL(s) in the specified VRF.
|
||||
* Failure: The test will fail if the SSHD agent has not the right number of IPv6 ACL(s) in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifySSHIPv6Acl:
|
||||
number: 3
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySSHIPv6Acl"
|
||||
description = "Verifies if the SSHD agent has IPv6 ACL(s) configured."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management ssh ipv6 access-list summary")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ipv6 access-list summary", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
number: conint(ge=0) # type:ignore
|
||||
"""The number of expected IPv6 ACL(s)"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySSHIPv6Acl test."""
|
||||
|
||||
number: PositiveInteger
|
||||
"""The number of expected IPv6 ACL(s)."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF in which to check for the SSHD agent"""
|
||||
"""The name of the VRF in which to check for the SSHD agent. Defaults to `default` VRF."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySSHIPv6Acl."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
|
||||
ipv6_acl_number = len(ipv6_acl_list)
|
||||
not_configured_acl_list = []
|
||||
if ipv6_acl_number != self.inputs.number:
|
||||
self.result.is_failure(f"Expected {self.inputs.number} SSH IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
|
||||
return
|
||||
for ipv6_acl in ipv6_acl_list:
|
||||
if self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]:
|
||||
not_configured_acl_list.append(ipv6_acl["name"])
|
||||
if not_configured_acl_list:
|
||||
self.result.is_failure(f"SSH IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
|
||||
|
||||
not_configured_acl = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
|
||||
|
||||
if not_configured_acl:
|
||||
self.result.is_failure(f"SSH IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifyTelnetStatus(AntaTest):
|
||||
"""
|
||||
Verifies if Telnet is disabled in the default VRF.
|
||||
"""Verifies if Telnet is disabled in the default VRF.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if Telnet is disabled in the default VRF.
|
||||
* failure: The test will fail if Telnet is NOT disabled in the default VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if Telnet is disabled in the default VRF.
|
||||
* Failure: The test will fail if Telnet is NOT disabled in the default VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyTelnetStatus:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTelnetStatus"
|
||||
description = "Verifies if Telnet is disabled in the default VRF."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management telnet")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management telnet", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTelnetStatus."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["serverState"] == "disabled":
|
||||
self.result.is_success()
|
||||
|
@ -147,21 +183,29 @@ class VerifyTelnetStatus(AntaTest):
|
|||
|
||||
|
||||
class VerifyAPIHttpStatus(AntaTest):
|
||||
"""
|
||||
Verifies if eAPI HTTP server is disabled globally.
|
||||
"""Verifies if eAPI HTTP server is disabled globally.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if eAPI HTTP server is disabled globally.
|
||||
* failure: The test will fail if eAPI HTTP server is NOT disabled globally.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if eAPI HTTP server is disabled globally.
|
||||
* Failure: The test will fail if eAPI HTTP server is NOT disabled globally.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyAPIHttpStatus:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAPIHttpStatus"
|
||||
description = "Verifies if eAPI HTTP server is disabled globally."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management api http-commands")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAPIHttpStatus."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["enabled"] and not command_output["httpServer"]["running"]:
|
||||
self.result.is_success()
|
||||
|
@ -170,25 +214,36 @@ class VerifyAPIHttpStatus(AntaTest):
|
|||
|
||||
|
||||
class VerifyAPIHttpsSSL(AntaTest):
|
||||
"""
|
||||
Verifies if eAPI HTTPS server SSL profile is configured and valid.
|
||||
"""Verifies if eAPI HTTPS server SSL profile is configured and valid.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the eAPI HTTPS server SSL profile is configured and valid.
|
||||
* failure: The test will fail if the eAPI HTTPS server SSL profile is NOT configured, misconfigured or invalid.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the eAPI HTTPS server SSL profile is configured and valid.
|
||||
* Failure: The test will fail if the eAPI HTTPS server SSL profile is NOT configured, misconfigured or invalid.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyAPIHttpsSSL:
|
||||
profile: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAPIHttpsSSL"
|
||||
description = "Verifies if the eAPI has a valid SSL profile."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management api http-commands")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyAPIHttpsSSL test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
profile: str
|
||||
"""SSL profile to verify"""
|
||||
"""SSL profile to verify."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAPIHttpsSSL."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
try:
|
||||
if command_output["sslProfile"]["name"] == self.inputs.profile and command_output["sslProfile"]["state"] == "valid":
|
||||
|
@ -201,110 +256,149 @@ class VerifyAPIHttpsSSL(AntaTest):
|
|||
|
||||
|
||||
class VerifyAPIIPv4Acl(AntaTest):
|
||||
"""
|
||||
Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF.
|
||||
"""Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if eAPI has the provided number of IPv4 ACL(s) in the specified VRF.
|
||||
* failure: The test will fail if eAPI has not the right number of IPv4 ACL(s) in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if eAPI has the provided number of IPv4 ACL(s) in the specified VRF.
|
||||
* Failure: The test will fail if eAPI has not the right number of IPv4 ACL(s) in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyAPIIPv4Acl:
|
||||
number: 3
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAPIIPv4Acl"
|
||||
description = "Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management api http-commands ip access-list summary")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ip access-list summary", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
number: conint(ge=0) # type:ignore
|
||||
"""The number of expected IPv4 ACL(s)"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input parameters for the VerifyAPIIPv4Acl test."""
|
||||
|
||||
number: PositiveInteger
|
||||
"""The number of expected IPv4 ACL(s)."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF in which to check for eAPI"""
|
||||
"""The name of the VRF in which to check for eAPI. Defaults to `default` VRF."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAPIIPv4Acl."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
ipv4_acl_list = command_output["ipAclList"]["aclList"]
|
||||
ipv4_acl_number = len(ipv4_acl_list)
|
||||
not_configured_acl_list = []
|
||||
if ipv4_acl_number != self.inputs.number:
|
||||
self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
|
||||
return
|
||||
for ipv4_acl in ipv4_acl_list:
|
||||
if self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]:
|
||||
not_configured_acl_list.append(ipv4_acl["name"])
|
||||
if not_configured_acl_list:
|
||||
self.result.is_failure(f"eAPI IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
|
||||
|
||||
not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
|
||||
|
||||
if not_configured_acl:
|
||||
self.result.is_failure(f"eAPI IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifyAPIIPv6Acl(AntaTest):
|
||||
"""
|
||||
Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF.
|
||||
"""Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if eAPI has the provided number of IPv6 ACL(s) in the specified VRF.
|
||||
* failure: The test will fail if eAPI has not the right number of IPv6 ACL(s) in the specified VRF.
|
||||
* skipped: The test will be skipped if the number of IPv6 ACL(s) or VRF parameter is not provided.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if eAPI has the provided number of IPv6 ACL(s) in the specified VRF.
|
||||
* Failure: The test will fail if eAPI has not the right number of IPv6 ACL(s) in the specified VRF.
|
||||
* Skipped: The test will be skipped if the number of IPv6 ACL(s) or VRF parameter is not provided.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyAPIIPv6Acl:
|
||||
number: 3
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAPIIPv6Acl"
|
||||
description = "Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management api http-commands ipv6 access-list summary")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ipv6 access-list summary", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
number: conint(ge=0) # type:ignore
|
||||
"""The number of expected IPv6 ACL(s)"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input parameters for the VerifyAPIIPv6Acl test."""
|
||||
|
||||
number: PositiveInteger
|
||||
"""The number of expected IPv6 ACL(s)."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF in which to check for eAPI"""
|
||||
"""The name of the VRF in which to check for eAPI. Defaults to `default` VRF."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAPIIPv6Acl."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
|
||||
ipv6_acl_number = len(ipv6_acl_list)
|
||||
not_configured_acl_list = []
|
||||
if ipv6_acl_number != self.inputs.number:
|
||||
self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
|
||||
return
|
||||
for ipv6_acl in ipv6_acl_list:
|
||||
if self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]:
|
||||
not_configured_acl_list.append(ipv6_acl["name"])
|
||||
if not_configured_acl_list:
|
||||
self.result.is_failure(f"eAPI IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
|
||||
|
||||
not_configured_acl = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
|
||||
|
||||
if not_configured_acl:
|
||||
self.result.is_failure(f"eAPI IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifyAPISSLCertificate(AntaTest):
|
||||
"""
|
||||
Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size.
|
||||
"""Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the certificate's expiry date is greater than the threshold,
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the certificate's expiry date is greater than the threshold,
|
||||
and the certificate has the correct name, encryption algorithm, and key size.
|
||||
* failure: The test will fail if the certificate is expired or is going to expire,
|
||||
* Failure: The test will fail if the certificate is expired or is going to expire,
|
||||
or if the certificate has an incorrect name, encryption algorithm, or key size.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyAPISSLCertificate:
|
||||
certificates:
|
||||
- certificate_name: ARISTA_SIGNING_CA.crt
|
||||
expiry_threshold: 30
|
||||
common_name: AristaIT-ICA ECDSA Issuing Cert Authority
|
||||
encryption_algorithm: ECDSA
|
||||
key_size: 256
|
||||
- certificate_name: ARISTA_ROOT_CA.crt
|
||||
expiry_threshold: 30
|
||||
common_name: Arista Networks Internal IT Root Cert Authority
|
||||
encryption_algorithm: RSA
|
||||
key_size: 4096
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAPISSLCertificate"
|
||||
description = "Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show management security ssl certificate"), AntaCommand(command="show clock")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaCommand(command="show management security ssl certificate", revision=1),
|
||||
AntaCommand(command="show clock", revision=1),
|
||||
]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""
|
||||
Input parameters for the VerifyAPISSLCertificate test.
|
||||
"""
|
||||
"""Input parameters for the VerifyAPISSLCertificate test."""
|
||||
|
||||
certificates: List[APISSLCertificates]
|
||||
"""List of API SSL certificates"""
|
||||
certificates: list[APISSLCertificate]
|
||||
"""List of API SSL certificates."""
|
||||
|
||||
class APISSLCertificates(BaseModel):
|
||||
"""
|
||||
This class defines the details of an API SSL certificate.
|
||||
"""
|
||||
class APISSLCertificate(BaseModel):
|
||||
"""Model for an API SSL certificate."""
|
||||
|
||||
certificate_name: str
|
||||
"""The name of the certificate to be verified."""
|
||||
|
@ -314,31 +408,30 @@ class VerifyAPISSLCertificate(AntaTest):
|
|||
"""The common subject name of the certificate."""
|
||||
encryption_algorithm: EncryptionAlgorithm
|
||||
"""The encryption algorithm of the certificate."""
|
||||
key_size: Union[RsaKeySize, EcdsaKeySize]
|
||||
key_size: RsaKeySize | EcdsaKeySize
|
||||
"""The encryption algorithm key size of the certificate."""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_inputs(self: BaseModel) -> BaseModel:
|
||||
"""
|
||||
Validate the key size provided to the APISSLCertificates class.
|
||||
"""Validate the key size provided to the APISSLCertificates class.
|
||||
|
||||
If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}.
|
||||
|
||||
If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}.
|
||||
"""
|
||||
|
||||
if self.encryption_algorithm == "RSA" and self.key_size not in 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__:
|
||||
raise ValueError(
|
||||
f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {EcdsaKeySize.__args__}."
|
||||
)
|
||||
msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {EcdsaKeySize.__args__}."
|
||||
raise ValueError(msg)
|
||||
|
||||
return self
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAPISSLCertificate."""
|
||||
# Mark the result as success by default
|
||||
self.result.is_success()
|
||||
|
||||
|
@ -356,7 +449,7 @@ class VerifyAPISSLCertificate(AntaTest):
|
|||
continue
|
||||
|
||||
expiry_time = certificate_data["notAfter"]
|
||||
day_difference = (datetime.fromtimestamp(expiry_time) - datetime.fromtimestamp(current_timestamp)).days
|
||||
day_difference = (datetime.fromtimestamp(expiry_time, tz=timezone.utc) - datetime.fromtimestamp(current_timestamp, tz=timezone.utc)).days
|
||||
|
||||
# Verify certificate expiry
|
||||
if 0 < day_difference < certificate.expiry_threshold:
|
||||
|
@ -381,27 +474,39 @@ class VerifyAPISSLCertificate(AntaTest):
|
|||
|
||||
|
||||
class VerifyBannerLogin(AntaTest):
|
||||
"""
|
||||
Verifies the login banner of a device.
|
||||
"""Verifies the login banner of a device.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the login banner matches the provided input.
|
||||
* failure: The test will fail if the login banner does not match the provided input.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the login banner matches the provided input.
|
||||
* Failure: The test will fail if the login banner does not match the provided input.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyBannerLogin:
|
||||
login_banner: |
|
||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyBannerLogin"
|
||||
description = "Verifies the login banner of a device."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show banner login")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner login", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Defines the input parameters for this test case."""
|
||||
"""Input model for the VerifyBannerLogin test."""
|
||||
|
||||
login_banner: str
|
||||
"""Expected login banner of the device."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyBannerLogin."""
|
||||
login_banner = self.instance_commands[0].json_output["loginBanner"]
|
||||
|
||||
# Remove leading and trailing whitespaces from each line
|
||||
|
@ -413,27 +518,39 @@ class VerifyBannerLogin(AntaTest):
|
|||
|
||||
|
||||
class VerifyBannerMotd(AntaTest):
|
||||
"""
|
||||
Verifies the motd banner of a device.
|
||||
"""Verifies the motd banner of a device.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the motd banner matches the provided input.
|
||||
* failure: The test will fail if the motd banner does not match the provided input.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the motd banner matches the provided input.
|
||||
* Failure: The test will fail if the motd banner does not match the provided input.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyBannerMotd:
|
||||
motd_banner: |
|
||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyBannerMotd"
|
||||
description = "Verifies the motd banner of a device."
|
||||
categories = ["security"]
|
||||
commands = [AntaCommand(command="show banner motd")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner motd", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Defines the input parameters for this test case."""
|
||||
"""Input model for the VerifyBannerMotd test."""
|
||||
|
||||
motd_banner: str
|
||||
"""Expected motd banner of the device."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyBannerMotd."""
|
||||
motd_banner = self.instance_commands[0].json_output["motd"]
|
||||
|
||||
# Remove leading and trailing whitespaces from each line
|
||||
|
@ -445,52 +562,77 @@ class VerifyBannerMotd(AntaTest):
|
|||
|
||||
|
||||
class VerifyIPv4ACL(AntaTest):
|
||||
"""
|
||||
Verifies the configuration of IPv4 ACLs.
|
||||
"""Verifies the configuration of IPv4 ACLs.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if an IPv4 ACL is configured with the correct sequence entries.
|
||||
* failure: The test will fail if an IPv4 ACL is not configured or entries are not in sequence.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if an IPv4 ACL is configured with the correct sequence entries.
|
||||
* Failure: The test will fail if an IPv4 ACL is not configured or entries are not in sequence.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyIPv4ACL:
|
||||
ipv4_access_lists:
|
||||
- name: default-control-plane-acl
|
||||
entries:
|
||||
- sequence: 10
|
||||
action: permit icmp any any
|
||||
- sequence: 20
|
||||
action: permit ip any any tracked
|
||||
- sequence: 30
|
||||
action: permit udp any any eq bfd ttl eq 255
|
||||
- name: LabTest
|
||||
entries:
|
||||
- sequence: 10
|
||||
action: permit icmp any any
|
||||
- sequence: 20
|
||||
action: permit tcp any any range 5900 5910
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyIPv4ACL"
|
||||
description = "Verifies the configuration of IPv4 ACLs."
|
||||
categories = ["security"]
|
||||
commands = [AntaTemplate(template="show ip access-lists {acl}")]
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip access-lists {acl}", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Inputs for the VerifyIPv4ACL test."""
|
||||
"""Input model for the VerifyIPv4ACL test."""
|
||||
|
||||
ipv4_access_lists: List[IPv4ACL]
|
||||
"""List of IPv4 ACLs to verify"""
|
||||
ipv4_access_lists: list[IPv4ACL]
|
||||
"""List of IPv4 ACLs to verify."""
|
||||
|
||||
class IPv4ACL(BaseModel):
|
||||
"""Detail of IPv4 ACL"""
|
||||
"""Model for an IPv4 ACL."""
|
||||
|
||||
name: str
|
||||
"""Name of IPv4 ACL"""
|
||||
"""Name of IPv4 ACL."""
|
||||
|
||||
entries: List[IPv4ACLEntries]
|
||||
"""List of IPv4 ACL entries"""
|
||||
entries: list[IPv4ACLEntry]
|
||||
"""List of IPv4 ACL entries."""
|
||||
|
||||
class IPv4ACLEntries(BaseModel):
|
||||
"""IPv4 ACL entries details"""
|
||||
class IPv4ACLEntry(BaseModel):
|
||||
"""Model for an IPv4 ACL entry."""
|
||||
|
||||
sequence: int = Field(ge=1, le=4294967295)
|
||||
"""Sequence number of an ACL entry"""
|
||||
"""Sequence number of an ACL entry."""
|
||||
action: str
|
||||
"""Action of an ACL entry"""
|
||||
"""Action of an ACL entry."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
return [template.render(acl=acl.name, entries=acl.entries) for acl in self.inputs.ipv4_access_lists]
|
||||
"""Render the template for each input ACL."""
|
||||
return [template.render(acl=acl.name) for acl in self.inputs.ipv4_access_lists]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyIPv4ACL."""
|
||||
self.result.is_success()
|
||||
for command_output in self.instance_commands:
|
||||
for command_output, acl in zip(self.instance_commands, self.inputs.ipv4_access_lists):
|
||||
# Collecting input ACL details
|
||||
acl_name = command_output.params["acl"]
|
||||
acl_entries = command_output.params["entries"]
|
||||
acl_name = command_output.params.acl
|
||||
# Retrieve the expected entries from the inputs
|
||||
acl_entries = acl.entries
|
||||
|
||||
# Check if ACL is configured
|
||||
ipv4_acl_list = command_output.json_output["aclList"]
|
||||
|
@ -512,3 +654,165 @@ class VerifyIPv4ACL(AntaTest):
|
|||
|
||||
if failed_log != f"{acl_name}:\n":
|
||||
self.result.is_failure(f"{failed_log}")
|
||||
|
||||
|
||||
class VerifyIPSecConnHealth(AntaTest):
|
||||
"""
|
||||
Verifies all IPv4 security connections.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all the IPv4 security connections are established in all vrf.
|
||||
* Failure: The test will fail if IPv4 security is not configured or any of IPv4 security connections are not established in any vrf.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyIPSecConnHealth:
|
||||
```
|
||||
"""
|
||||
|
||||
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.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to the EOS various services settings
|
||||
"""
|
||||
"""Module related to the EOS various services tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
from typing import List, Union
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from anta.custom_types import ErrDisableInterval, ErrDisableReasons
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
from anta.tools.get_dict_superset import get_dict_superset
|
||||
from anta.tools.get_item import get_item
|
||||
from anta.tools.utils import get_failed_logs
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from anta.tools import get_dict_superset, get_failed_logs, get_item
|
||||
|
||||
|
||||
class VerifyHostname(AntaTest):
|
||||
"""
|
||||
Verifies the hostname of a device.
|
||||
"""Verifies the hostname of a device.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the hostname matches the provided input.
|
||||
* failure: The test will fail if the hostname does not match the provided input.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the hostname matches the provided input.
|
||||
* Failure: The test will fail if the hostname does not match the provided input.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.services:
|
||||
- VerifyHostname:
|
||||
hostname: s1-spine1
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyHostname"
|
||||
description = "Verifies the hostname of a device."
|
||||
categories = ["services"]
|
||||
commands = [AntaCommand(command="show hostname")]
|
||||
categories: ClassVar[list[str]] = ["services"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hostname", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Defines the input parameters for this test case."""
|
||||
"""Input model for the VerifyHostname test."""
|
||||
|
||||
hostname: str
|
||||
"""Expected hostname of the device."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyHostname."""
|
||||
hostname = self.instance_commands[0].json_output["hostname"]
|
||||
|
||||
if hostname != self.inputs.hostname:
|
||||
|
@ -52,35 +57,48 @@ class VerifyHostname(AntaTest):
|
|||
|
||||
|
||||
class VerifyDNSLookup(AntaTest):
|
||||
"""
|
||||
This class verifies the DNS (Domain name service) name to IP address resolution.
|
||||
"""Verifies the DNS (Domain Name Service) name to IP address resolution.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if a domain name is resolved to an IP address.
|
||||
* failure: The test will fail if a domain name does not resolve to an IP address.
|
||||
* error: This test will error out if a domain name is invalid.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if a domain name is resolved to an IP address.
|
||||
* Failure: The test will fail if a domain name does not resolve to an IP address.
|
||||
* Error: This test will error out if a domain name is invalid.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.services:
|
||||
- VerifyDNSLookup:
|
||||
domain_names:
|
||||
- arista.com
|
||||
- www.google.com
|
||||
- arista.ca
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyDNSLookup"
|
||||
description = "Verifies the DNS name to IP address resolution."
|
||||
categories = ["services"]
|
||||
commands = [AntaTemplate(template="bash timeout 10 nslookup {domain}")]
|
||||
categories: ClassVar[list[str]] = ["services"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="bash timeout 10 nslookup {domain}", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Inputs for the VerifyDNSLookup test."""
|
||||
"""Input model for the VerifyDNSLookup test."""
|
||||
|
||||
domain_names: List[str]
|
||||
"""List of domain names"""
|
||||
domain_names: list[str]
|
||||
"""List of domain names."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render the template for each domain name in the input list."""
|
||||
return [template.render(domain=domain_name) for domain_name in self.inputs.domain_names]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyDNSLookup."""
|
||||
self.result.is_success()
|
||||
failed_domains = []
|
||||
for command in self.instance_commands:
|
||||
domain = command.params["domain"]
|
||||
domain = command.params.domain
|
||||
output = command.json_output["messages"][0]
|
||||
if f"Can't find {domain}: No answer" in output:
|
||||
failed_domains.append(domain)
|
||||
|
@ -89,29 +107,43 @@ class VerifyDNSLookup(AntaTest):
|
|||
|
||||
|
||||
class VerifyDNSServers(AntaTest):
|
||||
"""
|
||||
Verifies if the DNS (Domain Name Service) servers are correctly configured.
|
||||
"""Verifies if the DNS (Domain Name Service) servers are correctly configured.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the DNS server specified in the input is configured with the correct VRF and priority.
|
||||
* failure: The test will fail if the DNS server is not configured or if the VRF and priority of the DNS server do not match the input.
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.services:
|
||||
- VerifyDNSServers:
|
||||
dns_servers:
|
||||
- server_address: 10.14.0.1
|
||||
vrf: default
|
||||
priority: 1
|
||||
- server_address: 10.14.0.11
|
||||
vrf: MGMT
|
||||
priority: 0
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyDNSServers"
|
||||
description = "Verifies if the DNS servers are correctly configured."
|
||||
categories = ["services"]
|
||||
commands = [AntaCommand(command="show ip name-server")]
|
||||
categories: ClassVar[list[str]] = ["services"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip name-server", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Inputs for the VerifyDNSServers test."""
|
||||
"""Input model for the VerifyDNSServers test."""
|
||||
|
||||
dns_servers: List[DnsServers]
|
||||
dns_servers: list[DnsServer]
|
||||
"""List of DNS servers to verify."""
|
||||
|
||||
class DnsServers(BaseModel):
|
||||
"""DNS server details"""
|
||||
class DnsServer(BaseModel):
|
||||
"""Model for a DNS server."""
|
||||
|
||||
server_address: Union[IPv4Address, IPv6Address]
|
||||
server_address: IPv4Address | IPv6Address
|
||||
"""The IPv4/IPv6 address of the DNS server."""
|
||||
vrf: str = "default"
|
||||
"""The VRF for the DNS server. Defaults to 'default' if not provided."""
|
||||
|
@ -120,6 +152,7 @@ class VerifyDNSServers(AntaTest):
|
|||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyDNSServers."""
|
||||
command_output = self.instance_commands[0].json_output["nameServerConfigs"]
|
||||
self.result.is_success()
|
||||
for server in self.inputs.dns_servers:
|
||||
|
@ -141,35 +174,49 @@ class VerifyDNSServers(AntaTest):
|
|||
|
||||
|
||||
class VerifyErrdisableRecovery(AntaTest):
|
||||
"""
|
||||
Verifies the errdisable recovery reason, status, and interval.
|
||||
"""Verifies the errdisable recovery reason, status, and interval.
|
||||
|
||||
Expected Results:
|
||||
* Success: The test will pass if the errdisable recovery reason status is enabled and the interval matches the input.
|
||||
* Failure: The test will fail if the errdisable recovery reason is not found, the status is not enabled, or the interval does not match the input.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the errdisable recovery reason status is enabled and the interval matches the input.
|
||||
* Failure: The test will fail if the errdisable recovery reason is not found, the status is not enabled, or the interval does not match the input.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.services:
|
||||
- VerifyErrdisableRecovery:
|
||||
reasons:
|
||||
- reason: acl
|
||||
interval: 30
|
||||
- reason: bpduguard
|
||||
interval: 30
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyErrdisableRecovery"
|
||||
description = "Verifies the errdisable recovery reason, status, and interval."
|
||||
categories = ["services"]
|
||||
commands = [AntaCommand(command="show errdisable recovery", ofmt="text")] # Command does not support JSON output hence using text output
|
||||
categories: ClassVar[list[str]] = ["services"]
|
||||
# NOTE: Only `text` output format is supported for this command
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show errdisable recovery", ofmt="text")]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Inputs for the VerifyErrdisableRecovery test."""
|
||||
"""Input model for the VerifyErrdisableRecovery test."""
|
||||
|
||||
reasons: List[ErrDisableReason]
|
||||
"""List of errdisable reasons"""
|
||||
reasons: list[ErrDisableReason]
|
||||
"""List of errdisable reasons."""
|
||||
|
||||
class ErrDisableReason(BaseModel):
|
||||
"""Details of an errdisable reason"""
|
||||
"""Model for an errdisable reason."""
|
||||
|
||||
reason: ErrDisableReasons
|
||||
"""Type or name of the errdisable reason"""
|
||||
"""Type or name of the errdisable reason."""
|
||||
interval: ErrDisableInterval
|
||||
"""Interval of the reason in seconds"""
|
||||
"""Interval of the reason in seconds."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyErrdisableRecovery."""
|
||||
command_output = self.instance_commands[0].text_output
|
||||
self.result.is_success()
|
||||
for error_reason in self.inputs.reasons:
|
||||
|
|
|
@ -1,38 +1,52 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to the EOS various SNMP settings
|
||||
"""
|
||||
"""Module related to the EOS various SNMP tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import conint
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.custom_types import PositiveInteger
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifySnmpStatus(AntaTest):
|
||||
"""
|
||||
Verifies whether the SNMP agent is enabled in a specified VRF.
|
||||
"""Verifies whether the SNMP agent is enabled in a specified VRF.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the SNMP agent is enabled in the specified VRF.
|
||||
* failure: The test will fail if the SNMP agent is disabled in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SNMP agent is enabled in the specified VRF.
|
||||
* Failure: The test will fail if the SNMP agent is disabled in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.snmp:
|
||||
- VerifySnmpStatus:
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySnmpStatus"
|
||||
description = "Verifies if the SNMP agent is enabled."
|
||||
categories = ["snmp"]
|
||||
commands = [AntaCommand(command="show snmp")]
|
||||
categories: ClassVar[list[str]] = ["snmp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySnmpStatus test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF in which to check for the SNMP agent"""
|
||||
"""The name of the VRF in which to check for the SNMP agent. Defaults to `default` VRF."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySnmpStatus."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["enabled"] and self.inputs.vrf in command_output["vrfs"]["snmpVrfs"]:
|
||||
self.result.is_success()
|
||||
|
@ -41,103 +55,134 @@ class VerifySnmpStatus(AntaTest):
|
|||
|
||||
|
||||
class VerifySnmpIPv4Acl(AntaTest):
|
||||
"""
|
||||
Verifies if the SNMP agent has the right number IPv4 ACL(s) configured for a specified VRF.
|
||||
"""Verifies if the SNMP agent has the right number IPv4 ACL(s) configured for a specified VRF.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the SNMP agent has the provided number of IPv4 ACL(s) in the specified VRF.
|
||||
* failure: The test will fail if the SNMP agent has not the right number of IPv4 ACL(s) in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SNMP agent has the provided number of IPv4 ACL(s) in the specified VRF.
|
||||
* Failure: The test will fail if the SNMP agent has not the right number of IPv4 ACL(s) in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.snmp:
|
||||
- VerifySnmpIPv4Acl:
|
||||
number: 3
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySnmpIPv4Acl"
|
||||
description = "Verifies if the SNMP agent has IPv4 ACL(s) configured."
|
||||
categories = ["snmp"]
|
||||
commands = [AntaCommand(command="show snmp ipv4 access-list summary")]
|
||||
categories: ClassVar[list[str]] = ["snmp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv4 access-list summary", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
number: conint(ge=0) # type:ignore
|
||||
"""The number of expected IPv4 ACL(s)"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySnmpIPv4Acl test."""
|
||||
|
||||
number: PositiveInteger
|
||||
"""The number of expected IPv4 ACL(s)."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF in which to check for the SNMP agent"""
|
||||
"""The name of the VRF in which to check for the SNMP agent. Defaults to `default` VRF."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySnmpIPv4Acl."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
ipv4_acl_list = command_output["ipAclList"]["aclList"]
|
||||
ipv4_acl_number = len(ipv4_acl_list)
|
||||
not_configured_acl_list = []
|
||||
if ipv4_acl_number != self.inputs.number:
|
||||
self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
|
||||
return
|
||||
for ipv4_acl in ipv4_acl_list:
|
||||
if self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]:
|
||||
not_configured_acl_list.append(ipv4_acl["name"])
|
||||
if not_configured_acl_list:
|
||||
self.result.is_failure(f"SNMP IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
|
||||
|
||||
not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
|
||||
|
||||
if not_configured_acl:
|
||||
self.result.is_failure(f"SNMP IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifySnmpIPv6Acl(AntaTest):
|
||||
"""
|
||||
Verifies if the SNMP agent has the right number IPv6 ACL(s) configured for a specified VRF.
|
||||
"""Verifies if the SNMP agent has the right number IPv6 ACL(s) configured for a specified VRF.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the SNMP agent has the provided number of IPv6 ACL(s) in the specified VRF.
|
||||
* failure: The test will fail if the SNMP agent has not the right number of IPv6 ACL(s) in the specified VRF.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SNMP agent has the provided number of IPv6 ACL(s) in the specified VRF.
|
||||
* Failure: The test will fail if the SNMP agent has not the right number of IPv6 ACL(s) in the specified VRF.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.snmp:
|
||||
- VerifySnmpIPv6Acl:
|
||||
number: 3
|
||||
vrf: default
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySnmpIPv6Acl"
|
||||
description = "Verifies if the SNMP agent has IPv6 ACL(s) configured."
|
||||
categories = ["snmp"]
|
||||
commands = [AntaCommand(command="show snmp ipv6 access-list summary")]
|
||||
categories: ClassVar[list[str]] = ["snmp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv6 access-list summary", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
number: conint(ge=0) # type:ignore
|
||||
"""The number of expected IPv6 ACL(s)"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySnmpIPv6Acl test."""
|
||||
|
||||
number: PositiveInteger
|
||||
"""The number of expected IPv6 ACL(s)."""
|
||||
vrf: str = "default"
|
||||
"""The name of the VRF in which to check for the SNMP agent"""
|
||||
"""The name of the VRF in which to check for the SNMP agent. Defaults to `default` VRF."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySnmpIPv6Acl."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
|
||||
ipv6_acl_number = len(ipv6_acl_list)
|
||||
not_configured_acl_list = []
|
||||
if ipv6_acl_number != self.inputs.number:
|
||||
self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
|
||||
return
|
||||
for ipv6_acl in ipv6_acl_list:
|
||||
if self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]:
|
||||
not_configured_acl_list.append(ipv6_acl["name"])
|
||||
if not_configured_acl_list:
|
||||
self.result.is_failure(f"SNMP IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}")
|
||||
|
||||
acl_not_configured = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
|
||||
|
||||
if acl_not_configured:
|
||||
self.result.is_failure(f"SNMP IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {acl_not_configured}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifySnmpLocation(AntaTest):
|
||||
"""
|
||||
This class verifies the SNMP location of a device.
|
||||
"""Verifies the SNMP location of a device.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the SNMP location matches the provided input.
|
||||
* failure: The test will fail if the SNMP location does not match the provided input.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SNMP location matches the provided input.
|
||||
* Failure: The test will fail if the SNMP location does not match the provided input.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.snmp:
|
||||
- VerifySnmpLocation:
|
||||
location: New York
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySnmpLocation"
|
||||
description = "Verifies the SNMP location of a device."
|
||||
categories = ["snmp"]
|
||||
commands = [AntaCommand(command="show snmp")]
|
||||
categories: ClassVar[list[str]] = ["snmp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Defines the input parameters for this test case."""
|
||||
"""Input model for the VerifySnmpLocation test."""
|
||||
|
||||
location: str
|
||||
"""Expected SNMP location of the device."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySnmpLocation."""
|
||||
location = self.instance_commands[0].json_output["location"]["location"]
|
||||
|
||||
if location != self.inputs.location:
|
||||
|
@ -147,27 +192,36 @@ class VerifySnmpLocation(AntaTest):
|
|||
|
||||
|
||||
class VerifySnmpContact(AntaTest):
|
||||
"""
|
||||
This class verifies the SNMP contact of a device.
|
||||
"""Verifies the SNMP contact of a device.
|
||||
|
||||
Expected results:
|
||||
* success: The test will pass if the SNMP contact matches the provided input.
|
||||
* failure: The test will fail if the SNMP contact does not match the provided input.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SNMP contact matches the provided input.
|
||||
* Failure: The test will fail if the SNMP contact does not match the provided input.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.snmp:
|
||||
- VerifySnmpContact:
|
||||
contact: Jon@example.com
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySnmpContact"
|
||||
description = "Verifies the SNMP contact of a device."
|
||||
categories = ["snmp"]
|
||||
commands = [AntaCommand(command="show snmp")]
|
||||
categories: ClassVar[list[str]] = ["snmp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Defines the input parameters for this test case."""
|
||||
"""Input model for the VerifySnmpContact test."""
|
||||
|
||||
contact: str
|
||||
"""Expected SNMP contact details of the device."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySnmpContact."""
|
||||
contact = self.instance_commands[0].json_output["contact"]["contact"]
|
||||
|
||||
if contact != self.inputs.contact:
|
||||
|
|
|
@ -1,35 +1,53 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to the EOS software
|
||||
"""
|
||||
"""Module related to the EOS software tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
# Need to keep List for pydantic in python 3.8
|
||||
from typing import List
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyEOSVersion(AntaTest):
|
||||
"""
|
||||
Verifies the device is running one of the allowed EOS version.
|
||||
"""Verifies that the device is running one of the allowed EOS version.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the device is running one of the allowed EOS version.
|
||||
* Failure: The test will fail if the device is not running one of the allowed EOS version.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.software:
|
||||
- VerifyEOSVersion:
|
||||
versions:
|
||||
- 4.25.4M
|
||||
- 4.26.1F
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyEOSVersion"
|
||||
description = "Verifies the device is running one of the allowed EOS version."
|
||||
categories = ["software"]
|
||||
commands = [AntaCommand(command="show version")]
|
||||
description = "Verifies the EOS version of the device."
|
||||
categories: ClassVar[list[str]] = ["software"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
versions: List[str]
|
||||
"""List of allowed EOS versions"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyEOSVersion test."""
|
||||
|
||||
versions: list[str]
|
||||
"""List of allowed EOS versions."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyEOSVersion."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["version"] in self.inputs.versions:
|
||||
self.result.is_success()
|
||||
|
@ -38,21 +56,38 @@ class VerifyEOSVersion(AntaTest):
|
|||
|
||||
|
||||
class VerifyTerminAttrVersion(AntaTest):
|
||||
"""
|
||||
Verifies the device is running one of the allowed TerminAttr version.
|
||||
"""Verifies that he device is running one of the allowed TerminAttr version.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the device is running one of the allowed TerminAttr version.
|
||||
* Failure: The test will fail if the device is not running one of the allowed TerminAttr version.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.software:
|
||||
- VerifyTerminAttrVersion:
|
||||
versions:
|
||||
- v1.13.6
|
||||
- v1.8.0
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyTerminAttrVersion"
|
||||
description = "Verifies the device is running one of the allowed TerminAttr version."
|
||||
categories = ["software"]
|
||||
commands = [AntaCommand(command="show version detail")]
|
||||
description = "Verifies the TerminAttr version of the device."
|
||||
categories: ClassVar[list[str]] = ["software"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
versions: List[str]
|
||||
"""List of allowed TerminAttr versions"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyTerminAttrVersion test."""
|
||||
|
||||
versions: list[str]
|
||||
"""List of allowed TerminAttr versions."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyTerminAttrVersion."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
command_output_data = command_output["details"]["packages"]["TerminAttr-core"]["version"]
|
||||
if command_output_data in self.inputs.versions:
|
||||
|
@ -62,17 +97,32 @@ class VerifyTerminAttrVersion(AntaTest):
|
|||
|
||||
|
||||
class VerifyEOSExtensions(AntaTest):
|
||||
"""
|
||||
Verifies all EOS extensions installed on the device are enabled for boot persistence.
|
||||
"""Verifies that all EOS extensions installed on the device are enabled for boot persistence.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all EOS extensions installed on the device are enabled for boot persistence.
|
||||
* Failure: The test will fail if some EOS extensions installed on the device are not enabled for boot persistence.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.software:
|
||||
- VerifyEOSExtensions:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyEOSExtensions"
|
||||
description = "Verifies all EOS extensions installed on the device are enabled for boot persistence."
|
||||
categories = ["software"]
|
||||
commands = [AntaCommand(command="show extensions"), AntaCommand(command="show boot-extensions")]
|
||||
description = "Verifies that all EOS extensions installed on the device are enabled for boot persistence."
|
||||
categories: ClassVar[list[str]] = ["software"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaCommand(command="show extensions", revision=2),
|
||||
AntaCommand(command="show boot-extensions", revision=1),
|
||||
]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyEOSExtensions."""
|
||||
boot_extensions = []
|
||||
show_extensions_command_output = self.instance_commands[0].json_output
|
||||
show_boot_extensions_command_output = self.instance_commands[1].json_output
|
||||
|
@ -80,9 +130,9 @@ class VerifyEOSExtensions(AntaTest):
|
|||
extension for extension, extension_data in show_extensions_command_output["extensions"].items() if extension_data["status"] == "installed"
|
||||
]
|
||||
for extension in show_boot_extensions_command_output["extensions"]:
|
||||
extension = extension.strip("\n")
|
||||
if extension != "":
|
||||
boot_extensions.append(extension)
|
||||
formatted_extension = extension.strip("\n")
|
||||
if formatted_extension != "":
|
||||
boot_extensions.append(formatted_extension)
|
||||
installed_extensions.sort()
|
||||
boot_extensions.sort()
|
||||
if installed_extensions == boot_extensions:
|
||||
|
|
|
@ -1,52 +1,71 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to various Spanning Tree Protocol (STP) settings
|
||||
"""
|
||||
"""Module related to various Spanning Tree Protocol (STP) tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
# Need to keep List for pydantic in python 3.8
|
||||
from typing import List, Literal
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from anta.custom_types import Vlan
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
from anta.tools.get_value import get_value
|
||||
from anta.tools import get_value
|
||||
|
||||
|
||||
class VerifySTPMode(AntaTest):
|
||||
"""
|
||||
Verifies the configured STP mode for a provided list of VLAN(s).
|
||||
"""Verifies the configured STP mode for a provided list of VLAN(s).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the STP mode is configured properly in the specified VLAN(s).
|
||||
* failure: The test will fail if the STP mode is NOT configured properly for one or more specified VLAN(s).
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the STP mode is configured properly in the specified VLAN(s).
|
||||
* Failure: The test will fail if the STP mode is NOT configured properly for one or more specified VLAN(s).
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.stp:
|
||||
- VerifySTPMode:
|
||||
mode: rapidPvst
|
||||
vlans:
|
||||
- 10
|
||||
- 20
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySTPMode"
|
||||
description = "Verifies the configured STP mode for a provided list of VLAN(s)."
|
||||
categories = ["stp"]
|
||||
commands = [AntaTemplate(template="show spanning-tree vlan {vlan}")]
|
||||
categories: ClassVar[list[str]] = ["stp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree vlan {vlan}", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySTPMode test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
mode: Literal["mstp", "rstp", "rapidPvst"] = "mstp"
|
||||
"""STP mode to verify"""
|
||||
vlans: List[Vlan]
|
||||
"""List of VLAN on which to verify STP mode"""
|
||||
"""STP mode to verify. Supported values: mstp, rstp, rapidPvst. Defaults to mstp."""
|
||||
vlans: list[Vlan]
|
||||
"""List of VLAN on which to verify STP mode."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render the template for each VLAN in the input list."""
|
||||
return [template.render(vlan=vlan) for vlan in self.inputs.vlans]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySTPMode."""
|
||||
not_configured = []
|
||||
wrong_stp_mode = []
|
||||
for command in self.instance_commands:
|
||||
if "vlan" in command.params:
|
||||
vlan_id = command.params["vlan"]
|
||||
if not (stp_mode := get_value(command.json_output, f"spanningTreeVlanInstances.{vlan_id}.spanningTreeVlanInstance.protocol")):
|
||||
vlan_id = command.params.vlan
|
||||
if not (
|
||||
stp_mode := get_value(
|
||||
command.json_output,
|
||||
f"spanningTreeVlanInstances.{vlan_id}.spanningTreeVlanInstance.protocol",
|
||||
)
|
||||
):
|
||||
not_configured.append(vlan_id)
|
||||
elif stp_mode != self.inputs.mode:
|
||||
wrong_stp_mode.append(vlan_id)
|
||||
|
@ -59,21 +78,29 @@ class VerifySTPMode(AntaTest):
|
|||
|
||||
|
||||
class VerifySTPBlockedPorts(AntaTest):
|
||||
"""
|
||||
Verifies there is no STP blocked ports.
|
||||
"""Verifies there is no STP blocked ports.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if there are NO ports blocked by STP.
|
||||
* failure: The test will fail if there are ports blocked by STP.
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if there are NO ports blocked by STP.
|
||||
* Failure: The test will fail if there are ports blocked by STP.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.stp:
|
||||
- VerifySTPBlockedPorts:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySTPBlockedPorts"
|
||||
description = "Verifies there is no STP blocked ports."
|
||||
categories = ["stp"]
|
||||
commands = [AntaCommand(command="show spanning-tree blockedports")]
|
||||
categories: ClassVar[list[str]] = ["stp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree blockedports", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySTPBlockedPorts."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if not (stp_instances := command_output["spanningTreeInstances"]):
|
||||
self.result.is_success()
|
||||
|
@ -84,21 +111,29 @@ class VerifySTPBlockedPorts(AntaTest):
|
|||
|
||||
|
||||
class VerifySTPCounters(AntaTest):
|
||||
"""
|
||||
Verifies there is no errors in STP BPDU packets.
|
||||
"""Verifies there is no errors in STP BPDU packets.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if there are NO STP BPDU packet errors under all interfaces participating in STP.
|
||||
* failure: The test will fail if there are STP BPDU packet errors on one or many interface(s).
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if there are NO STP BPDU packet errors under all interfaces participating in STP.
|
||||
* Failure: The test will fail if there are STP BPDU packet errors on one or many interface(s).
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.stp:
|
||||
- VerifySTPCounters:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySTPCounters"
|
||||
description = "Verifies there is no errors in STP BPDU packets."
|
||||
categories = ["stp"]
|
||||
commands = [AntaCommand(command="show spanning-tree counters")]
|
||||
categories: ClassVar[list[str]] = ["stp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree counters", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySTPCounters."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
interfaces_with_errors = [
|
||||
interface for interface, counters in command_output["interfaces"].items() if counters["bpduTaggedError"] or counters["bpduOtherError"] != 0
|
||||
|
@ -110,77 +145,104 @@ class VerifySTPCounters(AntaTest):
|
|||
|
||||
|
||||
class VerifySTPForwardingPorts(AntaTest):
|
||||
"""
|
||||
Verifies that all interfaces are in a forwarding state for a provided list of VLAN(s).
|
||||
"""Verifies that all interfaces are in a forwarding state for a provided list of VLAN(s).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if all interfaces are in a forwarding state for the specified VLAN(s).
|
||||
* failure: The test will fail if one or many interfaces are NOT in a forwarding state in the specified VLAN(s).
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if all interfaces are in a forwarding state for the specified VLAN(s).
|
||||
* Failure: The test will fail if one or many interfaces are NOT in a forwarding state in the specified VLAN(s).
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.stp:
|
||||
- VerifySTPForwardingPorts:
|
||||
vlans:
|
||||
- 10
|
||||
- 20
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySTPForwardingPorts"
|
||||
description = "Verifies that all interfaces are forwarding for a provided list of VLAN(s)."
|
||||
categories = ["stp"]
|
||||
commands = [AntaTemplate(template="show spanning-tree topology vlan {vlan} status")]
|
||||
categories: ClassVar[list[str]] = ["stp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree topology vlan {vlan} status", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
vlans: List[Vlan]
|
||||
"""List of VLAN on which to verify forwarding states"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySTPForwardingPorts test."""
|
||||
|
||||
vlans: list[Vlan]
|
||||
"""List of VLAN on which to verify forwarding states."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render the template for each VLAN in the input list."""
|
||||
return [template.render(vlan=vlan) for vlan in self.inputs.vlans]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySTPForwardingPorts."""
|
||||
not_configured = []
|
||||
not_forwarding = []
|
||||
for command in self.instance_commands:
|
||||
if "vlan" in command.params:
|
||||
vlan_id = command.params["vlan"]
|
||||
vlan_id = command.params.vlan
|
||||
if not (topologies := get_value(command.json_output, "topologies")):
|
||||
not_configured.append(vlan_id)
|
||||
else:
|
||||
for value in topologies.values():
|
||||
if int(vlan_id) in value["vlans"]:
|
||||
if vlan_id and int(vlan_id) in value["vlans"]:
|
||||
interfaces_not_forwarding = [interface for interface, state in value["interfaces"].items() if state["state"] != "forwarding"]
|
||||
if interfaces_not_forwarding:
|
||||
not_forwarding.append({f"VLAN {vlan_id}": interfaces_not_forwarding})
|
||||
if not_configured:
|
||||
self.result.is_failure(f"STP instance is not configured for the following VLAN(s): {not_configured}")
|
||||
if not_forwarding:
|
||||
self.result.is_failure(f"The following VLAN(s) have interface(s) that are not in a fowarding state: {not_forwarding}")
|
||||
self.result.is_failure(f"The following VLAN(s) have interface(s) that are not in a forwarding state: {not_forwarding}")
|
||||
if not not_configured and not interfaces_not_forwarding:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifySTPRootPriority(AntaTest):
|
||||
"""
|
||||
Verifies the STP root priority for a provided list of VLAN or MST instance ID(s).
|
||||
"""Verifies the STP root priority for a provided list of VLAN or MST instance ID(s).
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the STP root priority is configured properly for the specified VLAN or MST instance ID(s).
|
||||
* failure: The test will fail if the STP root priority is NOT configured properly for the specified VLAN or MST instance ID(s).
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the STP root priority is configured properly for the specified VLAN or MST instance ID(s).
|
||||
* Failure: The test will fail if the STP root priority is NOT configured properly for the specified VLAN or MST instance ID(s).
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.stp:
|
||||
- VerifySTPRootPriority:
|
||||
priority: 32768
|
||||
instances:
|
||||
- 10
|
||||
- 20
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySTPRootPriority"
|
||||
description = "Verifies the STP root priority for a provided list of VLAN or MST instance ID(s)."
|
||||
categories = ["stp"]
|
||||
commands = [AntaCommand(command="show spanning-tree root detail")]
|
||||
categories: ClassVar[list[str]] = ["stp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree root detail", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySTPRootPriority test."""
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
priority: int
|
||||
"""STP root priority to verify"""
|
||||
instances: List[Vlan] = []
|
||||
"""STP root priority to verify."""
|
||||
instances: list[Vlan] = Field(default=[])
|
||||
"""List of VLAN or MST instance ID(s). If empty, ALL VLAN or MST instance ID(s) will be verified."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySTPRootPriority."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if not (stp_instances := command_output["instances"]):
|
||||
self.result.is_failure("No STP instances configured")
|
||||
return
|
||||
# Checking the type of instances based on first instance
|
||||
first_name = list(stp_instances)[0]
|
||||
first_name = next(iter(stp_instances))
|
||||
if first_name.startswith("MST"):
|
||||
prefix = "MST"
|
||||
elif first_name.startswith("VL"):
|
||||
|
|
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.
|
||||
# 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 system-level features and protocols
|
||||
"""
|
||||
"""Module related to system-level features and protocols tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from pydantic import conint
|
||||
|
||||
from anta.custom_types import PositiveInteger
|
||||
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):
|
||||
"""
|
||||
This test verifies if the device uptime is higher than the provided minimum uptime value.
|
||||
"""Verifies if the device uptime is higher than the provided minimum uptime value.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.system:
|
||||
- VerifyUptime:
|
||||
minimum: 86400
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyUptime"
|
||||
description = "Verifies the device uptime."
|
||||
categories = ["system"]
|
||||
commands = [AntaCommand(command="show uptime")]
|
||||
categories: ClassVar[list[str]] = ["system"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show uptime", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
minimum: conint(ge=0) # type: ignore
|
||||
"""Minimum uptime in seconds"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyUptime test."""
|
||||
|
||||
minimum: PositiveInteger
|
||||
"""Minimum uptime in seconds."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyUptime."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if command_output["upTime"] > self.inputs.minimum:
|
||||
self.result.is_success()
|
||||
|
@ -43,24 +60,32 @@ class VerifyUptime(AntaTest):
|
|||
|
||||
|
||||
class VerifyReloadCause(AntaTest):
|
||||
"""
|
||||
This test verifies the last reload cause of the device.
|
||||
"""Verifies the last reload cause of the device.
|
||||
|
||||
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.
|
||||
* error: The test will report an error if the reload cause is NOT available.
|
||||
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.
|
||||
* Error: The test will report an error if the reload cause is NOT available.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.system:
|
||||
- VerifyReloadCause:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyReloadCause"
|
||||
description = "Verifies the last reload cause of the device."
|
||||
categories = ["system"]
|
||||
commands = [AntaCommand(command="show reload cause")]
|
||||
categories: ClassVar[list[str]] = ["system"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show reload cause", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyReloadCause."""
|
||||
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")
|
||||
return
|
||||
if len(command_output["resetCauses"]) == 0:
|
||||
|
@ -79,24 +104,33 @@ class VerifyReloadCause(AntaTest):
|
|||
|
||||
|
||||
class VerifyCoredump(AntaTest):
|
||||
"""
|
||||
This test verifies if there are core dump files in the /var/core directory.
|
||||
"""Verifies if there are core dump files in the /var/core directory.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Note:
|
||||
* This test will NOT check for minidump(s) generated by certain agents in /var/core/minidump.
|
||||
Info
|
||||
----
|
||||
* This test will NOT check for minidump(s) generated by certain agents in /var/core/minidump.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.system:
|
||||
- VerifyCoreDump:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyCoredump"
|
||||
description = "Verifies there are no core dump files."
|
||||
categories = ["system"]
|
||||
commands = [AntaCommand(command="show system coredump", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["system"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system coredump", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyCoredump."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
core_files = command_output["coreFiles"]
|
||||
if "minidump" in core_files:
|
||||
|
@ -108,21 +142,29 @@ class VerifyCoredump(AntaTest):
|
|||
|
||||
|
||||
class VerifyAgentLogs(AntaTest):
|
||||
"""
|
||||
This test verifies that no agent crash reports are present on the device.
|
||||
"""Verifies that no agent crash reports are present on the device.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.system:
|
||||
- VerifyAgentLogs:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyAgentLogs"
|
||||
description = "Verifies there are no agent crash reports."
|
||||
categories = ["system"]
|
||||
commands = [AntaCommand(command="show agent logs crash", ofmt="text")]
|
||||
categories: ClassVar[list[str]] = ["system"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show agent logs crash", ofmt="text")]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyAgentLogs."""
|
||||
command_output = self.instance_commands[0].text_output
|
||||
if len(command_output) == 0:
|
||||
self.result.is_success()
|
||||
|
@ -133,92 +175,124 @@ class VerifyAgentLogs(AntaTest):
|
|||
|
||||
|
||||
class VerifyCPUUtilization(AntaTest):
|
||||
"""
|
||||
This test verifies whether the CPU utilization is below 75%.
|
||||
"""Verifies whether the CPU utilization is below 75%.
|
||||
|
||||
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%.
|
||||
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%.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.system:
|
||||
- VerifyCPUUtilization:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyCPUUtilization"
|
||||
description = "Verifies whether the CPU utilization is below 75%."
|
||||
categories = ["system"]
|
||||
commands = [AntaCommand(command="show processes top once")]
|
||||
categories: ClassVar[list[str]] = ["system"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show processes top once", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyCPUUtilization."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
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()
|
||||
else:
|
||||
self.result.is_failure(f"Device has reported a high CPU utilization: {100 - command_output_data}%")
|
||||
|
||||
|
||||
class VerifyMemoryUtilization(AntaTest):
|
||||
"""
|
||||
This test verifies whether the memory utilization is below 75%.
|
||||
"""Verifies whether the memory utilization is below 75%.
|
||||
|
||||
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%.
|
||||
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%.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.system:
|
||||
- VerifyMemoryUtilization:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyMemoryUtilization"
|
||||
description = "Verifies whether the memory utilization is below 75%."
|
||||
categories = ["system"]
|
||||
commands = [AntaCommand(command="show version")]
|
||||
categories: ClassVar[list[str]] = ["system"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyMemoryUtilization."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
memory_usage = command_output["memFree"] / command_output["memTotal"]
|
||||
if memory_usage > 0.25:
|
||||
if memory_usage > MEMORY_THRESHOLD:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(f"Device has reported a high memory usage: {(1 - memory_usage)*100:.2f}%")
|
||||
|
||||
|
||||
class VerifyFileSystemUtilization(AntaTest):
|
||||
"""
|
||||
This test verifies that no partition is utilizing more than 75% of its disk space.
|
||||
"""Verifies that no partition is utilizing more than 75% of its disk space.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.system:
|
||||
- VerifyFileSystemUtilization:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyFileSystemUtilization"
|
||||
description = "Verifies that no partition is utilizing more than 75% of its disk space."
|
||||
categories = ["system"]
|
||||
commands = [AntaCommand(command="bash timeout 10 df -h", ofmt="text")]
|
||||
categories: ClassVar[list[str]] = ["system"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="bash timeout 10 df -h", ofmt="text")]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyFileSystemUtilization."""
|
||||
command_output = self.instance_commands[0].text_output
|
||||
self.result.is_success()
|
||||
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}%")
|
||||
|
||||
|
||||
class VerifyNTP(AntaTest):
|
||||
"""
|
||||
This test verifies that the Network Time Protocol (NTP) is synchronized.
|
||||
"""Verifies that the Network Time Protocol (NTP) is synchronized.
|
||||
|
||||
Expected Results:
|
||||
* success: The test will pass if the NTP is synchronised.
|
||||
* failure: The test will fail if the NTP is NOT synchronised.
|
||||
Expected Results
|
||||
----------------
|
||||
* 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"
|
||||
description = "Verifies if NTP is synchronised."
|
||||
categories = ["system"]
|
||||
commands = [AntaCommand(command="show ntp status", ofmt="text")]
|
||||
categories: ClassVar[list[str]] = ["system"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ntp status", ofmt="text")]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyNTP."""
|
||||
command_output = self.instance_commands[0].text_output
|
||||
if command_output.split("\n")[0].split(" ")[0] == "synchronised":
|
||||
self.result.is_success()
|
||||
|
|
|
@ -1,42 +1,53 @@
|
|||
# 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 VLAN
|
||||
"""
|
||||
"""Module related to VLAN tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from typing import TYPE_CHECKING, ClassVar, Literal
|
||||
|
||||
from anta.custom_types import Vlan
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
from anta.tools.get_value import get_value
|
||||
from anta.tools.utils import get_failed_logs
|
||||
from anta.tools import get_failed_logs, get_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
||||
|
||||
class VerifyVlanInternalPolicy(AntaTest):
|
||||
"""
|
||||
This class checks if the VLAN internal allocation policy is ascending or descending and
|
||||
if the VLANs are within the specified range.
|
||||
"""Verifies if the VLAN internal allocation policy is ascending or descending and if the VLANs are within the specified range.
|
||||
|
||||
Expected Results:
|
||||
* Success: The test will pass if the VLAN internal allocation policy is either ascending or descending
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the VLAN internal allocation policy is either ascending or descending
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.vlan:
|
||||
- VerifyVlanInternalPolicy:
|
||||
policy: ascending
|
||||
start_vlan_id: 1006
|
||||
end_vlan_id: 4094
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyVlanInternalPolicy"
|
||||
description = "This test checks the VLAN internal allocation policy and the range of VLANs."
|
||||
categories = ["vlan"]
|
||||
commands = [AntaCommand(command="show vlan internal allocation policy")]
|
||||
description = "Verifies the VLAN internal allocation policy and the range of VLANs."
|
||||
categories: ClassVar[list[str]] = ["vlan"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vlan internal allocation policy", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Inputs for the VerifyVlanInternalPolicy test."""
|
||||
"""Input model for the VerifyVlanInternalPolicy test."""
|
||||
|
||||
policy: Literal["ascending", "descending"]
|
||||
"""The VLAN internal allocation policy."""
|
||||
"""The VLAN internal allocation policy. Supported values: ascending, descending."""
|
||||
start_vlan_id: Vlan
|
||||
"""The starting VLAN ID in the range."""
|
||||
end_vlan_id: Vlan
|
||||
|
@ -44,6 +55,7 @@ class VerifyVlanInternalPolicy(AntaTest):
|
|||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyVlanInternalPolicy."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
keys_to_verify = ["policy", "startVlanId", "endVlanId"]
|
||||
|
|
|
@ -1,44 +1,54 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""
|
||||
Test functions related to VXLAN
|
||||
"""
|
||||
"""Module related to VXLAN tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
# Need to keep List and Dict for pydantic in python 3.8
|
||||
from typing import Dict, List
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from anta.custom_types import Vlan, Vni, VxlanSrcIntf
|
||||
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):
|
||||
"""
|
||||
This test verifies if the Vxlan1 interface is configured and 'up/up'.
|
||||
"""Verifies if the Vxlan1 interface is configured and 'up/up'.
|
||||
|
||||
!!! warning
|
||||
The name of this test has been updated from 'VerifyVxlan' for better representation.
|
||||
Warning
|
||||
-------
|
||||
The name of this test has been updated from 'VerifyVxlan' for better representation.
|
||||
|
||||
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'.
|
||||
* skipped: The test will be skipped if the Vxlan1 interface is not configured.
|
||||
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'.
|
||||
* Skipped: The test will be skipped if the Vxlan1 interface is not configured.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.vxlan:
|
||||
- VerifyVxlan1Interface:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyVxlan1Interface"
|
||||
description = "Verifies the Vxlan1 interface status."
|
||||
categories = ["vxlan"]
|
||||
commands = [AntaCommand(command="show interfaces description", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["vxlan"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces description", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyVxlan1Interface."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if "Vxlan1" not in command_output["interfaceDescriptions"]:
|
||||
self.result.is_skipped("Vxlan1 interface is not configured")
|
||||
|
@ -50,27 +60,35 @@ class VerifyVxlan1Interface(AntaTest):
|
|||
else:
|
||||
self.result.is_failure(
|
||||
f"Vxlan1 interface is {command_output['interfaceDescriptions']['Vxlan1']['lineProtocolStatus']}"
|
||||
f"/{command_output['interfaceDescriptions']['Vxlan1']['interfaceStatus']}"
|
||||
f"/{command_output['interfaceDescriptions']['Vxlan1']['interfaceStatus']}",
|
||||
)
|
||||
|
||||
|
||||
class VerifyVxlanConfigSanity(AntaTest):
|
||||
"""
|
||||
This test verifies that no issues are detected with the VXLAN configuration.
|
||||
"""Verifies that no issues are detected with the VXLAN configuration.
|
||||
|
||||
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.
|
||||
* skipped: The test will be skipped if VXLAN is not configured on the device.
|
||||
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.
|
||||
* Skipped: The test will be skipped if VXLAN is not configured on the device.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.vxlan:
|
||||
- VerifyVxlanConfigSanity:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyVxlanConfigSanity"
|
||||
description = "Verifies there are no VXLAN config-sanity inconsistencies."
|
||||
categories = ["vxlan"]
|
||||
commands = [AntaCommand(command="show vxlan config-sanity", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["vxlan"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan config-sanity", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyVxlanConfigSanity."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if "categories" not in command_output or len(command_output["categories"]) == 0:
|
||||
self.result.is_skipped("VXLAN is not configured")
|
||||
|
@ -87,26 +105,39 @@ class VerifyVxlanConfigSanity(AntaTest):
|
|||
|
||||
|
||||
class VerifyVxlanVniBinding(AntaTest):
|
||||
"""
|
||||
This test verifies the VNI-VLAN bindings of the Vxlan1 interface.
|
||||
"""Verifies the VNI-VLAN bindings of the Vxlan1 interface.
|
||||
|
||||
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.
|
||||
* skipped: The test will be skipped if the Vxlan1 interface is not configured.
|
||||
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.
|
||||
* 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"
|
||||
description = "Verifies the VNI-VLAN bindings of the Vxlan1 interface."
|
||||
categories = ["vxlan"]
|
||||
commands = [AntaCommand(command="show vxlan vni", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["vxlan"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan vni", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
bindings: Dict[Vni, Vlan]
|
||||
"""VNI to VLAN bindings to verify"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyVxlanVniBinding test."""
|
||||
|
||||
bindings: dict[Vni, Vlan]
|
||||
"""VNI to VLAN bindings to verify."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyVxlanVniBinding."""
|
||||
self.result.is_success()
|
||||
|
||||
no_binding = []
|
||||
|
@ -117,17 +148,17 @@ class VerifyVxlanVniBinding(AntaTest):
|
|||
return
|
||||
|
||||
for vni, vlan in self.inputs.bindings.items():
|
||||
vni = str(vni)
|
||||
if vni in vxlan1["vniBindings"]:
|
||||
retrieved_vlan = vxlan1["vniBindings"][vni]["vlan"]
|
||||
elif vni in vxlan1["vniBindingsToVrf"]:
|
||||
retrieved_vlan = vxlan1["vniBindingsToVrf"][vni]["vlan"]
|
||||
str_vni = str(vni)
|
||||
if str_vni in vxlan1["vniBindings"]:
|
||||
retrieved_vlan = vxlan1["vniBindings"][str_vni]["vlan"]
|
||||
elif str_vni in vxlan1["vniBindingsToVrf"]:
|
||||
retrieved_vlan = vxlan1["vniBindingsToVrf"][str_vni]["vlan"]
|
||||
else:
|
||||
no_binding.append(vni)
|
||||
no_binding.append(str_vni)
|
||||
retrieved_vlan = None
|
||||
|
||||
if retrieved_vlan and vlan != retrieved_vlan:
|
||||
wrong_binding.append({vni: retrieved_vlan})
|
||||
wrong_binding.append({str_vni: retrieved_vlan})
|
||||
|
||||
if 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):
|
||||
"""
|
||||
This test verifies the VTEP peers of the Vxlan1 interface.
|
||||
"""Verifies the VTEP peers of the Vxlan1 interface.
|
||||
|
||||
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.
|
||||
* skipped: The test will be skipped if the Vxlan1 interface is not configured.
|
||||
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.
|
||||
* 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"
|
||||
description = "Verifies the VTEP peers of the Vxlan1 interface"
|
||||
categories = ["vxlan"]
|
||||
commands = [AntaCommand(command="show vxlan vtep", ofmt="json")]
|
||||
categories: ClassVar[list[str]] = ["vxlan"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan vtep", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
|
||||
vteps: List[IPv4Address]
|
||||
"""List of VTEP peers to verify"""
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyVxlanVtep test."""
|
||||
|
||||
vteps: list[IPv4Address]
|
||||
"""List of VTEP peers to verify."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyVxlanVtep."""
|
||||
self.result.is_success()
|
||||
|
||||
inputs_vteps = [str(input_vtep) for input_vtep in self.inputs.vteps]
|
||||
|
@ -176,30 +220,40 @@ class VerifyVxlanVtep(AntaTest):
|
|||
|
||||
|
||||
class VerifyVxlan1ConnSettings(AntaTest):
|
||||
"""
|
||||
Verifies the interface vxlan1 source interface and UDP port.
|
||||
"""Verifies the interface vxlan1 source interface and UDP port.
|
||||
|
||||
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.
|
||||
* skipped: Skips if the Vxlan1 interface is not configured.
|
||||
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.
|
||||
* Skipped: Skips if the Vxlan1 interface is not configured.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.vxlan:
|
||||
- VerifyVxlan1ConnSettings:
|
||||
source_interface: Loopback1
|
||||
udp_port: 4789
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyVxlan1ConnSettings"
|
||||
description = "Verifies the interface vxlan1 source interface and UDP port."
|
||||
categories = ["vxlan"]
|
||||
commands = [AntaCommand(command="show interfaces")]
|
||||
categories: ClassVar[list[str]] = ["vxlan"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Inputs for the VerifyVxlan1ConnSettings test."""
|
||||
"""Input model for the VerifyVxlan1ConnSettings test."""
|
||||
|
||||
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 used for vxlan1 interface"""
|
||||
"""UDP port used for vxlan1 interface."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyVxlan1ConnSettings."""
|
||||
self.result.is_success()
|
||||
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,3 +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.
|
|
@ -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)
|
Loading…
Add table
Add a link
Reference in a new issue