Merging upstream version 1.1.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
50f8dbf7e8
commit
2044ea6182
196 changed files with 10121 additions and 3780 deletions
|
@ -20,7 +20,10 @@ __credits__ = [
|
|||
__copyright__ = "Copyright 2022-2024, Arista Networks, Inc."
|
||||
|
||||
# ANTA Debug Mode environment variable
|
||||
__DEBUG__ = bool(os.environ.get("ANTA_DEBUG", "").lower() == "true")
|
||||
__DEBUG__ = os.environ.get("ANTA_DEBUG", "").lower() == "true"
|
||||
if __DEBUG__:
|
||||
# enable asyncio DEBUG mode when __DEBUG__ is enabled
|
||||
os.environ["PYTHONASYNCIODEBUG"] = "1"
|
||||
|
||||
|
||||
# Source: https://rich.readthedocs.io/en/stable/appendix/colors.html
|
||||
|
|
214
anta/catalog.py
214
anta/catalog.py
|
@ -10,21 +10,29 @@ import logging
|
|||
import math
|
||||
from collections import defaultdict
|
||||
from inspect import isclass
|
||||
from itertools import chain
|
||||
from json import load as json_load
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Optional, Union
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
|
||||
from warnings import warn
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_serializer, model_validator
|
||||
from pydantic.types import ImportString
|
||||
from pydantic_core import PydanticCustomError
|
||||
from yaml import YAMLError, safe_load
|
||||
from yaml import YAMLError, safe_dump, safe_load
|
||||
|
||||
from anta.logger import anta_log_exception
|
||||
from anta.models import AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sys
|
||||
from types import ModuleType
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self
|
||||
else:
|
||||
from typing_extensions import Self
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# { <module_name> : [ { <test_class_name>: <input_as_dict_or_None> }, ... ] }
|
||||
|
@ -37,8 +45,12 @@ ListAntaTestTuples = list[tuple[type[AntaTest], Optional[Union[AntaTest.Input, d
|
|||
class AntaTestDefinition(BaseModel):
|
||||
"""Define a test with its associated inputs.
|
||||
|
||||
test: An AntaTest concrete subclass
|
||||
inputs: The associated AntaTest.Input subclass instance
|
||||
Attributes
|
||||
----------
|
||||
test
|
||||
An AntaTest concrete subclass.
|
||||
inputs
|
||||
The associated AntaTest.Input subclass instance.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
@ -58,6 +70,7 @@ class AntaTestDefinition(BaseModel):
|
|||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
A dictionary representing the model.
|
||||
"""
|
||||
return {self.test.__name__: self.inputs}
|
||||
|
@ -116,7 +129,7 @@ class AntaTestDefinition(BaseModel):
|
|||
raise ValueError(msg)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_inputs(self) -> AntaTestDefinition:
|
||||
def check_inputs(self) -> Self:
|
||||
"""Check the `inputs` field typing.
|
||||
|
||||
The `inputs` class attribute needs to be an instance of the AntaTest.Input subclass defined in the class `test`.
|
||||
|
@ -130,14 +143,14 @@ class AntaTestDefinition(BaseModel):
|
|||
class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods
|
||||
"""Represents an ANTA Test Catalog File.
|
||||
|
||||
Example:
|
||||
Example
|
||||
-------
|
||||
A valid test catalog file must have the following structure:
|
||||
```
|
||||
<Python module>:
|
||||
- <AntaTest subclass>:
|
||||
<AntaTest.Input compliant dictionary>
|
||||
```
|
||||
A valid test catalog file must have the following structure:
|
||||
```
|
||||
<Python module>:
|
||||
- <AntaTest subclass>:
|
||||
<AntaTest.Input compliant dictionary>
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
|
@ -147,16 +160,16 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
|
|||
def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[ModuleType, list[Any]]:
|
||||
"""Allow the user to provide a data structure with nested Python modules.
|
||||
|
||||
Example:
|
||||
Example
|
||||
-------
|
||||
```
|
||||
anta.tests.routing:
|
||||
generic:
|
||||
- <AntaTestDefinition>
|
||||
bgp:
|
||||
- <AntaTestDefinition>
|
||||
```
|
||||
`anta.tests.routing.generic` and `anta.tests.routing.bgp` are importable Python modules.
|
||||
```
|
||||
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]] = {}
|
||||
|
@ -166,7 +179,7 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
|
|||
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
|
||||
except Exception as e:
|
||||
# A test module is potentially user-defined code.
|
||||
# We need to catch everything if we want to have meaningful logs
|
||||
module_str = f"{module_name[1:] if module_name.startswith('.') else module_name}{f' from package {package}' if package else ''}"
|
||||
|
@ -232,13 +245,24 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
|
|||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The YAML representation string of this model.
|
||||
"""
|
||||
# TODO: Pydantic and YAML serialization/deserialization is not supported natively.
|
||||
# This could be improved.
|
||||
# https://github.com/pydantic/pydantic/issues/1043
|
||||
# Explore if this worth using this: https://github.com/NowanIlfideme/pydantic-yaml
|
||||
return yaml.safe_dump(yaml.safe_load(self.model_dump_json(serialize_as_any=True, exclude_unset=True)), indent=2, width=math.inf)
|
||||
return safe_dump(safe_load(self.model_dump_json(serialize_as_any=True, exclude_unset=True)), indent=2, width=math.inf)
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Return a JSON representation string of this model.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The JSON representation string of this model.
|
||||
"""
|
||||
return self.model_dump_json(serialize_as_any=True, exclude_unset=True, indent=2)
|
||||
|
||||
|
||||
class AntaCatalog:
|
||||
|
@ -254,10 +278,12 @@ class AntaCatalog:
|
|||
) -> None:
|
||||
"""Instantiate an AntaCatalog instance.
|
||||
|
||||
Args:
|
||||
----
|
||||
tests: A list of AntaTestDefinition instances.
|
||||
filename: The path from which the catalog is loaded.
|
||||
Parameters
|
||||
----------
|
||||
tests
|
||||
A list of AntaTestDefinition instances.
|
||||
filename
|
||||
The path from which the catalog is loaded.
|
||||
|
||||
"""
|
||||
self._tests: list[AntaTestDefinition] = []
|
||||
|
@ -270,11 +296,14 @@ class AntaCatalog:
|
|||
else:
|
||||
self._filename = Path(filename)
|
||||
|
||||
# Default indexes for faster access
|
||||
self.tag_to_tests: defaultdict[str | None, set[AntaTestDefinition]] = defaultdict(set)
|
||||
self.tests_without_tags: set[AntaTestDefinition] = set()
|
||||
self.indexes_built: bool = False
|
||||
self.final_tests_count: int = 0
|
||||
self.indexes_built: bool
|
||||
self.tag_to_tests: defaultdict[str | None, set[AntaTestDefinition]]
|
||||
self._init_indexes()
|
||||
|
||||
def _init_indexes(self) -> None:
|
||||
"""Init indexes related variables."""
|
||||
self.tag_to_tests = defaultdict(set)
|
||||
self.indexes_built = False
|
||||
|
||||
@property
|
||||
def filename(self) -> Path | None:
|
||||
|
@ -298,19 +327,30 @@ class AntaCatalog:
|
|||
self._tests = value
|
||||
|
||||
@staticmethod
|
||||
def parse(filename: str | Path) -> AntaCatalog:
|
||||
def parse(filename: str | Path, file_format: Literal["yaml", "json"] = "yaml") -> AntaCatalog:
|
||||
"""Create an AntaCatalog instance from a test catalog file.
|
||||
|
||||
Args:
|
||||
----
|
||||
filename: Path to test catalog YAML file
|
||||
Parameters
|
||||
----------
|
||||
filename
|
||||
Path to test catalog YAML or JSON file.
|
||||
file_format
|
||||
Format of the file, either 'yaml' or 'json'.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AntaCatalog
|
||||
An AntaCatalog populated with the file content.
|
||||
"""
|
||||
if file_format not in ["yaml", "json"]:
|
||||
message = f"'{file_format}' is not a valid format for an AntaCatalog file. Only 'yaml' and 'json' are supported."
|
||||
raise ValueError(message)
|
||||
|
||||
try:
|
||||
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:
|
||||
data = safe_load(f) if file_format == "yaml" else json_load(f)
|
||||
except (TypeError, YAMLError, OSError, ValueError) as e:
|
||||
message = f"Unable to parse ANTA Test Catalog file '{filename}'"
|
||||
anta_log_exception(e, message, logger)
|
||||
raise
|
||||
|
@ -325,11 +365,17 @@ class AntaCatalog:
|
|||
It is the data structure returned by `yaml.load()` function of a valid
|
||||
YAML Test Catalog file.
|
||||
|
||||
Args:
|
||||
----
|
||||
data: Python dictionary used to instantiate the AntaCatalog instance
|
||||
filename: value to be set as AntaCatalog instance attribute
|
||||
Parameters
|
||||
----------
|
||||
data
|
||||
Python dictionary used to instantiate the AntaCatalog instance.
|
||||
filename
|
||||
value to be set as AntaCatalog instance attribute
|
||||
|
||||
Returns
|
||||
-------
|
||||
AntaCatalog
|
||||
An AntaCatalog populated with the 'data' dictionary content.
|
||||
"""
|
||||
tests: list[AntaTestDefinition] = []
|
||||
if data is None:
|
||||
|
@ -359,10 +405,15 @@ class AntaCatalog:
|
|||
|
||||
See ListAntaTestTuples type alias for details.
|
||||
|
||||
Args:
|
||||
----
|
||||
data: Python list used to instantiate the AntaCatalog instance
|
||||
Parameters
|
||||
----------
|
||||
data
|
||||
Python list used to instantiate the AntaCatalog instance.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AntaCatalog
|
||||
An AntaCatalog populated with the 'data' list content.
|
||||
"""
|
||||
tests: list[AntaTestDefinition] = []
|
||||
try:
|
||||
|
@ -372,24 +423,54 @@ class AntaCatalog:
|
|||
raise
|
||||
return AntaCatalog(tests)
|
||||
|
||||
def merge(self, catalog: AntaCatalog) -> AntaCatalog:
|
||||
"""Merge two AntaCatalog instances.
|
||||
@classmethod
|
||||
def merge_catalogs(cls, catalogs: list[AntaCatalog]) -> AntaCatalog:
|
||||
"""Merge multiple AntaCatalog instances.
|
||||
|
||||
Args:
|
||||
----
|
||||
catalog: AntaCatalog instance to merge to this instance.
|
||||
Parameters
|
||||
----------
|
||||
catalogs
|
||||
A list of AntaCatalog instances to merge.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AntaCatalog
|
||||
A new AntaCatalog instance containing the tests of all the input catalogs.
|
||||
"""
|
||||
combined_tests = list(chain(*(catalog.tests for catalog in catalogs)))
|
||||
return cls(tests=combined_tests)
|
||||
|
||||
def merge(self, catalog: AntaCatalog) -> AntaCatalog:
|
||||
"""Merge two AntaCatalog instances.
|
||||
|
||||
Warning
|
||||
-------
|
||||
This method is deprecated and will be removed in ANTA v2.0. Use `AntaCatalog.merge_catalogs()` instead.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
catalog
|
||||
AntaCatalog instance to merge to this instance.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AntaCatalog
|
||||
A new AntaCatalog instance containing the tests of the two instances.
|
||||
"""
|
||||
return AntaCatalog(tests=self.tests + catalog.tests)
|
||||
# TODO: Use a decorator to deprecate this method instead. See https://github.com/aristanetworks/anta/issues/754
|
||||
warn(
|
||||
message="AntaCatalog.merge() is deprecated and will be removed in ANTA v2.0. Use AntaCatalog.merge_catalogs() instead.",
|
||||
category=DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self.merge_catalogs([self, catalog])
|
||||
|
||||
def dump(self) -> AntaCatalogFile:
|
||||
"""Return an AntaCatalogFile instance from this AntaCatalog instance.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AntaCatalogFile
|
||||
An AntaCatalogFile instance containing tests of this AntaCatalog instance.
|
||||
"""
|
||||
root: dict[ImportString[Any], list[AntaTestDefinition]] = {}
|
||||
|
@ -403,9 +484,7 @@ class AntaCatalog:
|
|||
|
||||
If a `filtered_tests` set is provided, only the tests in this set will be indexed.
|
||||
|
||||
This method populates two attributes:
|
||||
- tag_to_tests: A dictionary mapping each tag to a set of tests that contain it.
|
||||
- tests_without_tags: A set of tests that do not have any tags.
|
||||
This method populates the tag_to_tests attribute, which is a dictionary mapping tags to sets of tests.
|
||||
|
||||
Once the indexes are built, the `indexes_built` attribute is set to True.
|
||||
"""
|
||||
|
@ -419,27 +498,34 @@ class AntaCatalog:
|
|||
for tag in test_tags:
|
||||
self.tag_to_tests[tag].add(test)
|
||||
else:
|
||||
self.tests_without_tags.add(test)
|
||||
self.tag_to_tests[None].add(test)
|
||||
|
||||
self.tag_to_tests[None] = self.tests_without_tags
|
||||
self.indexes_built = True
|
||||
|
||||
def clear_indexes(self) -> None:
|
||||
"""Clear this AntaCatalog instance indexes."""
|
||||
self._init_indexes()
|
||||
|
||||
def get_tests_by_tags(self, tags: set[str], *, strict: bool = False) -> set[AntaTestDefinition]:
|
||||
"""Return all tests that match a given set of tags, according to the specified strictness.
|
||||
|
||||
Args:
|
||||
----
|
||||
tags: The tags to filter tests by. If empty, return all tests without tags.
|
||||
strict: If True, returns only tests that contain all specified tags (intersection).
|
||||
If False, returns tests that contain any of the specified tags (union).
|
||||
Parameters
|
||||
----------
|
||||
tags
|
||||
The tags to filter tests by. If empty, return all tests without tags.
|
||||
strict
|
||||
If True, returns only tests that contain all specified tags (intersection).
|
||||
If False, returns tests that contain any of the specified tags (union).
|
||||
|
||||
Returns
|
||||
-------
|
||||
set[AntaTestDefinition]: A set of tests that match the given tags.
|
||||
set[AntaTestDefinition]
|
||||
A set of tests that match the given tags.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError: If the indexes have not been built prior to method call.
|
||||
ValueError
|
||||
If the indexes have not been built prior to method call.
|
||||
"""
|
||||
if not self.indexes_built:
|
||||
msg = "Indexes have not been built yet. Call build_indexes() first."
|
||||
|
|
|
@ -25,7 +25,8 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
@click.group(cls=AliasedGroup)
|
||||
@click.pass_context
|
||||
@click.version_option(__version__)
|
||||
@click.help_option(allow_from_autoenv=False)
|
||||
@click.version_option(__version__, allow_from_autoenv=False)
|
||||
@click.option(
|
||||
"--log-file",
|
||||
help="Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout.",
|
||||
|
@ -61,7 +62,7 @@ def cli() -> None:
|
|||
"""Entrypoint for pyproject.toml."""
|
||||
try:
|
||||
anta(obj={}, auto_envvar_prefix="ANTA")
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
except Exception as exc: # noqa: BLE001
|
||||
anta_log_exception(
|
||||
exc,
|
||||
f"Uncaught Exception when running ANTA CLI\n{GITHUB_SUGGESTION}",
|
||||
|
|
|
@ -35,7 +35,6 @@ def run_cmd(
|
|||
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
|
||||
|
@ -71,14 +70,16 @@ def run_template(
|
|||
version: Literal["1", "latest"],
|
||||
revision: int,
|
||||
) -> None:
|
||||
# pylint: disable=too-many-arguments
|
||||
# Using \b for click
|
||||
# ruff: noqa: D301
|
||||
"""Run arbitrary templated command to an ANTA device.
|
||||
|
||||
Takes a list of arguments (keys followed by a value) to build a dictionary used as template parameters.
|
||||
|
||||
Example:
|
||||
\b
|
||||
Example
|
||||
-------
|
||||
anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1
|
||||
anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1
|
||||
|
||||
"""
|
||||
template_params = dict(zip(params[::2], params[1::2]))
|
||||
|
|
|
@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, Callable
|
|||
|
||||
import click
|
||||
|
||||
from anta.cli.utils import ExitCode, inventory_options
|
||||
from anta.cli.utils import ExitCode, core_options
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.inventory import AntaInventory
|
||||
|
@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
|
|||
def debug_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Click common options required to execute a command on a specific device."""
|
||||
|
||||
@inventory_options
|
||||
@core_options
|
||||
@click.option(
|
||||
"--ofmt",
|
||||
type=click.Choice(["json", "text"]),
|
||||
|
@ -44,12 +44,10 @@ def debug_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
|||
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/aristanetworks/anta/issues/584
|
||||
# pylint: disable=unused-argument
|
||||
# ruff: noqa: ARG001
|
||||
if (d := inventory.get(device)) is None:
|
||||
logger.error("Device '%s' does not exist in Inventory", device)
|
||||
|
|
|
@ -9,7 +9,7 @@ from anta.cli.exec import commands
|
|||
|
||||
|
||||
@click.group("exec")
|
||||
def _exec() -> None: # pylint: disable=redefined-builtin
|
||||
def _exec() -> None:
|
||||
"""Commands to execute various scripts on EOS devices."""
|
||||
|
||||
|
||||
|
|
|
@ -10,16 +10,15 @@ import asyncio
|
|||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from click.exceptions import UsageError
|
||||
from httpx import ConnectError, HTTPError
|
||||
|
||||
from anta.custom_types import REGEXP_PATH_MARKERS
|
||||
from anta.device import AntaDevice, AsyncEOSDevice
|
||||
from anta.models import AntaCommand
|
||||
from anta.tools import safe_command
|
||||
from asynceapi import EapiCommandError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
@ -52,7 +51,7 @@ async def clear_counters(anta_inventory: AntaInventory, tags: set[str] | None =
|
|||
|
||||
async def collect_commands(
|
||||
inv: AntaInventory,
|
||||
commands: dict[str, str],
|
||||
commands: dict[str, list[str]],
|
||||
root_dir: Path,
|
||||
tags: set[str] | None = None,
|
||||
) -> None:
|
||||
|
@ -61,17 +60,16 @@ async def collect_commands(
|
|||
async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "text"]) -> None:
|
||||
outdir = Path() / root_dir / dev.name / outformat
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
safe_command = re.sub(rf"{REGEXP_PATH_MARKERS}", "_", command)
|
||||
c = AntaCommand(command=command, ofmt=outformat)
|
||||
await dev.collect(c)
|
||||
if not c.collected:
|
||||
logger.error("Could not collect commands on device %s: %s", dev.name, c.errors)
|
||||
return
|
||||
if c.ofmt == "json":
|
||||
outfile = outdir / f"{safe_command}.json"
|
||||
outfile = outdir / f"{safe_command(command)}.json"
|
||||
content = json.dumps(c.json_output, indent=2)
|
||||
elif c.ofmt == "text":
|
||||
outfile = outdir / f"{safe_command}.log"
|
||||
outfile = outdir / f"{safe_command(command)}.log"
|
||||
content = c.text_output
|
||||
else:
|
||||
logger.error("Command outformat is not in ['json', 'text'] for command '%s'", command)
|
||||
|
@ -83,6 +81,9 @@ async def collect_commands(
|
|||
logger.info("Connecting to devices...")
|
||||
await inv.connect_inventory()
|
||||
devices = inv.get_inventory(established_only=True, tags=tags).devices
|
||||
if not devices:
|
||||
logger.info("No online device found. Exiting")
|
||||
return
|
||||
logger.info("Collecting commands from remote devices")
|
||||
coros = []
|
||||
if "json_format" in commands:
|
||||
|
@ -134,8 +135,8 @@ async def collect_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bo
|
|||
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
|
||||
if device.enable and device._enable_password is not None:
|
||||
commands.append({"cmd": "enable", "input": device._enable_password})
|
||||
elif device.enable:
|
||||
commands.append({"cmd": "enable"})
|
||||
commands.extend(
|
||||
|
@ -146,7 +147,7 @@ async def collect_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bo
|
|||
)
|
||||
logger.warning("Configuring 'aaa authorization exec default local' on device %s", device.name)
|
||||
command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text")
|
||||
await device._session.cli(commands=commands) # pylint: disable=protected-access
|
||||
await device._session.cli(commands=commands)
|
||||
logger.info("Configured 'aaa authorization exec default local' on device %s", device.name)
|
||||
|
||||
logger.debug("'aaa authorization exec default local' is already configured on device %s", device.name)
|
||||
|
|
|
@ -45,7 +45,6 @@ logger = logging.getLogger(__name__)
|
|||
default=False,
|
||||
)
|
||||
def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str | None, *, ignore_cert: bool) -> None:
|
||||
# pylint: disable=too-many-arguments
|
||||
"""Build ANTA inventory from CloudVision.
|
||||
|
||||
NOTE: Only username/password authentication is supported for on-premises CloudVision instances.
|
||||
|
@ -127,7 +126,6 @@ def inventory(inventory: AntaInventory, tags: set[str] | None, *, connected: boo
|
|||
@click.command
|
||||
@inventory_options
|
||||
def tags(inventory: AntaInventory, **kwargs: Any) -> None:
|
||||
# pylint: disable=unused-argument
|
||||
"""Get list of configured tags in user inventory."""
|
||||
tags: set[str] = set()
|
||||
for device in inventory.values():
|
||||
|
|
|
@ -82,20 +82,26 @@ def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str, *, verify_ce
|
|||
|
||||
TODO: need to handle requests error
|
||||
|
||||
Args:
|
||||
----
|
||||
cvp_ip: IP address of CloudVision.
|
||||
cvp_username: Username to connect to CloudVision.
|
||||
cvp_password: Password to connect to CloudVision.
|
||||
verify_cert: Enable or disable certificate verification when connecting to CloudVision.
|
||||
Parameters
|
||||
----------
|
||||
cvp_ip
|
||||
IP address of CloudVision.
|
||||
cvp_username
|
||||
Username to connect to CloudVision.
|
||||
cvp_password
|
||||
Password to connect to CloudVision.
|
||||
verify_cert
|
||||
Enable or disable certificate verification when connecting to CloudVision.
|
||||
|
||||
Returns
|
||||
-------
|
||||
token(str): The token to use in further API calls to CloudVision.
|
||||
str
|
||||
The token to use in further API calls to CloudVision.
|
||||
|
||||
Raises
|
||||
------
|
||||
requests.ssl.SSLError: If the certificate verification fails
|
||||
requests.ssl.SSLError
|
||||
If the certificate verification fails.
|
||||
|
||||
"""
|
||||
# use CVP REST API to generate a token
|
||||
|
@ -161,11 +167,14 @@ def deep_yaml_parsing(data: dict[str, Any], hosts: list[AntaInventoryHost] | Non
|
|||
def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group: str = "all") -> None:
|
||||
"""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.
|
||||
Parameters
|
||||
----------
|
||||
inventory
|
||||
Ansible Inventory file to read.
|
||||
output
|
||||
ANTA inventory file to generate.
|
||||
ansible_group
|
||||
Ansible group from where to extract data.
|
||||
|
||||
"""
|
||||
try:
|
||||
|
|
|
@ -5,19 +5,14 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING, get_args
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from anta.cli.nrfu import commands
|
||||
from anta.cli.utils import AliasedGroup, catalog_options, inventory_options
|
||||
from anta.custom_types import TestStatus
|
||||
from anta.models import AntaTest
|
||||
from anta.result_manager import ResultManager
|
||||
from anta.runner import main
|
||||
|
||||
from .utils import anta_progress_bar, print_settings
|
||||
from anta.result_manager.models import AntaTestStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.catalog import AntaCatalog
|
||||
|
@ -37,6 +32,7 @@ class IgnoreRequiredWithHelp(AliasedGroup):
|
|||
"""Ignore MissingParameter exception when parsing arguments if `--help` is present for a subcommand."""
|
||||
# Adding a flag for potential callbacks
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["args"] = args
|
||||
if "--help" in args:
|
||||
ctx.obj["_anta_help"] = True
|
||||
|
||||
|
@ -53,7 +49,7 @@ class IgnoreRequiredWithHelp(AliasedGroup):
|
|||
return super().parse_args(ctx, args)
|
||||
|
||||
|
||||
HIDE_STATUS: list[str] = list(get_args(TestStatus))
|
||||
HIDE_STATUS: list[str] = list(AntaTestStatus)
|
||||
HIDE_STATUS.remove("unset")
|
||||
|
||||
|
||||
|
@ -96,7 +92,7 @@ HIDE_STATUS.remove("unset")
|
|||
default=None,
|
||||
type=click.Choice(HIDE_STATUS, case_sensitive=False),
|
||||
multiple=True,
|
||||
help="Group result by test or device.",
|
||||
help="Hide results by type: success / failure / error / skipped'.",
|
||||
required=False,
|
||||
)
|
||||
@click.option(
|
||||
|
@ -107,7 +103,6 @@ HIDE_STATUS.remove("unset")
|
|||
is_flag=True,
|
||||
default=False,
|
||||
)
|
||||
# pylint: disable=too-many-arguments
|
||||
def nrfu(
|
||||
ctx: click.Context,
|
||||
inventory: AntaInventory,
|
||||
|
@ -120,38 +115,35 @@ def nrfu(
|
|||
ignore_status: bool,
|
||||
ignore_error: bool,
|
||||
dry_run: bool,
|
||||
catalog_format: str = "yaml",
|
||||
) -> None:
|
||||
"""Run ANTA tests on selected inventory devices."""
|
||||
# If help is invoke somewhere, skip the command
|
||||
if ctx.obj.get("_anta_help"):
|
||||
return
|
||||
|
||||
# We use ctx.obj to pass stuff to the next Click functions
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["result_manager"] = ResultManager()
|
||||
ctx.obj["ignore_status"] = ignore_status
|
||||
ctx.obj["ignore_error"] = ignore_error
|
||||
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,
|
||||
devices=set(device) if device else None,
|
||||
tests=set(test) if test else None,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
)
|
||||
if dry_run:
|
||||
return
|
||||
ctx.obj["catalog"] = catalog
|
||||
ctx.obj["catalog_format"] = catalog_format
|
||||
ctx.obj["inventory"] = inventory
|
||||
ctx.obj["tags"] = tags
|
||||
ctx.obj["device"] = device
|
||||
ctx.obj["test"] = test
|
||||
ctx.obj["dry_run"] = dry_run
|
||||
|
||||
# Invoke `anta nrfu table` if no command is passed
|
||||
if ctx.invoked_subcommand is None:
|
||||
if not ctx.invoked_subcommand:
|
||||
ctx.invoke(commands.table)
|
||||
|
||||
|
||||
nrfu.add_command(commands.table)
|
||||
nrfu.add_command(commands.csv)
|
||||
nrfu.add_command(commands.json)
|
||||
nrfu.add_command(commands.text)
|
||||
nrfu.add_command(commands.tpl_report)
|
||||
nrfu.add_command(commands.md_report)
|
||||
|
|
|
@ -13,7 +13,7 @@ import click
|
|||
|
||||
from anta.cli.utils import exit_with_code
|
||||
|
||||
from .utils import print_jinja, print_json, print_table, print_text
|
||||
from .utils import print_jinja, print_json, print_table, print_text, run_tests, save_markdown_report, save_to_csv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -27,11 +27,9 @@ logger = logging.getLogger(__name__)
|
|||
help="Group result by test or device.",
|
||||
required=False,
|
||||
)
|
||||
def table(
|
||||
ctx: click.Context,
|
||||
group_by: Literal["device", "test"] | None,
|
||||
) -> None:
|
||||
"""ANTA command to check network states with table result."""
|
||||
def table(ctx: click.Context, group_by: Literal["device", "test"] | None) -> None:
|
||||
"""ANTA command to check network state with table results."""
|
||||
run_tests(ctx)
|
||||
print_table(ctx, group_by=group_by)
|
||||
exit_with_code(ctx)
|
||||
|
||||
|
@ -44,10 +42,11 @@ def table(
|
|||
type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path),
|
||||
show_envvar=True,
|
||||
required=False,
|
||||
help="Path to save report as a file",
|
||||
help="Path to save report as a JSON file",
|
||||
)
|
||||
def json(ctx: click.Context, output: pathlib.Path | None) -> None:
|
||||
"""ANTA command to check network state with JSON result."""
|
||||
"""ANTA command to check network state with JSON results."""
|
||||
run_tests(ctx)
|
||||
print_json(ctx, output=output)
|
||||
exit_with_code(ctx)
|
||||
|
||||
|
@ -55,11 +54,34 @@ def json(ctx: click.Context, output: pathlib.Path | None) -> None:
|
|||
@click.command()
|
||||
@click.pass_context
|
||||
def text(ctx: click.Context) -> None:
|
||||
"""ANTA command to check network states with text result."""
|
||||
"""ANTA command to check network state with text results."""
|
||||
run_tests(ctx)
|
||||
print_text(ctx)
|
||||
exit_with_code(ctx)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.pass_context
|
||||
@click.option(
|
||||
"--csv-output",
|
||||
type=click.Path(
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
exists=False,
|
||||
writable=True,
|
||||
path_type=pathlib.Path,
|
||||
),
|
||||
show_envvar=True,
|
||||
required=False,
|
||||
help="Path to save report as a CSV file",
|
||||
)
|
||||
def csv(ctx: click.Context, csv_output: pathlib.Path) -> None:
|
||||
"""ANTA command to check network states with CSV result."""
|
||||
run_tests(ctx)
|
||||
save_to_csv(ctx, csv_file=csv_output)
|
||||
exit_with_code(ctx)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.pass_context
|
||||
@click.option(
|
||||
|
@ -80,5 +102,22 @@ def text(ctx: click.Context) -> None:
|
|||
)
|
||||
def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path | None) -> None:
|
||||
"""ANTA command to check network state with templated report."""
|
||||
run_tests(ctx)
|
||||
print_jinja(results=ctx.obj["result_manager"], template=template, output=output)
|
||||
exit_with_code(ctx)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.pass_context
|
||||
@click.option(
|
||||
"--md-output",
|
||||
type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path),
|
||||
show_envvar=True,
|
||||
required=True,
|
||||
help="Path to save the report as a Markdown file",
|
||||
)
|
||||
def md_report(ctx: click.Context, md_output: pathlib.Path) -> None:
|
||||
"""ANTA command to check network state with Markdown report."""
|
||||
run_tests(ctx)
|
||||
save_markdown_report(ctx, md_output=md_output)
|
||||
exit_with_code(ctx)
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
@ -14,7 +15,12 @@ from rich.panel import Panel
|
|||
from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn
|
||||
|
||||
from anta.cli.console import console
|
||||
from anta.cli.utils import ExitCode
|
||||
from anta.models import AntaTest
|
||||
from anta.reporter import ReportJinja, ReportTable
|
||||
from anta.reporter.csv_reporter import ReportCsv
|
||||
from anta.reporter.md_reporter import MDReportGenerator
|
||||
from anta.runner import main
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pathlib
|
||||
|
@ -28,6 +34,37 @@ if TYPE_CHECKING:
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_tests(ctx: click.Context) -> None:
|
||||
"""Run the tests."""
|
||||
# Digging up the parameters from the parent context
|
||||
if ctx.parent is None:
|
||||
ctx.exit()
|
||||
nrfu_ctx_params = ctx.parent.params
|
||||
tags = nrfu_ctx_params["tags"]
|
||||
device = nrfu_ctx_params["device"] or None
|
||||
test = nrfu_ctx_params["test"] or None
|
||||
dry_run = nrfu_ctx_params["dry_run"]
|
||||
|
||||
catalog = ctx.obj["catalog"]
|
||||
inventory = ctx.obj["inventory"]
|
||||
|
||||
print_settings(inventory, catalog)
|
||||
with anta_progress_bar() as AntaTest.progress:
|
||||
asyncio.run(
|
||||
main(
|
||||
ctx.obj["result_manager"],
|
||||
inventory,
|
||||
catalog,
|
||||
tags=tags,
|
||||
devices=set(device) if device else None,
|
||||
tests=set(test) if test else None,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
)
|
||||
if dry_run:
|
||||
ctx.exit()
|
||||
|
||||
|
||||
def _get_result_manager(ctx: click.Context) -> ResultManager:
|
||||
"""Get a ResultManager instance based on Click context."""
|
||||
return ctx.obj["result_manager"].filter(ctx.obj.get("hide")) if ctx.obj.get("hide") is not None else ctx.obj["result_manager"]
|
||||
|
@ -58,14 +95,21 @@ def print_table(ctx: click.Context, group_by: Literal["device", "test"] | None =
|
|||
|
||||
|
||||
def print_json(ctx: click.Context, output: pathlib.Path | None = None) -> None:
|
||||
"""Print result in a json format."""
|
||||
"""Print results as JSON. If output is provided, save to file instead."""
|
||||
results = _get_result_manager(ctx)
|
||||
console.print()
|
||||
console.print(Panel("JSON results", style="cyan"))
|
||||
rich.print_json(results.json)
|
||||
if output is not None:
|
||||
with output.open(mode="w", encoding="utf-8") as fout:
|
||||
fout.write(results.json)
|
||||
|
||||
if output is None:
|
||||
console.print()
|
||||
console.print(Panel("JSON results", style="cyan"))
|
||||
rich.print_json(results.json)
|
||||
else:
|
||||
try:
|
||||
with output.open(mode="w", encoding="utf-8") as file:
|
||||
file.write(results.json)
|
||||
console.print(f"JSON results saved to {output} ✅", style="cyan")
|
||||
except OSError:
|
||||
console.print(f"Failed to save JSON results to {output} ❌", style="cyan")
|
||||
ctx.exit(ExitCode.USAGE_ERROR)
|
||||
|
||||
|
||||
def print_text(ctx: click.Context) -> None:
|
||||
|
@ -88,6 +132,34 @@ def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib.
|
|||
file.write(report)
|
||||
|
||||
|
||||
def save_to_csv(ctx: click.Context, csv_file: pathlib.Path) -> None:
|
||||
"""Save results to a CSV file."""
|
||||
try:
|
||||
ReportCsv.generate(results=_get_result_manager(ctx), csv_filename=csv_file)
|
||||
console.print(f"CSV report saved to {csv_file} ✅", style="cyan")
|
||||
except OSError:
|
||||
console.print(f"Failed to save CSV report to {csv_file} ❌", style="cyan")
|
||||
ctx.exit(ExitCode.USAGE_ERROR)
|
||||
|
||||
|
||||
def save_markdown_report(ctx: click.Context, md_output: pathlib.Path) -> None:
|
||||
"""Save the markdown report to a file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ctx
|
||||
Click context containing the result manager.
|
||||
md_output
|
||||
Path to save the markdown report.
|
||||
"""
|
||||
try:
|
||||
MDReportGenerator.generate(results=_get_result_manager(ctx), md_filename=md_output)
|
||||
console.print(f"Markdown report saved to {md_output} ✅", style="cyan")
|
||||
except OSError:
|
||||
console.print(f"Failed to save Markdown report to {md_output} ❌", style="cyan")
|
||||
ctx.exit(ExitCode.USAGE_ERROR)
|
||||
|
||||
|
||||
# Adding our own ANTA spinner - overriding rich SPINNERS for our own
|
||||
# so ignore warning for redefinition
|
||||
rich.spinner.SPINNERS = { # type: ignore[attr-defined]
|
||||
|
|
|
@ -40,7 +40,6 @@ class ExitCode(enum.IntEnum):
|
|||
|
||||
|
||||
def parse_tags(ctx: click.Context, param: Option, value: str | None) -> set[str] | None:
|
||||
# pylint: disable=unused-argument
|
||||
# ruff: noqa: ARG001
|
||||
"""Click option callback to parse an ANTA inventory tags."""
|
||||
if value is not None:
|
||||
|
@ -60,9 +59,10 @@ def exit_with_code(ctx: click.Context) -> None:
|
|||
* 1 if status is `failure`
|
||||
* 2 if status is `error`.
|
||||
|
||||
Args:
|
||||
----
|
||||
ctx: Click Context
|
||||
Parameters
|
||||
----------
|
||||
ctx
|
||||
Click Context.
|
||||
|
||||
"""
|
||||
if ctx.obj.get("ignore_status"):
|
||||
|
@ -112,7 +112,7 @@ class AliasedGroup(click.Group):
|
|||
return cmd.name, cmd, args
|
||||
|
||||
|
||||
def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
def core_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Click common options when requiring an inventory to interact with devices."""
|
||||
|
||||
@click.option(
|
||||
|
@ -190,22 +190,12 @@ def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
|||
required=True,
|
||||
type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path),
|
||||
)
|
||||
@click.option(
|
||||
"--tags",
|
||||
help="List of tags using comma as separator: tag1,tag2,tag3.",
|
||||
show_envvar=True,
|
||||
envvar="ANTA_TAGS",
|
||||
type=str,
|
||||
required=False,
|
||||
callback=parse_tags,
|
||||
)
|
||||
@click.pass_context
|
||||
@functools.wraps(f)
|
||||
def wrapper(
|
||||
ctx: click.Context,
|
||||
*args: tuple[Any],
|
||||
inventory: Path,
|
||||
tags: set[str] | None,
|
||||
username: str,
|
||||
password: str | None,
|
||||
enable_password: str | None,
|
||||
|
@ -216,10 +206,9 @@ def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
|||
disable_cache: bool,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> Any:
|
||||
# pylint: disable=too-many-arguments
|
||||
# If help is invoke somewhere, do not parse inventory
|
||||
if ctx.obj.get("_anta_help"):
|
||||
return f(*args, inventory=None, tags=tags, **kwargs)
|
||||
return f(*args, inventory=None, **kwargs)
|
||||
if prompt:
|
||||
# User asked for a password prompt
|
||||
if password is None:
|
||||
|
@ -255,7 +244,36 @@ def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
|||
)
|
||||
except (TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError):
|
||||
ctx.exit(ExitCode.USAGE_ERROR)
|
||||
return f(*args, inventory=i, tags=tags, **kwargs)
|
||||
return f(*args, inventory=i, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Click common options when requiring an inventory to interact with devices."""
|
||||
|
||||
@core_options
|
||||
@click.option(
|
||||
"--tags",
|
||||
help="List of tags using comma as separator: tag1,tag2,tag3.",
|
||||
show_envvar=True,
|
||||
envvar="ANTA_TAGS",
|
||||
type=str,
|
||||
required=False,
|
||||
callback=parse_tags,
|
||||
)
|
||||
@click.pass_context
|
||||
@functools.wraps(f)
|
||||
def wrapper(
|
||||
ctx: click.Context,
|
||||
*args: tuple[Any],
|
||||
tags: set[str] | None,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> Any:
|
||||
# If help is invoke somewhere, do not parse inventory
|
||||
if ctx.obj.get("_anta_help"):
|
||||
return f(*args, tags=tags, **kwargs)
|
||||
return f(*args, tags=tags, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
@ -268,7 +286,7 @@ def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
|||
"-c",
|
||||
envvar="ANTA_CATALOG",
|
||||
show_envvar=True,
|
||||
help="Path to the test catalog YAML file",
|
||||
help="Path to the test catalog file",
|
||||
type=click.Path(
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
|
@ -278,19 +296,29 @@ def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
|||
),
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--catalog-format",
|
||||
envvar="ANTA_CATALOG_FORMAT",
|
||||
show_envvar=True,
|
||||
help="Format of the catalog file, either 'yaml' or 'json'",
|
||||
default="yaml",
|
||||
type=click.Choice(["yaml", "json"], case_sensitive=False),
|
||||
)
|
||||
@click.pass_context
|
||||
@functools.wraps(f)
|
||||
def wrapper(
|
||||
ctx: click.Context,
|
||||
*args: tuple[Any],
|
||||
catalog: Path,
|
||||
catalog_format: str,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> Any:
|
||||
# If help is invoke somewhere, do not parse catalog
|
||||
if ctx.obj.get("_anta_help"):
|
||||
return f(*args, catalog=None, **kwargs)
|
||||
try:
|
||||
c = AntaCatalog.parse(catalog)
|
||||
file_format = catalog_format.lower()
|
||||
c = AntaCatalog.parse(catalog, file_format=file_format) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError, YAMLError, OSError):
|
||||
ctx.exit(ExitCode.USAGE_ERROR)
|
||||
return f(*args, catalog=c, **kwargs)
|
||||
|
|
19
anta/constants.py
Normal file
19
anta/constants.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""Constants used in ANTA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
ACRONYM_CATEGORIES: set[str] = {"aaa", "mlag", "snmp", "bgp", "ospf", "vxlan", "stp", "igmp", "ip", "lldp", "ntp", "bfd", "ptp", "lanz", "stun", "vlan"}
|
||||
"""A set of network protocol or feature acronyms that should be represented in uppercase."""
|
||||
|
||||
MD_REPORT_TOC = """**Table of Contents:**
|
||||
|
||||
- [ANTA Report](#anta-report)
|
||||
- [Test Results Summary](#test-results-summary)
|
||||
- [Summary Totals](#summary-totals)
|
||||
- [Summary Totals Device Under Test](#summary-totals-device-under-test)
|
||||
- [Summary Totals Per Category](#summary-totals-per-category)
|
||||
- [Test Results](#test-results)"""
|
||||
"""Table of Contents for the Markdown report."""
|
|
@ -21,6 +21,8 @@ REGEXP_TYPE_EOS_INTERFACE = r"^(Dps|Ethernet|Fabric|Loopback|Management|Port-Cha
|
|||
"""Match EOS interface types like Ethernet1/1, Vlan1, Loopback1, etc."""
|
||||
REGEXP_TYPE_VXLAN_SRC_INTERFACE = r"^(Loopback)([0-9]|[1-9][0-9]{1,2}|[1-7][0-9]{3}|8[01][0-9]{2}|819[01])$"
|
||||
"""Match Vxlan source interface like Loopback10."""
|
||||
REGEX_TYPE_PORTCHANNEL = r"^Port-Channel[0-9]{1,6}$"
|
||||
"""Match Port Channel interface like Port-Channel5."""
|
||||
REGEXP_TYPE_HOSTNAME = r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$"
|
||||
"""Match hostname like `my-hostname`, `my-hostname-1`, `my-hostname-1-2`."""
|
||||
|
||||
|
@ -66,9 +68,9 @@ def interface_case_sensitivity(v: str) -> str:
|
|||
|
||||
Examples
|
||||
--------
|
||||
- ethernet -> Ethernet
|
||||
- vlan -> Vlan
|
||||
- loopback -> Loopback
|
||||
- ethernet -> Ethernet
|
||||
- vlan -> Vlan
|
||||
- loopback -> Loopback
|
||||
|
||||
"""
|
||||
if isinstance(v, str) and v != "" and not v[0].isupper():
|
||||
|
@ -81,10 +83,10 @@ def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str:
|
|||
|
||||
Examples
|
||||
--------
|
||||
- IPv4 Unicast
|
||||
- L2vpnEVPN
|
||||
- ipv4 MPLS Labels
|
||||
- ipv4Mplsvpn
|
||||
- IPv4 Unicast
|
||||
- L2vpnEVPN
|
||||
- ipv4 MPLS Labels
|
||||
- ipv4Mplsvpn
|
||||
|
||||
"""
|
||||
patterns = {
|
||||
|
@ -112,9 +114,6 @@ def validate_regex(value: str) -> str:
|
|||
return value
|
||||
|
||||
|
||||
# ANTA framework
|
||||
TestStatus = Literal["unset", "success", "failure", "error", "skipped"]
|
||||
|
||||
# AntaTest.Input types
|
||||
AAAAuthMethod = Annotated[str, AfterValidator(aaa_group_prefix)]
|
||||
Vlan = Annotated[int, Field(ge=0, le=4094)]
|
||||
|
@ -138,6 +137,12 @@ VxlanSrcIntf = Annotated[
|
|||
BeforeValidator(interface_autocomplete),
|
||||
BeforeValidator(interface_case_sensitivity),
|
||||
]
|
||||
PortChannelInterface = Annotated[
|
||||
str,
|
||||
Field(pattern=REGEX_TYPE_PORTCHANNEL),
|
||||
BeforeValidator(interface_autocomplete),
|
||||
BeforeValidator(interface_case_sensitivity),
|
||||
]
|
||||
Afi = Literal["ipv4", "ipv6", "vpn-ipv4", "vpn-ipv6", "evpn", "rt-membership", "path-selection", "link-state"]
|
||||
Safi = Literal["unicast", "multicast", "labeled-unicast", "sr-te"]
|
||||
EncryptionAlgorithm = Literal["RSA", "ECDSA"]
|
||||
|
@ -167,3 +172,39 @@ Revision = Annotated[int, Field(ge=1, le=99)]
|
|||
Hostname = Annotated[str, Field(pattern=REGEXP_TYPE_HOSTNAME)]
|
||||
Port = Annotated[int, Field(ge=1, le=65535)]
|
||||
RegexString = Annotated[str, AfterValidator(validate_regex)]
|
||||
BgpDropStats = Literal[
|
||||
"inDropAsloop",
|
||||
"inDropClusterIdLoop",
|
||||
"inDropMalformedMpbgp",
|
||||
"inDropOrigId",
|
||||
"inDropNhLocal",
|
||||
"inDropNhAfV6",
|
||||
"prefixDroppedMartianV4",
|
||||
"prefixDroppedMaxRouteLimitViolatedV4",
|
||||
"prefixDroppedMartianV6",
|
||||
"prefixDroppedMaxRouteLimitViolatedV6",
|
||||
"prefixLuDroppedV4",
|
||||
"prefixLuDroppedMartianV4",
|
||||
"prefixLuDroppedMaxRouteLimitViolatedV4",
|
||||
"prefixLuDroppedV6",
|
||||
"prefixLuDroppedMartianV6",
|
||||
"prefixLuDroppedMaxRouteLimitViolatedV6",
|
||||
"prefixEvpnDroppedUnsupportedRouteType",
|
||||
"prefixBgpLsDroppedReceptionUnsupported",
|
||||
"outDropV4LocalAddr",
|
||||
"outDropV6LocalAddr",
|
||||
"prefixVpnIpv4DroppedImportMatchFailure",
|
||||
"prefixVpnIpv4DroppedMaxRouteLimitViolated",
|
||||
"prefixVpnIpv6DroppedImportMatchFailure",
|
||||
"prefixVpnIpv6DroppedMaxRouteLimitViolated",
|
||||
"prefixEvpnDroppedImportMatchFailure",
|
||||
"prefixEvpnDroppedMaxRouteLimitViolated",
|
||||
"prefixRtMembershipDroppedLocalAsReject",
|
||||
"prefixRtMembershipDroppedMaxRouteLimitViolated",
|
||||
]
|
||||
BgpUpdateError = Literal["inUpdErrWithdraw", "inUpdErrIgnore", "inUpdErrDisableAfiSafi", "disabledAfiSafi", "lastUpdErrTime"]
|
||||
BfdProtocol = Literal["bgp", "isis", "lag", "ospf", "ospfv3", "pim", "route-input", "static-bfd", "static-route", "vrrp", "vxlan"]
|
||||
SnmpPdu = Literal["inGetPdus", "inGetNextPdus", "inSetPdus", "outGetResponsePdus", "outTrapPdus"]
|
||||
SnmpErrorCounter = Literal[
|
||||
"inVersionErrs", "inBadCommunityNames", "inBadCommunityUses", "inParseErrs", "outTooBigErrs", "outNoSuchNameErrs", "outBadValueErrs", "outGeneralErrs"
|
||||
]
|
||||
|
|
|
@ -20,26 +20,30 @@ F = TypeVar("F", bound=Callable[..., Any])
|
|||
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: A list of new test classes that should replace the deprecated test.
|
||||
Parameters
|
||||
----------
|
||||
new_tests
|
||||
A list of new test classes that should replace the deprecated test.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Callable[[F], F]: A decorator that can be used to wrap test functions.
|
||||
Callable[[F], F]
|
||||
A decorator that can be used to wrap test functions.
|
||||
|
||||
"""
|
||||
|
||||
def decorator(function: F) -> F:
|
||||
"""Actual decorator that logs the message.
|
||||
|
||||
Args:
|
||||
----
|
||||
function: The test function to be decorated.
|
||||
Parameters
|
||||
----------
|
||||
function
|
||||
The test function to be decorated.
|
||||
|
||||
Returns
|
||||
-------
|
||||
F: The decorated function.
|
||||
F
|
||||
The decorated function.
|
||||
|
||||
"""
|
||||
|
||||
|
@ -64,26 +68,30 @@ def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]:
|
|||
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 of hardware models on which the test should be skipped.
|
||||
Parameters
|
||||
----------
|
||||
platforms
|
||||
List of hardware models on which the test should be skipped.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Callable[[F], F]: A decorator that can be used to wrap test functions.
|
||||
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.
|
||||
|
||||
Args:
|
||||
----
|
||||
function: The test function to be decorated.
|
||||
Parameters
|
||||
----------
|
||||
function
|
||||
The test function to be decorated.
|
||||
|
||||
Returns
|
||||
-------
|
||||
F: The decorated function.
|
||||
F
|
||||
The decorated function.
|
||||
|
||||
"""
|
||||
|
||||
|
|
203
anta/device.py
203
anta/device.py
|
@ -42,24 +42,34 @@ class AntaDevice(ABC):
|
|||
|
||||
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: 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.
|
||||
name : str
|
||||
Device name.
|
||||
is_online : bool
|
||||
True if the device IP is reachable and a port can be open.
|
||||
established : bool
|
||||
True if remote command execution succeeds.
|
||||
hw_model : str
|
||||
Hardware model of the device.
|
||||
tags : set[str]
|
||||
Tags for this device.
|
||||
cache : Cache | None
|
||||
In-memory cache from aiocache library for this device (None if cache is disabled).
|
||||
cache_locks : dict
|
||||
Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, tags: set[str] | None = None, *, disable_cache: bool = False) -> None:
|
||||
"""Initialize an AntaDevice.
|
||||
|
||||
Args:
|
||||
----
|
||||
name: Device name.
|
||||
tags: Tags for this device.
|
||||
disable_cache: Disable caching for all commands for this device.
|
||||
Parameters
|
||||
----------
|
||||
name
|
||||
Device name.
|
||||
tags
|
||||
Tags for this device.
|
||||
disable_cache
|
||||
Disable caching for all commands for this device.
|
||||
|
||||
"""
|
||||
self.name: str = name
|
||||
|
@ -96,7 +106,7 @@ class AntaDevice(ABC):
|
|||
|
||||
@property
|
||||
def cache_statistics(self) -> dict[str, Any] | None:
|
||||
"""Returns the device cache statistics for logging purposes."""
|
||||
"""Return the device cache statistics for logging purposes."""
|
||||
# Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough
|
||||
# https://github.com/pylint-dev/pylint/issues/7258
|
||||
if self.cache is not None:
|
||||
|
@ -116,6 +126,17 @@ class AntaDevice(ABC):
|
|||
yield "established", self.established
|
||||
yield "disable_cache", self.cache is None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a printable representation of an AntaDevice."""
|
||||
return (
|
||||
f"AntaDevice({self.name!r}, "
|
||||
f"tags={self.tags!r}, "
|
||||
f"hw_model={self.hw_model!r}, "
|
||||
f"is_online={self.is_online!r}, "
|
||||
f"established={self.established!r}, "
|
||||
f"disable_cache={self.cache is None!r})"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None:
|
||||
"""Collect device command output.
|
||||
|
@ -130,10 +151,12 @@ class AntaDevice(ABC):
|
|||
exception and implement proper logging, the `output` attribute of the
|
||||
`AntaCommand` object passed as argument would be `None` in this case.
|
||||
|
||||
Args:
|
||||
----
|
||||
command: The command to collect.
|
||||
collection_id: An identifier used to build the eAPI request ID.
|
||||
Parameters
|
||||
----------
|
||||
command
|
||||
The command to collect.
|
||||
collection_id
|
||||
An identifier used to build the eAPI request ID.
|
||||
"""
|
||||
|
||||
async def collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None:
|
||||
|
@ -147,10 +170,12 @@ class AntaDevice(ABC):
|
|||
When caching is NOT enabled, either at the device or command level, the method directly collects the output
|
||||
via the private `_collect` method without interacting with the cache.
|
||||
|
||||
Args:
|
||||
----
|
||||
command: The command to collect.
|
||||
collection_id: An identifier used to build the eAPI request ID.
|
||||
Parameters
|
||||
----------
|
||||
command
|
||||
The command to collect.
|
||||
collection_id
|
||||
An identifier used to build the eAPI request ID.
|
||||
"""
|
||||
# Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough
|
||||
# https://github.com/pylint-dev/pylint/issues/7258
|
||||
|
@ -170,10 +195,12 @@ class AntaDevice(ABC):
|
|||
async def collect_commands(self, commands: list[AntaCommand], *, collection_id: str | None = None) -> None:
|
||||
"""Collect multiple commands.
|
||||
|
||||
Args:
|
||||
----
|
||||
commands: The commands to collect.
|
||||
collection_id: An identifier used to build the eAPI request ID.
|
||||
Parameters
|
||||
----------
|
||||
commands
|
||||
The commands to collect.
|
||||
collection_id
|
||||
An identifier used to build the eAPI request ID.
|
||||
"""
|
||||
await asyncio.gather(*(self.collect(command=command, collection_id=collection_id) for command in commands))
|
||||
|
||||
|
@ -182,9 +209,12 @@ class AntaDevice(ABC):
|
|||
"""Update attributes of an AntaDevice instance.
|
||||
|
||||
This coroutine must update the following attributes of AntaDevice:
|
||||
- `is_online`: When the device IP is reachable and a port can be open
|
||||
- `established`: When a command execution succeeds
|
||||
- `hw_model`: The hardware model of the device
|
||||
|
||||
- `is_online`: When the device IP is reachable and a port can be open.
|
||||
|
||||
- `established`: When a command execution succeeds.
|
||||
|
||||
- `hw_model`: The hardware model of the device.
|
||||
"""
|
||||
|
||||
async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None:
|
||||
|
@ -192,11 +222,14 @@ class AntaDevice(ABC):
|
|||
|
||||
It is not mandatory to implement this for a valid AntaDevice subclass.
|
||||
|
||||
Args:
|
||||
----
|
||||
sources: List of files to copy to or from the device.
|
||||
destination: Local or remote destination when copying the files. Can be a folder.
|
||||
direction: Defines if this coroutine copies files to or from the device.
|
||||
Parameters
|
||||
----------
|
||||
sources
|
||||
List of files to copy to or from the device.
|
||||
destination
|
||||
Local or remote destination when copying the files. Can be a folder.
|
||||
direction
|
||||
Defines if this coroutine copies files to or from the device.
|
||||
|
||||
"""
|
||||
_ = (sources, destination, direction)
|
||||
|
@ -209,15 +242,19 @@ class AsyncEOSDevice(AntaDevice):
|
|||
|
||||
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: Tags for this device
|
||||
name : str
|
||||
Device name.
|
||||
is_online : bool
|
||||
True if the device IP is reachable and a port can be open.
|
||||
established : bool
|
||||
True if remote command execution succeeds.
|
||||
hw_model : str
|
||||
Hardware model of the device.
|
||||
tags : set[str]
|
||||
Tags for this device.
|
||||
|
||||
"""
|
||||
|
||||
# pylint: disable=R0913
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
|
@ -237,21 +274,34 @@ class AsyncEOSDevice(AntaDevice):
|
|||
) -> None:
|
||||
"""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: Collect commands using privileged mode.
|
||||
enable_password: Password used to gain privileged access on EOS.
|
||||
port: eAPI port. Defaults to 80 is proto is 'http' or 443 if proto is 'https'.
|
||||
ssh_port: SSH port.
|
||||
tags: Tags for this device.
|
||||
timeout: Timeout value in seconds for outgoing API calls.
|
||||
insecure: Disable SSH Host Key validation.
|
||||
proto: eAPI protocol. Value can be 'http' or 'https'.
|
||||
disable_cache: Disable caching for all commands for this device.
|
||||
Parameters
|
||||
----------
|
||||
host
|
||||
Device FQDN or IP.
|
||||
username
|
||||
Username to connect to eAPI and SSH.
|
||||
password
|
||||
Password to connect to eAPI and SSH.
|
||||
name
|
||||
Device name.
|
||||
enable
|
||||
Collect commands using privileged mode.
|
||||
enable_password
|
||||
Password used to gain privileged access on EOS.
|
||||
port
|
||||
eAPI port. Defaults to 80 is proto is 'http' or 443 if proto is 'https'.
|
||||
ssh_port
|
||||
SSH port.
|
||||
tags
|
||||
Tags for this device.
|
||||
timeout
|
||||
Timeout value in seconds for outgoing API calls.
|
||||
insecure
|
||||
Disable SSH Host Key validation.
|
||||
proto
|
||||
eAPI protocol. Value can be 'http' or 'https'.
|
||||
disable_cache
|
||||
Disable caching for all commands for this device.
|
||||
|
||||
"""
|
||||
if host is None:
|
||||
|
@ -298,6 +348,22 @@ class AsyncEOSDevice(AntaDevice):
|
|||
yield ("_session", vars(self._session))
|
||||
yield ("_ssh_opts", _ssh_opts)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a printable representation of an AsyncEOSDevice."""
|
||||
return (
|
||||
f"AsyncEOSDevice({self.name!r}, "
|
||||
f"tags={self.tags!r}, "
|
||||
f"hw_model={self.hw_model!r}, "
|
||||
f"is_online={self.is_online!r}, "
|
||||
f"established={self.established!r}, "
|
||||
f"disable_cache={self.cache is None!r}, "
|
||||
f"host={self._session.host!r}, "
|
||||
f"eapi_port={self._session.port!r}, "
|
||||
f"username={self._ssh_opts.username!r}, "
|
||||
f"enable={self.enable!r}, "
|
||||
f"insecure={self._ssh_opts.known_hosts is None!r})"
|
||||
)
|
||||
|
||||
@property
|
||||
def _keys(self) -> tuple[Any, ...]:
|
||||
"""Two AsyncEOSDevice objects are equal if the hostname and the port are the same.
|
||||
|
@ -306,17 +372,19 @@ class AsyncEOSDevice(AntaDevice):
|
|||
"""
|
||||
return (self._session.host, self._session.port)
|
||||
|
||||
async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: # noqa: C901 function is too complex - because of many required except blocks #pylint: disable=line-too-long
|
||||
async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: # 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.
|
||||
collection_id: An identifier used to build the eAPI request ID.
|
||||
Parameters
|
||||
----------
|
||||
command
|
||||
The command to collect.
|
||||
collection_id
|
||||
An identifier used to build the eAPI request ID.
|
||||
"""
|
||||
commands: list[dict[str, str | int]] = []
|
||||
if self.enable and self._enable_password is not None:
|
||||
|
@ -397,6 +465,10 @@ class AsyncEOSDevice(AntaDevice):
|
|||
self.hw_model = show_version.json_output.get("modelName", None)
|
||||
if self.hw_model is None:
|
||||
logger.critical("Cannot parse 'show version' returned by device %s", self.name)
|
||||
# in some cases it is possible that 'modelName' comes back empty
|
||||
# and it is nice to get a meaninfule error message
|
||||
elif self.hw_model == "":
|
||||
logger.critical("Got an empty 'modelName' in the 'show version' returned by device %s", self.name)
|
||||
else:
|
||||
logger.warning("Could not connect to device %s: cannot open eAPI port", self.name)
|
||||
|
||||
|
@ -405,11 +477,14 @@ class AsyncEOSDevice(AntaDevice):
|
|||
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().
|
||||
|
||||
Args:
|
||||
----
|
||||
sources: List of files to copy to or from the device.
|
||||
destination: Local or remote destination when copying the files. Can be a folder.
|
||||
direction: Defines if this coroutine copies files to or from the device.
|
||||
Parameters
|
||||
----------
|
||||
sources
|
||||
List of files to copy to or from the device.
|
||||
destination
|
||||
Local or remote destination when copying the files. Can be a folder.
|
||||
direction
|
||||
Defines if this coroutine copies files to or from the device.
|
||||
|
||||
"""
|
||||
async with asyncssh.connect(
|
||||
|
|
|
@ -44,10 +44,12 @@ class AntaInventory(dict[str, AntaDevice]):
|
|||
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: The value of disable_cache in the inventory
|
||||
kwargs: The kwargs to instantiate the device
|
||||
Parameters
|
||||
----------
|
||||
inventory_disable_cache
|
||||
The value of disable_cache in the inventory.
|
||||
kwargs
|
||||
The kwargs to instantiate the device.
|
||||
|
||||
"""
|
||||
updated_kwargs = kwargs.copy()
|
||||
|
@ -62,11 +64,14 @@ class AntaInventory(dict[str, AntaDevice]):
|
|||
) -> None:
|
||||
"""Parse the host section of an AntaInventoryInput and add the devices to the inventory.
|
||||
|
||||
Args:
|
||||
----
|
||||
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
|
||||
Parameters
|
||||
----------
|
||||
inventory_input
|
||||
AntaInventoryInput used to parse the devices.
|
||||
inventory
|
||||
AntaInventory to add the parsed devices to.
|
||||
**kwargs
|
||||
Additional keyword arguments to pass to the device constructor.
|
||||
|
||||
"""
|
||||
if inventory_input.hosts is None:
|
||||
|
@ -91,15 +96,19 @@ class AntaInventory(dict[str, AntaDevice]):
|
|||
) -> None:
|
||||
"""Parse the network section of an AntaInventoryInput and add the devices to the inventory.
|
||||
|
||||
Args:
|
||||
----
|
||||
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
|
||||
Parameters
|
||||
----------
|
||||
inventory_input
|
||||
AntaInventoryInput used to parse the devices.
|
||||
inventory
|
||||
AntaInventory to add the parsed devices to.
|
||||
**kwargs
|
||||
Additional keyword arguments to pass to the device constructor.
|
||||
|
||||
Raises
|
||||
------
|
||||
InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema.
|
||||
InventoryIncorrectSchemaError
|
||||
Inventory file is not following AntaInventory Schema.
|
||||
|
||||
"""
|
||||
if inventory_input.networks is None:
|
||||
|
@ -124,15 +133,19 @@ class AntaInventory(dict[str, AntaDevice]):
|
|||
) -> None:
|
||||
"""Parse the range section of an AntaInventoryInput and add the devices to the inventory.
|
||||
|
||||
Args:
|
||||
----
|
||||
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
|
||||
Parameters
|
||||
----------
|
||||
inventory_input
|
||||
AntaInventoryInput used to parse the devices.
|
||||
inventory
|
||||
AntaInventory to add the parsed devices to.
|
||||
**kwargs
|
||||
Additional keyword arguments to pass to the device constructor.
|
||||
|
||||
Raises
|
||||
------
|
||||
InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema.
|
||||
InventoryIncorrectSchemaError
|
||||
Inventory file is not following AntaInventory Schema.
|
||||
|
||||
"""
|
||||
if inventory_input.ranges is None:
|
||||
|
@ -158,7 +171,6 @@ class AntaInventory(dict[str, AntaDevice]):
|
|||
anta_log_exception(e, message, logger)
|
||||
raise InventoryIncorrectSchemaError(message) from e
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
@staticmethod
|
||||
def parse(
|
||||
filename: str | Path,
|
||||
|
@ -175,21 +187,31 @@ class AntaInventory(dict[str, AntaDevice]):
|
|||
|
||||
The inventory devices are AsyncEOSDevice instances.
|
||||
|
||||
Args:
|
||||
----
|
||||
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.
|
||||
Parameters
|
||||
----------
|
||||
filename
|
||||
Path to device inventory YAML file.
|
||||
username
|
||||
Username to use to connect to devices.
|
||||
password
|
||||
Password to use to connect to devices.
|
||||
enable_password
|
||||
Enable password to use if required.
|
||||
timeout
|
||||
Timeout value in seconds for outgoing API calls.
|
||||
enable
|
||||
Whether or not the commands need to be run in enable mode towards the devices.
|
||||
insecure
|
||||
Disable SSH Host Key validation.
|
||||
disable_cache
|
||||
Disable cache globally.
|
||||
|
||||
Raises
|
||||
------
|
||||
InventoryRootKeyError: Root key of inventory is missing.
|
||||
InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema.
|
||||
InventoryRootKeyError
|
||||
Root key of inventory is missing.
|
||||
InventoryIncorrectSchemaError
|
||||
Inventory file is not following AntaInventory Schema.
|
||||
|
||||
"""
|
||||
inventory = AntaInventory()
|
||||
|
@ -254,14 +276,18 @@ class AntaInventory(dict[str, AntaDevice]):
|
|||
def get_inventory(self, *, established_only: bool = False, tags: set[str] | None = None, devices: set[str] | None = None) -> AntaInventory:
|
||||
"""Return a filtered inventory.
|
||||
|
||||
Args:
|
||||
----
|
||||
established_only: Whether or not to include only established devices.
|
||||
tags: Tags to filter devices.
|
||||
devices: Names to filter devices.
|
||||
Parameters
|
||||
----------
|
||||
established_only
|
||||
Whether or not to include only established devices.
|
||||
tags
|
||||
Tags to filter devices.
|
||||
devices
|
||||
Names to filter devices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AntaInventory
|
||||
An inventory with filtered AntaDevice objects.
|
||||
"""
|
||||
|
||||
|
@ -293,9 +319,10 @@ class AntaInventory(dict[str, AntaDevice]):
|
|||
def add_device(self, device: AntaDevice) -> None:
|
||||
"""Add a device to final inventory.
|
||||
|
||||
Args:
|
||||
----
|
||||
device: Device object to be added
|
||||
Parameters
|
||||
----------
|
||||
device
|
||||
Device object to be added.
|
||||
|
||||
"""
|
||||
self[device.name] = device
|
||||
|
|
|
@ -21,11 +21,16 @@ class AntaInventoryHost(BaseModel):
|
|||
|
||||
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.
|
||||
host : Hostname | IPvAnyAddress
|
||||
IP Address or FQDN of the device.
|
||||
port : Port | None
|
||||
Custom eAPI port to use.
|
||||
name : str | None
|
||||
Custom name of the device.
|
||||
tags : set[str]
|
||||
Tags of the device.
|
||||
disable_cache : bool
|
||||
Disable cache for this device.
|
||||
|
||||
"""
|
||||
|
||||
|
@ -43,9 +48,12 @@ class AntaInventoryNetwork(BaseModel):
|
|||
|
||||
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.
|
||||
network : IPvAnyNetwork
|
||||
Subnet to use for scanning.
|
||||
tags : set[str]
|
||||
Tags of the devices in this network.
|
||||
disable_cache : bool
|
||||
Disable cache for all devices in this network.
|
||||
|
||||
"""
|
||||
|
||||
|
@ -61,10 +69,14 @@ class AntaInventoryRange(BaseModel):
|
|||
|
||||
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.
|
||||
start : IPvAnyAddress
|
||||
IPv4 or IPv6 address for the beginning of the range.
|
||||
stop : IPvAnyAddress
|
||||
IPv4 or IPv6 address for the end of the range.
|
||||
tags : set[str]
|
||||
Tags of the devices in this IP range.
|
||||
disable_cache : bool
|
||||
Disable cache for all devices in this IP range.
|
||||
|
||||
"""
|
||||
|
||||
|
@ -90,6 +102,7 @@ class AntaInventoryInput(BaseModel):
|
|||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The YAML representation string of this model.
|
||||
"""
|
||||
# TODO: Pydantic and YAML serialization/deserialization is not supported natively.
|
||||
|
|
|
@ -49,10 +49,12 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None:
|
|||
If a file is provided and logging level is DEBUG, only the logging level INFO and higher will
|
||||
be logged to stdout while all levels will be logged in the file.
|
||||
|
||||
Args:
|
||||
----
|
||||
level: ANTA logging level
|
||||
file: Send logs to a file
|
||||
Parameters
|
||||
----------
|
||||
level
|
||||
ANTA logging level
|
||||
file
|
||||
Send logs to a file
|
||||
|
||||
"""
|
||||
# Init root logger
|
||||
|
@ -104,11 +106,14 @@ def anta_log_exception(exception: BaseException, message: str | None = None, cal
|
|||
|
||||
If `anta.__DEBUG__` is True then the `logger.exception` method is called to get the traceback, otherwise `logger.error` is called.
|
||||
|
||||
Args:
|
||||
----
|
||||
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.
|
||||
Parameters
|
||||
----------
|
||||
exception
|
||||
The Exception being logged.
|
||||
message
|
||||
An optional message.
|
||||
calling_logger
|
||||
A logger to which the exception should be logged. If not present, the logger in this file is used.
|
||||
|
||||
"""
|
||||
if calling_logger is None:
|
||||
|
|
152
anta/models.py
152
anta/models.py
|
@ -18,7 +18,7 @@ from pydantic import BaseModel, ConfigDict, ValidationError, create_model
|
|||
from anta import GITHUB_SUGGESTION
|
||||
from anta.custom_types import REGEXP_EOS_BLACKLIST_CMDS, Revision
|
||||
from anta.logger import anta_log_exception, exc_to_str
|
||||
from anta.result_manager.models import TestResult
|
||||
from anta.result_manager.models import AntaTestStatus, TestResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Coroutine
|
||||
|
@ -48,16 +48,21 @@ class AntaTemplate:
|
|||
|
||||
Attributes
|
||||
----------
|
||||
template: Python f-string. Example: 'show vlan {vlan_id}'
|
||||
version: eAPI version - valid values are 1 or "latest".
|
||||
revision: Revision of the command. Valid values are 1 to 99. Revision has precedence over version.
|
||||
ofmt: eAPI output - json or text.
|
||||
use_cache: Enable or disable caching for this AntaTemplate if the AntaDevice supports it.
|
||||
template
|
||||
Python f-string. Example: 'show vlan {vlan_id}'.
|
||||
version
|
||||
eAPI version - valid values are 1 or "latest".
|
||||
revision
|
||||
Revision of the command. Valid values are 1 to 99. Revision has precedence over version.
|
||||
ofmt
|
||||
eAPI output - json or text.
|
||||
use_cache
|
||||
Enable or disable caching for this AntaTemplate if the AntaDevice supports it.
|
||||
"""
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
def __init__( # noqa: PLR0913
|
||||
def __init__(
|
||||
self,
|
||||
template: str,
|
||||
version: Literal[1, "latest"] = "latest",
|
||||
|
@ -66,7 +71,6 @@ class AntaTemplate:
|
|||
*,
|
||||
use_cache: bool = True,
|
||||
) -> None:
|
||||
# pylint: disable=too-many-arguments
|
||||
self.template = template
|
||||
self.version = version
|
||||
self.revision = revision
|
||||
|
@ -95,20 +99,22 @@ class AntaTemplate:
|
|||
|
||||
Keep the parameters used in the AntaTemplate instance.
|
||||
|
||||
Args:
|
||||
----
|
||||
params: dictionary of variables with string values to render the Python f-string
|
||||
Parameters
|
||||
----------
|
||||
params
|
||||
Dictionary of variables with string values to render the Python f-string.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AntaCommand
|
||||
The rendered AntaCommand.
|
||||
This AntaCommand instance have a template attribute that references this
|
||||
AntaTemplate instance.
|
||||
|
||||
Raises
|
||||
------
|
||||
AntaTemplateRenderError
|
||||
If a parameter is missing to render the AntaTemplate instance.
|
||||
AntaTemplateRenderError
|
||||
If a parameter is missing to render the AntaTemplate instance.
|
||||
"""
|
||||
try:
|
||||
command = self.template.format(**params)
|
||||
|
@ -141,15 +147,24 @@ class AntaCommand(BaseModel):
|
|||
|
||||
Attributes
|
||||
----------
|
||||
command: Device command
|
||||
version: eAPI version - valid values are 1 or "latest".
|
||||
revision: eAPI revision of the command. Valid values are 1 to 99. Revision has precedence over version.
|
||||
ofmt: eAPI output - json or text.
|
||||
output: Output of the command. Only defined if there was no errors.
|
||||
template: AntaTemplate object used to render this command.
|
||||
errors: If the command execution fails, eAPI returns a list of strings detailing the error(s).
|
||||
params: Pydantic Model containing the variables values used to render the template.
|
||||
use_cache: Enable or disable caching for this AntaCommand if the AntaDevice supports it.
|
||||
command
|
||||
Device command.
|
||||
version
|
||||
eAPI version - valid values are 1 or "latest".
|
||||
revision
|
||||
eAPI revision of the command. Valid values are 1 to 99. Revision has precedence over version.
|
||||
ofmt
|
||||
eAPI output - json or text.
|
||||
output
|
||||
Output of the command. Only defined if there was no errors.
|
||||
template
|
||||
AntaTemplate object used to render this command.
|
||||
errors
|
||||
If the command execution fails, eAPI returns a list of strings detailing the error(s).
|
||||
params
|
||||
Pydantic Model containing the variables values used to render the template.
|
||||
use_cache
|
||||
Enable or disable caching for this AntaCommand if the AntaDevice supports it.
|
||||
|
||||
"""
|
||||
|
||||
|
@ -214,9 +229,9 @@ class AntaCommand(BaseModel):
|
|||
|
||||
Raises
|
||||
------
|
||||
RuntimeError
|
||||
If the command has not been collected and has not returned an error.
|
||||
AntaDevice.collect() must be called before this property.
|
||||
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()."
|
||||
|
@ -229,9 +244,9 @@ class AntaCommand(BaseModel):
|
|||
|
||||
Raises
|
||||
------
|
||||
RuntimeError
|
||||
If the command has not been collected and has not returned an error.
|
||||
AntaDevice.collect() must be called before this property.
|
||||
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()."
|
||||
|
@ -245,10 +260,12 @@ class AntaTemplateRenderError(RuntimeError):
|
|||
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
|
||||
Parameters
|
||||
----------
|
||||
template
|
||||
The AntaTemplate instance that failed to render.
|
||||
key
|
||||
Key that has not been provided to render the template.
|
||||
|
||||
"""
|
||||
self.template = template
|
||||
|
@ -297,11 +314,16 @@ class AntaTest(ABC):
|
|||
|
||||
Attributes
|
||||
----------
|
||||
device: AntaDevice instance on which this test is run
|
||||
inputs: AntaTest.Input instance carrying the test inputs
|
||||
instance_commands: List of AntaCommand instances of this test
|
||||
result: TestResult instance representing the result of this test
|
||||
logger: Python logger for this test instance
|
||||
device
|
||||
AntaDevice instance on which this test is run.
|
||||
inputs
|
||||
AntaTest.Input instance carrying the test inputs.
|
||||
instance_commands
|
||||
List of AntaCommand instances of this test.
|
||||
result
|
||||
TestResult instance representing the result of this test.
|
||||
logger
|
||||
Python logger for this test instance.
|
||||
"""
|
||||
|
||||
# Mandatory class attributes
|
||||
|
@ -332,7 +354,8 @@ class AntaTest(ABC):
|
|||
|
||||
Attributes
|
||||
----------
|
||||
result_overwrite: Define fields to overwrite in the TestResult object
|
||||
result_overwrite
|
||||
Define fields to overwrite in the TestResult object.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
@ -351,9 +374,12 @@ class AntaTest(ABC):
|
|||
|
||||
Attributes
|
||||
----------
|
||||
description: overwrite TestResult.description
|
||||
categories: overwrite TestResult.categories
|
||||
custom_field: a free string that will be included in the TestResult object
|
||||
description
|
||||
Overwrite `TestResult.description`.
|
||||
categories
|
||||
Overwrite `TestResult.categories`.
|
||||
custom_field
|
||||
A free string that will be included in the TestResult object.
|
||||
|
||||
"""
|
||||
|
||||
|
@ -367,7 +393,8 @@ class AntaTest(ABC):
|
|||
|
||||
Attributes
|
||||
----------
|
||||
tags: Tag of devices on which to run the test.
|
||||
tags
|
||||
Tag of devices on which to run the test.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
@ -381,12 +408,15 @@ class AntaTest(ABC):
|
|||
) -> None:
|
||||
"""AntaTest Constructor.
|
||||
|
||||
Args:
|
||||
----
|
||||
device: AntaDevice instance on which the test will be run
|
||||
inputs: dictionary of attributes used to instantiate the AntaTest.Input instance
|
||||
eos_data: Populate outputs of the test commands instead of collecting from devices.
|
||||
This list must have the same length and order than the `instance_commands` instance attribute.
|
||||
Parameters
|
||||
----------
|
||||
device
|
||||
AntaDevice instance on which the test will be run.
|
||||
inputs
|
||||
Dictionary of attributes used to instantiate the AntaTest.Input instance.
|
||||
eos_data
|
||||
Populate outputs of the test commands instead of collecting from devices.
|
||||
This list must have the same length and order than the `instance_commands` instance attribute.
|
||||
"""
|
||||
self.logger: logging.Logger = logging.getLogger(f"{self.module}.{self.__class__.__name__}")
|
||||
self.device: AntaDevice = device
|
||||
|
@ -399,7 +429,7 @@ class AntaTest(ABC):
|
|||
description=self.description,
|
||||
)
|
||||
self._init_inputs(inputs)
|
||||
if self.result.result == "unset":
|
||||
if self.result.result == AntaTestStatus.UNSET:
|
||||
self._init_commands(eos_data)
|
||||
|
||||
def _init_inputs(self, inputs: dict[str, Any] | AntaTest.Input | None) -> None:
|
||||
|
@ -450,7 +480,7 @@ class AntaTest(ABC):
|
|||
except NotImplementedError as e:
|
||||
self.result.is_error(message=e.args[0])
|
||||
return
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
except Exception as e: # noqa: BLE001
|
||||
# render() is user-defined code.
|
||||
# We need to catch everything if we want the AntaTest object
|
||||
# to live until the reporting
|
||||
|
@ -528,7 +558,7 @@ class AntaTest(ABC):
|
|||
try:
|
||||
if self.blocked is False:
|
||||
await self.device.collect_commands(self.instance_commands, collection_id=self.name)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
except Exception as e: # noqa: BLE001
|
||||
# device._collect() is user-defined code.
|
||||
# We need to catch everything if we want the AntaTest object
|
||||
# to live until the reporting
|
||||
|
@ -556,16 +586,20 @@ class AntaTest(ABC):
|
|||
) -> 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.
|
||||
Parameters
|
||||
----------
|
||||
self
|
||||
The test instance.
|
||||
eos_data
|
||||
Populate outputs of the test commands instead of collecting from devices.
|
||||
This list must have the same length and order than the `instance_commands` instance attribute.
|
||||
kwargs
|
||||
Any keyword argument to pass to the test.
|
||||
|
||||
Returns
|
||||
-------
|
||||
result: TestResult instance attribute populated with error status if any
|
||||
TestResult
|
||||
The TestResult instance attribute populated with error status if any.
|
||||
|
||||
"""
|
||||
if self.result.result != "unset":
|
||||
|
@ -596,7 +630,7 @@ class AntaTest(ABC):
|
|||
|
||||
try:
|
||||
function(self, **kwargs)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
except Exception as e: # noqa: BLE001
|
||||
# test() is user-defined code.
|
||||
# We need to catch everything if we want the AntaTest object
|
||||
# to live until the reporting
|
||||
|
|
|
@ -7,19 +7,20 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from jinja2 import Template
|
||||
from rich.table import Table
|
||||
|
||||
from anta import RICH_COLOR_PALETTE, RICH_COLOR_THEME
|
||||
from anta.tools import convert_categories
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pathlib
|
||||
|
||||
from anta.custom_types import TestStatus
|
||||
from anta.result_manager import ResultManager
|
||||
from anta.result_manager.models import TestResult
|
||||
from anta.result_manager.models import AntaTestStatus, TestResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -27,17 +28,33 @@ logger = logging.getLogger(__name__)
|
|||
class ReportTable:
|
||||
"""TableReport Generate a Table based on TestResult."""
|
||||
|
||||
@dataclass()
|
||||
class Headers: # pylint: disable=too-many-instance-attributes
|
||||
"""Headers for the table report."""
|
||||
|
||||
device: str = "Device"
|
||||
test_case: str = "Test Name"
|
||||
number_of_success: str = "# of success"
|
||||
number_of_failure: str = "# of failure"
|
||||
number_of_skipped: str = "# of skipped"
|
||||
number_of_errors: str = "# of errors"
|
||||
list_of_error_nodes: str = "List of failed or error nodes"
|
||||
list_of_error_tests: str = "List of failed or error test cases"
|
||||
|
||||
def _split_list_to_txt_list(self, usr_list: list[str], delimiter: str | None = None) -> str:
|
||||
"""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.
|
||||
Parameters
|
||||
----------
|
||||
usr_list : list[str]
|
||||
List of string to concatenate.
|
||||
delimiter : str, optional
|
||||
A delimiter to use to start string. Defaults to None.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str: Multi-lines string
|
||||
str
|
||||
Multi-lines string.
|
||||
|
||||
"""
|
||||
if delimiter is not None:
|
||||
|
@ -49,55 +66,58 @@ class ReportTable:
|
|||
|
||||
First key is considered as header and is colored using RICH_COLOR_PALETTE.HEADER
|
||||
|
||||
Args:
|
||||
----
|
||||
headers: List of headers.
|
||||
table: A rich Table instance.
|
||||
Parameters
|
||||
----------
|
||||
headers
|
||||
List of headers.
|
||||
table
|
||||
A rich Table instance.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Table
|
||||
A rich `Table` instance with headers.
|
||||
|
||||
"""
|
||||
for idx, header in enumerate(headers):
|
||||
if idx == 0:
|
||||
table.add_column(header, justify="left", style=RICH_COLOR_PALETTE.HEADER, no_wrap=True)
|
||||
elif header == "Test Name":
|
||||
# We always want the full test name
|
||||
table.add_column(header, justify="left", no_wrap=True)
|
||||
else:
|
||||
table.add_column(header, justify="left")
|
||||
return table
|
||||
|
||||
def _color_result(self, status: TestStatus) -> str:
|
||||
"""Return a colored string based on the status value.
|
||||
def _color_result(self, status: AntaTestStatus) -> str:
|
||||
"""Return a colored string based on an AntaTestStatus.
|
||||
|
||||
Args:
|
||||
----
|
||||
status (TestStatus): status value to color.
|
||||
Parameters
|
||||
----------
|
||||
status
|
||||
AntaTestStatus enum to color.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str: the colored string
|
||||
|
||||
str
|
||||
The colored string.
|
||||
"""
|
||||
color = RICH_COLOR_THEME.get(status, "")
|
||||
color = RICH_COLOR_THEME.get(str(status), "")
|
||||
return f"[{color}]{status}" if color != "" else str(status)
|
||||
|
||||
def report_all(self, manager: ResultManager, title: str = "All tests results") -> Table:
|
||||
"""Create a table report with all tests for one or all devices.
|
||||
|
||||
Create table with full output: Host / Test / Status / Message
|
||||
Create table with full output: Device | Test Name | Test Status | Message(s) | Test description | Test category
|
||||
|
||||
Args:
|
||||
----
|
||||
manager: A ResultManager instance.
|
||||
title: Title for the report. Defaults to 'All tests results'.
|
||||
Parameters
|
||||
----------
|
||||
manager
|
||||
A ResultManager instance.
|
||||
title
|
||||
Title for the report. Defaults to 'All tests results'.
|
||||
|
||||
Returns
|
||||
-------
|
||||
A fully populated rich `Table`
|
||||
|
||||
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"]
|
||||
|
@ -106,7 +126,7 @@ class ReportTable:
|
|||
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)
|
||||
categories = ", ".join(convert_categories(result.categories))
|
||||
table.add_row(str(result.name), result.test, state, message, result.description, categories)
|
||||
|
||||
for result in manager.results:
|
||||
|
@ -121,43 +141,42 @@ class ReportTable:
|
|||
) -> Table:
|
||||
"""Create a table report with result aggregated per test.
|
||||
|
||||
Create table with full output: Test | Number of success | Number of failure | Number of error | List of nodes in error or failure
|
||||
Create table with full output:
|
||||
Test Name | # of success | # of skipped | # of failure | # of errors | List of failed or error nodes
|
||||
|
||||
Args:
|
||||
----
|
||||
manager: A ResultManager instance.
|
||||
tests: List of test names to include. None to select all tests.
|
||||
title: Title of the report.
|
||||
Parameters
|
||||
----------
|
||||
manager
|
||||
A ResultManager instance.
|
||||
tests
|
||||
List of test names to include. None to select all tests.
|
||||
title
|
||||
Title of the report.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Table
|
||||
A fully populated rich `Table`.
|
||||
"""
|
||||
table = Table(title=title, show_lines=True)
|
||||
headers = [
|
||||
"Test Case",
|
||||
"# of success",
|
||||
"# of skipped",
|
||||
"# of failure",
|
||||
"# of errors",
|
||||
"List of failed or error nodes",
|
||||
self.Headers.test_case,
|
||||
self.Headers.number_of_success,
|
||||
self.Headers.number_of_skipped,
|
||||
self.Headers.number_of_failure,
|
||||
self.Headers.number_of_errors,
|
||||
self.Headers.list_of_error_nodes,
|
||||
]
|
||||
table = self._build_headers(headers=headers, table=table)
|
||||
for test in manager.get_tests():
|
||||
for test, stats in sorted(manager.test_stats.items()):
|
||||
if tests is None or test in tests:
|
||||
results = manager.filter_by_tests({test}).results
|
||||
nb_failure = len([result for result in results if result.result == "failure"])
|
||||
nb_error = len([result for result in results if result.result == "error"])
|
||||
list_failure = [result.name for result in results if result.result in ["failure", "error"]]
|
||||
nb_success = len([result for result in results if result.result == "success"])
|
||||
nb_skipped = len([result for result in results if result.result == "skipped"])
|
||||
table.add_row(
|
||||
test,
|
||||
str(nb_success),
|
||||
str(nb_skipped),
|
||||
str(nb_failure),
|
||||
str(nb_error),
|
||||
str(list_failure),
|
||||
str(stats.devices_success_count),
|
||||
str(stats.devices_skipped_count),
|
||||
str(stats.devices_failure_count),
|
||||
str(stats.devices_error_count),
|
||||
", ".join(stats.devices_failure),
|
||||
)
|
||||
return table
|
||||
|
||||
|
@ -169,43 +188,41 @@ class ReportTable:
|
|||
) -> Table:
|
||||
"""Create a table report with result aggregated per device.
|
||||
|
||||
Create table with full output: Host | Number of success | Number of failure | Number of error | List of nodes in error or failure
|
||||
Create table with full output: Device | # of success | # of skipped | # of failure | # of errors | List of failed or error test cases
|
||||
|
||||
Args:
|
||||
----
|
||||
manager: A ResultManager instance.
|
||||
devices: List of device names to include. None to select all devices.
|
||||
title: Title of the report.
|
||||
Parameters
|
||||
----------
|
||||
manager
|
||||
A ResultManager instance.
|
||||
devices
|
||||
List of device names to include. None to select all devices.
|
||||
title
|
||||
Title of the report.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Table
|
||||
A fully populated rich `Table`.
|
||||
"""
|
||||
table = Table(title=title, show_lines=True)
|
||||
headers = [
|
||||
"Device",
|
||||
"# of success",
|
||||
"# of skipped",
|
||||
"# of failure",
|
||||
"# of errors",
|
||||
"List of failed or error test cases",
|
||||
self.Headers.device,
|
||||
self.Headers.number_of_success,
|
||||
self.Headers.number_of_skipped,
|
||||
self.Headers.number_of_failure,
|
||||
self.Headers.number_of_errors,
|
||||
self.Headers.list_of_error_tests,
|
||||
]
|
||||
table = self._build_headers(headers=headers, table=table)
|
||||
for device in manager.get_devices():
|
||||
for device, stats in sorted(manager.device_stats.items()):
|
||||
if devices is None or device in devices:
|
||||
results = manager.filter_by_devices({device}).results
|
||||
nb_failure = len([result for result in results if result.result == "failure"])
|
||||
nb_error = len([result for result in results if result.result == "error"])
|
||||
list_failure = [result.test for result in results if result.result in ["failure", "error"]]
|
||||
nb_success = len([result for result in results if result.result == "success"])
|
||||
nb_skipped = len([result for result in results if result.result == "skipped"])
|
||||
table.add_row(
|
||||
device,
|
||||
str(nb_success),
|
||||
str(nb_skipped),
|
||||
str(nb_failure),
|
||||
str(nb_error),
|
||||
str(list_failure),
|
||||
str(stats.tests_success_count),
|
||||
str(stats.tests_skipped_count),
|
||||
str(stats.tests_failure_count),
|
||||
str(stats.tests_error_count),
|
||||
", ".join(stats.tests_failure),
|
||||
)
|
||||
return table
|
||||
|
||||
|
@ -227,6 +244,9 @@ class ReportJinja:
|
|||
Report is built based on a J2 template provided by user.
|
||||
Data structure sent to template is:
|
||||
|
||||
Example
|
||||
-------
|
||||
```
|
||||
>>> print(ResultManager.json)
|
||||
[
|
||||
{
|
||||
|
@ -238,15 +258,20 @@ class ReportJinja:
|
|||
description: ...,
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Args:
|
||||
----
|
||||
data: List of results from ResultManager.results
|
||||
trim_blocks: enable trim_blocks for J2 rendering.
|
||||
lstrip_blocks: enable lstrip_blocks for J2 rendering.
|
||||
Parameters
|
||||
----------
|
||||
data
|
||||
List of results from `ResultManager.results`.
|
||||
trim_blocks
|
||||
enable trim_blocks for J2 rendering.
|
||||
lstrip_blocks
|
||||
enable lstrip_blocks for J2 rendering.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Rendered template
|
||||
|
||||
"""
|
||||
|
|
122
anta/reporter/csv_reporter.py
Normal file
122
anta/reporter/csv_reporter.py
Normal file
|
@ -0,0 +1,122 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""CSV Report management for ANTA."""
|
||||
|
||||
# pylint: disable = too-few-public-methods
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from anta.logger import anta_log_exception
|
||||
from anta.tools import convert_categories
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pathlib
|
||||
|
||||
from anta.result_manager import ResultManager
|
||||
from anta.result_manager.models import TestResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReportCsv:
|
||||
"""Build a CSV report."""
|
||||
|
||||
@dataclass()
|
||||
class Headers:
|
||||
"""Headers for the CSV report."""
|
||||
|
||||
device: str = "Device"
|
||||
test_name: str = "Test Name"
|
||||
test_status: str = "Test Status"
|
||||
messages: str = "Message(s)"
|
||||
description: str = "Test description"
|
||||
categories: str = "Test category"
|
||||
|
||||
@classmethod
|
||||
def split_list_to_txt_list(cls, usr_list: list[str], delimiter: str = " - ") -> str:
|
||||
"""Split list to multi-lines string.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
usr_list
|
||||
List of string to concatenate.
|
||||
delimiter
|
||||
A delimiter to use to start string. Defaults to None.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Multi-lines string.
|
||||
|
||||
"""
|
||||
return f"{delimiter}".join(f"{line}" for line in usr_list)
|
||||
|
||||
@classmethod
|
||||
def convert_to_list(cls, result: TestResult) -> list[str]:
|
||||
"""
|
||||
Convert a TestResult into a list of string for creating file content.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
result
|
||||
A TestResult to convert into list.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[str]
|
||||
TestResult converted into a list.
|
||||
"""
|
||||
message = cls.split_list_to_txt_list(result.messages) if len(result.messages) > 0 else ""
|
||||
categories = cls.split_list_to_txt_list(convert_categories(result.categories)) if len(result.categories) > 0 else "None"
|
||||
return [
|
||||
str(result.name),
|
||||
result.test,
|
||||
result.result,
|
||||
message,
|
||||
result.description,
|
||||
categories,
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def generate(cls, results: ResultManager, csv_filename: pathlib.Path) -> None:
|
||||
"""Build CSV flle with tests results.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
results
|
||||
A ResultManager instance.
|
||||
csv_filename
|
||||
File path where to save CSV data.
|
||||
|
||||
Raises
|
||||
------
|
||||
OSError
|
||||
if any is raised while writing the CSV file.
|
||||
"""
|
||||
headers = [
|
||||
cls.Headers.device,
|
||||
cls.Headers.test_name,
|
||||
cls.Headers.test_status,
|
||||
cls.Headers.messages,
|
||||
cls.Headers.description,
|
||||
cls.Headers.categories,
|
||||
]
|
||||
|
||||
try:
|
||||
with csv_filename.open(mode="w", encoding="utf-8") as csvfile:
|
||||
csvwriter = csv.writer(
|
||||
csvfile,
|
||||
delimiter=",",
|
||||
)
|
||||
csvwriter.writerow(headers)
|
||||
for entry in results.results:
|
||||
csvwriter.writerow(cls.convert_to_list(entry))
|
||||
except OSError as exc:
|
||||
message = f"OSError caught while writing the CSV file '{csv_filename.resolve()}'."
|
||||
anta_log_exception(exc, message, logger)
|
||||
raise
|
299
anta/reporter/md_reporter.py
Normal file
299
anta/reporter/md_reporter.py
Normal file
|
@ -0,0 +1,299 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""Markdown report generator for ANTA test results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.constants import MD_REPORT_TOC
|
||||
from anta.logger import anta_log_exception
|
||||
from anta.result_manager.models import AntaTestStatus
|
||||
from anta.tools import convert_categories
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
from io import TextIOWrapper
|
||||
from pathlib import Path
|
||||
|
||||
from anta.result_manager import ResultManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class MDReportGenerator:
|
||||
"""Class responsible for generating a Markdown report based on the provided `ResultManager` object.
|
||||
|
||||
It aggregates different report sections, each represented by a subclass of `MDReportBase`,
|
||||
and sequentially generates their content into a markdown file.
|
||||
|
||||
The `generate` class method will loop over all the section subclasses and call their `generate_section` method.
|
||||
The final report will be generated in the same order as the `sections` list of the method.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def generate(cls, results: ResultManager, md_filename: Path) -> None:
|
||||
"""Generate and write the various sections of the markdown report.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
results
|
||||
The ResultsManager instance containing all test results.
|
||||
md_filename
|
||||
The path to the markdown file to write the report into.
|
||||
"""
|
||||
try:
|
||||
with md_filename.open("w", encoding="utf-8") as mdfile:
|
||||
sections: list[MDReportBase] = [
|
||||
ANTAReport(mdfile, results),
|
||||
TestResultsSummary(mdfile, results),
|
||||
SummaryTotals(mdfile, results),
|
||||
SummaryTotalsDeviceUnderTest(mdfile, results),
|
||||
SummaryTotalsPerCategory(mdfile, results),
|
||||
TestResults(mdfile, results),
|
||||
]
|
||||
for section in sections:
|
||||
section.generate_section()
|
||||
except OSError as exc:
|
||||
message = f"OSError caught while writing the Markdown file '{md_filename.resolve()}'."
|
||||
anta_log_exception(exc, message, logger)
|
||||
raise
|
||||
|
||||
|
||||
class MDReportBase(ABC):
|
||||
"""Base class for all sections subclasses.
|
||||
|
||||
Every subclasses must implement the `generate_section` method that uses the `ResultManager` object
|
||||
to generate and write content to the provided markdown file.
|
||||
"""
|
||||
|
||||
def __init__(self, mdfile: TextIOWrapper, results: ResultManager) -> None:
|
||||
"""Initialize the MDReportBase with an open markdown file object to write to and a ResultManager instance.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mdfile
|
||||
An open file object to write the markdown data into.
|
||||
results
|
||||
The ResultsManager instance containing all test results.
|
||||
"""
|
||||
self.mdfile = mdfile
|
||||
self.results = results
|
||||
|
||||
@abstractmethod
|
||||
def generate_section(self) -> None:
|
||||
"""Abstract method to generate a specific section of the markdown report.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
msg = "Must be implemented by subclasses"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
def generate_rows(self) -> Generator[str, None, None]:
|
||||
"""Generate the rows of a markdown table for a specific report section.
|
||||
|
||||
Subclasses can implement this method to generate the content of the table rows.
|
||||
"""
|
||||
msg = "Subclasses should implement this method"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
def generate_heading_name(self) -> str:
|
||||
"""Generate a formatted heading name based on the class name.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Formatted header name.
|
||||
|
||||
Example
|
||||
-------
|
||||
- `ANTAReport` will become `ANTA Report`.
|
||||
- `TestResultsSummary` will become `Test Results Summary`.
|
||||
"""
|
||||
class_name = self.__class__.__name__
|
||||
|
||||
# Split the class name into words, keeping acronyms together
|
||||
words = re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|\d|\W|$)|\d+", class_name)
|
||||
|
||||
# Capitalize each word, but keep acronyms in all caps
|
||||
formatted_words = [word if word.isupper() else word.capitalize() for word in words]
|
||||
|
||||
return " ".join(formatted_words)
|
||||
|
||||
def write_table(self, table_heading: list[str], *, last_table: bool = False) -> None:
|
||||
"""Write a markdown table with a table heading and multiple rows to the markdown file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
table_heading
|
||||
List of strings to join for the table heading.
|
||||
last_table
|
||||
Flag to determine if it's the last table of the markdown file to avoid unnecessary new line. Defaults to False.
|
||||
"""
|
||||
self.mdfile.write("\n".join(table_heading) + "\n")
|
||||
for row in self.generate_rows():
|
||||
self.mdfile.write(row)
|
||||
if not last_table:
|
||||
self.mdfile.write("\n")
|
||||
|
||||
def write_heading(self, heading_level: int) -> None:
|
||||
"""Write a markdown heading to the markdown file.
|
||||
|
||||
The heading name used is the class name.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
heading_level
|
||||
The level of the heading (1-6).
|
||||
|
||||
Example
|
||||
-------
|
||||
`## Test Results Summary`
|
||||
"""
|
||||
# Ensure the heading level is within the valid range of 1 to 6
|
||||
heading_level = max(1, min(heading_level, 6))
|
||||
heading_name = self.generate_heading_name()
|
||||
heading = "#" * heading_level + " " + heading_name
|
||||
self.mdfile.write(f"{heading}\n\n")
|
||||
|
||||
def safe_markdown(self, text: str | None) -> str:
|
||||
"""Escape markdown characters in the text to prevent markdown rendering issues.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text
|
||||
The text to escape markdown characters from.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The text with escaped markdown characters.
|
||||
"""
|
||||
# Custom field from a TestResult object can be None
|
||||
if text is None:
|
||||
return ""
|
||||
|
||||
# Replace newlines with spaces to keep content on one line
|
||||
text = text.replace("\n", " ")
|
||||
|
||||
# Replace backticks with single quotes
|
||||
return text.replace("`", "'")
|
||||
|
||||
|
||||
class ANTAReport(MDReportBase):
|
||||
"""Generate the `# ANTA Report` section of the markdown report."""
|
||||
|
||||
def generate_section(self) -> None:
|
||||
"""Generate the `# ANTA Report` section of the markdown report."""
|
||||
self.write_heading(heading_level=1)
|
||||
toc = MD_REPORT_TOC
|
||||
self.mdfile.write(toc + "\n\n")
|
||||
|
||||
|
||||
class TestResultsSummary(MDReportBase):
|
||||
"""Generate the `## Test Results Summary` section of the markdown report."""
|
||||
|
||||
def generate_section(self) -> None:
|
||||
"""Generate the `## Test Results Summary` section of the markdown report."""
|
||||
self.write_heading(heading_level=2)
|
||||
|
||||
|
||||
class SummaryTotals(MDReportBase):
|
||||
"""Generate the `### Summary Totals` section of the markdown report."""
|
||||
|
||||
TABLE_HEADING: ClassVar[list[str]] = [
|
||||
"| Total Tests | Total Tests Success | Total Tests Skipped | Total Tests Failure | Total Tests Error |",
|
||||
"| ----------- | ------------------- | ------------------- | ------------------- | ------------------|",
|
||||
]
|
||||
|
||||
def generate_rows(self) -> Generator[str, None, None]:
|
||||
"""Generate the rows of the summary totals table."""
|
||||
yield (
|
||||
f"| {self.results.get_total_results()} "
|
||||
f"| {self.results.get_total_results({AntaTestStatus.SUCCESS})} "
|
||||
f"| {self.results.get_total_results({AntaTestStatus.SKIPPED})} "
|
||||
f"| {self.results.get_total_results({AntaTestStatus.FAILURE})} "
|
||||
f"| {self.results.get_total_results({AntaTestStatus.ERROR})} |\n"
|
||||
)
|
||||
|
||||
def generate_section(self) -> None:
|
||||
"""Generate the `### Summary Totals` section of the markdown report."""
|
||||
self.write_heading(heading_level=3)
|
||||
self.write_table(table_heading=self.TABLE_HEADING)
|
||||
|
||||
|
||||
class SummaryTotalsDeviceUnderTest(MDReportBase):
|
||||
"""Generate the `### Summary Totals Devices Under Tests` section of the markdown report."""
|
||||
|
||||
TABLE_HEADING: ClassVar[list[str]] = [
|
||||
"| Device Under Test | Total Tests | Tests Success | Tests Skipped | Tests Failure | Tests Error | Categories Skipped | Categories Failed |",
|
||||
"| ------------------| ----------- | ------------- | ------------- | ------------- | ----------- | -------------------| ------------------|",
|
||||
]
|
||||
|
||||
def generate_rows(self) -> Generator[str, None, None]:
|
||||
"""Generate the rows of the summary totals device under test table."""
|
||||
for device, stat in self.results.device_stats.items():
|
||||
total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count
|
||||
categories_skipped = ", ".join(sorted(convert_categories(list(stat.categories_skipped))))
|
||||
categories_failed = ", ".join(sorted(convert_categories(list(stat.categories_failed))))
|
||||
yield (
|
||||
f"| {device} | {total_tests} | {stat.tests_success_count} | {stat.tests_skipped_count} | {stat.tests_failure_count} | {stat.tests_error_count} "
|
||||
f"| {categories_skipped or '-'} | {categories_failed or '-'} |\n"
|
||||
)
|
||||
|
||||
def generate_section(self) -> None:
|
||||
"""Generate the `### Summary Totals Devices Under Tests` section of the markdown report."""
|
||||
self.write_heading(heading_level=3)
|
||||
self.write_table(table_heading=self.TABLE_HEADING)
|
||||
|
||||
|
||||
class SummaryTotalsPerCategory(MDReportBase):
|
||||
"""Generate the `### Summary Totals Per Category` section of the markdown report."""
|
||||
|
||||
TABLE_HEADING: ClassVar[list[str]] = [
|
||||
"| Test Category | Total Tests | Tests Success | Tests Skipped | Tests Failure | Tests Error |",
|
||||
"| ------------- | ----------- | ------------- | ------------- | ------------- | ----------- |",
|
||||
]
|
||||
|
||||
def generate_rows(self) -> Generator[str, None, None]:
|
||||
"""Generate the rows of the summary totals per category table."""
|
||||
for category, stat in self.results.sorted_category_stats.items():
|
||||
total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count
|
||||
yield (
|
||||
f"| {category} | {total_tests} | {stat.tests_success_count} | {stat.tests_skipped_count} | {stat.tests_failure_count} "
|
||||
f"| {stat.tests_error_count} |\n"
|
||||
)
|
||||
|
||||
def generate_section(self) -> None:
|
||||
"""Generate the `### Summary Totals Per Category` section of the markdown report."""
|
||||
self.write_heading(heading_level=3)
|
||||
self.write_table(table_heading=self.TABLE_HEADING)
|
||||
|
||||
|
||||
class TestResults(MDReportBase):
|
||||
"""Generates the `## Test Results` section of the markdown report."""
|
||||
|
||||
TABLE_HEADING: ClassVar[list[str]] = [
|
||||
"| Device Under Test | Categories | Test | Description | Custom Field | Result | Messages |",
|
||||
"| ----------------- | ---------- | ---- | ----------- | ------------ | ------ | -------- |",
|
||||
]
|
||||
|
||||
def generate_rows(self) -> Generator[str, None, None]:
|
||||
"""Generate the rows of the all test results table."""
|
||||
for result in self.results.get_results(sort_by=["name", "test"]):
|
||||
messages = self.safe_markdown(", ".join(result.messages))
|
||||
categories = ", ".join(convert_categories(result.categories))
|
||||
yield (
|
||||
f"| {result.name or '-'} | {categories or '-'} | {result.test or '-'} "
|
||||
f"| {result.description or '-'} | {self.safe_markdown(result.custom_field) or '-'} | {result.result or '-'} | {messages or '-'} |\n"
|
||||
)
|
||||
|
||||
def generate_section(self) -> None:
|
||||
"""Generate the `## Test Results` section of the markdown report."""
|
||||
self.write_heading(heading_level=2)
|
||||
self.write_table(table_heading=self.TABLE_HEADING, last_table=True)
|
|
@ -6,14 +6,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from collections import defaultdict
|
||||
from functools import cached_property
|
||||
from itertools import chain
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from anta.result_manager.models import AntaTestStatus, TestResult
|
||||
|
||||
from anta.custom_types import TestStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.result_manager.models import TestResult
|
||||
from .models import CategoryStats, DeviceStats, TestStats
|
||||
|
||||
|
||||
class ResultManager:
|
||||
|
@ -21,52 +20,52 @@ class ResultManager:
|
|||
|
||||
Examples
|
||||
--------
|
||||
Create Inventory:
|
||||
Create Inventory:
|
||||
|
||||
inventory_anta = AntaInventory.parse(
|
||||
filename='examples/inventory.yml',
|
||||
username='ansible',
|
||||
password='ansible',
|
||||
inventory_anta = AntaInventory.parse(
|
||||
filename='examples/inventory.yml',
|
||||
username='ansible',
|
||||
password='ansible',
|
||||
)
|
||||
|
||||
Create Result Manager:
|
||||
|
||||
manager = ResultManager()
|
||||
|
||||
Run tests for all connected devices:
|
||||
|
||||
for device in inventory_anta.get_inventory().devices:
|
||||
manager.add(
|
||||
VerifyNTP(device=device).test()
|
||||
)
|
||||
manager.add(
|
||||
VerifyEOSVersion(device=device).test(version='4.28.3M')
|
||||
)
|
||||
|
||||
Create Result Manager:
|
||||
Print result in native format:
|
||||
|
||||
manager = ResultManager()
|
||||
|
||||
Run tests for all connected devices:
|
||||
|
||||
for device in inventory_anta.get_inventory().devices:
|
||||
manager.add(
|
||||
VerifyNTP(device=device).test()
|
||||
)
|
||||
manager.add(
|
||||
VerifyEOSVersion(device=device).test(version='4.28.3M')
|
||||
)
|
||||
|
||||
Print result in native format:
|
||||
|
||||
manager.results
|
||||
[
|
||||
TestResult(
|
||||
name="pf1",
|
||||
test="VerifyZeroTouch",
|
||||
categories=["configuration"],
|
||||
description="Verifies ZeroTouch is disabled",
|
||||
result="success",
|
||||
messages=[],
|
||||
custom_field=None,
|
||||
),
|
||||
TestResult(
|
||||
name="pf1",
|
||||
test='VerifyNTP',
|
||||
categories=["software"],
|
||||
categories=['system'],
|
||||
description='Verifies if NTP is synchronised.',
|
||||
result='failure',
|
||||
messages=["The device is not synchronized with the configured NTP server(s): 'NTP is disabled.'"],
|
||||
custom_field=None,
|
||||
),
|
||||
]
|
||||
manager.results
|
||||
[
|
||||
TestResult(
|
||||
name="pf1",
|
||||
test="VerifyZeroTouch",
|
||||
categories=["configuration"],
|
||||
description="Verifies ZeroTouch is disabled",
|
||||
result="success",
|
||||
messages=[],
|
||||
custom_field=None,
|
||||
),
|
||||
TestResult(
|
||||
name="pf1",
|
||||
test='VerifyNTP',
|
||||
categories=["software"],
|
||||
categories=['system'],
|
||||
description='Verifies if NTP is synchronised.',
|
||||
result='failure',
|
||||
messages=["The device is not synchronized with the configured NTP server(s): 'NTP is disabled.'"],
|
||||
custom_field=None,
|
||||
),
|
||||
]
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
@ -91,9 +90,13 @@ class ResultManager:
|
|||
error_status is set to True.
|
||||
"""
|
||||
self._result_entries: list[TestResult] = []
|
||||
self.status: TestStatus = "unset"
|
||||
self.status: AntaTestStatus = AntaTestStatus.UNSET
|
||||
self.error_status = False
|
||||
|
||||
self.device_stats: defaultdict[str, DeviceStats] = defaultdict(DeviceStats)
|
||||
self.category_stats: defaultdict[str, CategoryStats] = defaultdict(CategoryStats)
|
||||
self.test_stats: defaultdict[str, TestStats] = defaultdict(TestStats)
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Implement __len__ method to count number of results."""
|
||||
return len(self._result_entries)
|
||||
|
@ -105,67 +108,184 @@ class ResultManager:
|
|||
|
||||
@results.setter
|
||||
def results(self, value: list[TestResult]) -> None:
|
||||
"""Set the list of TestResult."""
|
||||
# When setting the results, we need to reset the state of the current instance
|
||||
self._result_entries = []
|
||||
self.status = "unset"
|
||||
self.status = AntaTestStatus.UNSET
|
||||
self.error_status = False
|
||||
for e in value:
|
||||
self.add(e)
|
||||
|
||||
# Also reset the stats attributes
|
||||
self.device_stats = defaultdict(DeviceStats)
|
||||
self.category_stats = defaultdict(CategoryStats)
|
||||
self.test_stats = defaultdict(TestStats)
|
||||
|
||||
for result in value:
|
||||
self.add(result)
|
||||
|
||||
@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)
|
||||
|
||||
@property
|
||||
def sorted_category_stats(self) -> dict[str, CategoryStats]:
|
||||
"""A property that returns the category_stats dictionary sorted by key name."""
|
||||
return dict(sorted(self.category_stats.items()))
|
||||
|
||||
@cached_property
|
||||
def results_by_status(self) -> dict[AntaTestStatus, list[TestResult]]:
|
||||
"""A cached property that returns the results grouped by status."""
|
||||
return {status: [result for result in self._result_entries if result.result == status] for status in AntaTestStatus}
|
||||
|
||||
def _update_status(self, test_status: AntaTestStatus) -> None:
|
||||
"""Update the status of the ResultManager instance based on the test status.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
test_status
|
||||
AntaTestStatus to update the ResultManager status.
|
||||
"""
|
||||
if test_status == "error":
|
||||
self.error_status = True
|
||||
return
|
||||
if self.status == "unset" or self.status == "skipped" and test_status in {"success", "failure"}:
|
||||
self.status = test_status
|
||||
elif self.status == "success" and test_status == "failure":
|
||||
self.status = AntaTestStatus.FAILURE
|
||||
|
||||
def _update_stats(self, result: TestResult) -> None:
|
||||
"""Update the statistics based on the test result.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
result
|
||||
TestResult to update the statistics.
|
||||
"""
|
||||
count_attr = f"tests_{result.result}_count"
|
||||
|
||||
# Update device stats
|
||||
device_stats: DeviceStats = self.device_stats[result.name]
|
||||
setattr(device_stats, count_attr, getattr(device_stats, count_attr) + 1)
|
||||
if result.result in ("failure", "error"):
|
||||
device_stats.tests_failure.add(result.test)
|
||||
device_stats.categories_failed.update(result.categories)
|
||||
elif result.result == "skipped":
|
||||
device_stats.categories_skipped.update(result.categories)
|
||||
|
||||
# Update category stats
|
||||
for category in result.categories:
|
||||
category_stats: CategoryStats = self.category_stats[category]
|
||||
setattr(category_stats, count_attr, getattr(category_stats, count_attr) + 1)
|
||||
|
||||
# Update test stats
|
||||
count_attr = f"devices_{result.result}_count"
|
||||
test_stats: TestStats = self.test_stats[result.test]
|
||||
setattr(test_stats, count_attr, getattr(test_stats, count_attr) + 1)
|
||||
if result.result in ("failure", "error"):
|
||||
test_stats.devices_failure.add(result.name)
|
||||
|
||||
def add(self, result: TestResult) -> None:
|
||||
"""Add a result to the ResultManager instance.
|
||||
|
||||
Args:
|
||||
----
|
||||
result: TestResult to add to the ResultManager instance.
|
||||
The result is added to the internal list of results and the overall status
|
||||
of the ResultManager instance is updated based on the added test status.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
result
|
||||
TestResult to add to the ResultManager instance.
|
||||
"""
|
||||
|
||||
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)
|
||||
self._update_status(result.result)
|
||||
self._update_stats(result)
|
||||
|
||||
# Every time a new result is added, we need to clear the cached property
|
||||
self.__dict__.pop("results_by_status", None)
|
||||
|
||||
def get_results(self, status: set[AntaTestStatus] | None = None, sort_by: list[str] | None = None) -> list[TestResult]:
|
||||
"""Get the results, optionally filtered by status and sorted by TestResult fields.
|
||||
|
||||
If no status is provided, all results are returned.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
status
|
||||
Optional set of AntaTestStatus enum members to filter the results.
|
||||
sort_by
|
||||
Optional list of TestResult fields to sort the results.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[TestResult]
|
||||
List of results.
|
||||
"""
|
||||
# Return all results if no status is provided, otherwise return results for multiple statuses
|
||||
results = self._result_entries if status is None else list(chain.from_iterable(self.results_by_status.get(status, []) for status in status))
|
||||
|
||||
if sort_by:
|
||||
accepted_fields = TestResult.model_fields.keys()
|
||||
if not set(sort_by).issubset(set(accepted_fields)):
|
||||
msg = f"Invalid sort_by fields: {sort_by}. Accepted fields are: {list(accepted_fields)}"
|
||||
raise ValueError(msg)
|
||||
results = sorted(results, key=lambda result: [getattr(result, field) for field in sort_by])
|
||||
|
||||
return results
|
||||
|
||||
def get_total_results(self, status: set[AntaTestStatus] | None = None) -> int:
|
||||
"""Get the total number of results, optionally filtered by status.
|
||||
|
||||
If no status is provided, the total number of results is returned.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
status
|
||||
Optional set of AntaTestStatus enum members to filter the results.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
Total number of results.
|
||||
"""
|
||||
if status is None:
|
||||
# Return the total number of results
|
||||
return sum(len(results) for results in self.results_by_status.values())
|
||||
|
||||
# Return the total number of results for multiple statuses
|
||||
return sum(len(self.results_by_status.get(status, [])) for status in status)
|
||||
|
||||
def get_status(self, *, ignore_error: bool = False) -> str:
|
||||
"""Return the current status including error_status if ignore_error is False."""
|
||||
return "error" if self.error_status and not ignore_error else self.status
|
||||
|
||||
def filter(self, hide: set[TestStatus]) -> ResultManager:
|
||||
def filter(self, hide: set[AntaTestStatus]) -> ResultManager:
|
||||
"""Get a filtered ResultManager based on test status.
|
||||
|
||||
Args:
|
||||
----
|
||||
hide: set of TestStatus literals to select tests to hide based on their status.
|
||||
Parameters
|
||||
----------
|
||||
hide
|
||||
Set of AntaTestStatus enum members to select tests to hide based on their status.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ResultManager
|
||||
A filtered `ResultManager`.
|
||||
"""
|
||||
possible_statuses = set(AntaTestStatus)
|
||||
manager = ResultManager()
|
||||
manager.results = [test for test in self._result_entries if test.result not in hide]
|
||||
manager.results = self.get_results(possible_statuses - hide)
|
||||
return manager
|
||||
|
||||
def filter_by_tests(self, tests: set[str]) -> ResultManager:
|
||||
"""Get a filtered ResultManager that only contains specific tests.
|
||||
|
||||
Args:
|
||||
----
|
||||
tests: Set of test names to filter the results.
|
||||
Parameters
|
||||
----------
|
||||
tests
|
||||
Set of test names to filter the results.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ResultManager
|
||||
A filtered `ResultManager`.
|
||||
"""
|
||||
manager = ResultManager()
|
||||
|
@ -175,12 +295,14 @@ class ResultManager:
|
|||
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.
|
||||
Parameters
|
||||
----------
|
||||
devices
|
||||
Set of device names to filter the results.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ResultManager
|
||||
A filtered `ResultManager`.
|
||||
"""
|
||||
manager = ResultManager()
|
||||
|
@ -192,6 +314,7 @@ class ResultManager:
|
|||
|
||||
Returns
|
||||
-------
|
||||
set[str]
|
||||
Set of test names.
|
||||
"""
|
||||
return {str(result.test) for result in self._result_entries}
|
||||
|
@ -201,6 +324,7 @@ class ResultManager:
|
|||
|
||||
Returns
|
||||
-------
|
||||
set[str]
|
||||
Set of device names.
|
||||
"""
|
||||
return {str(result.name) for result in self._result_entries}
|
||||
|
|
|
@ -5,9 +5,27 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from anta.custom_types import TestStatus
|
||||
|
||||
class AntaTestStatus(str, Enum):
|
||||
"""Test status Enum for the TestResult.
|
||||
|
||||
NOTE: This could be updated to StrEnum when Python 3.11 is the minimum supported version in ANTA.
|
||||
"""
|
||||
|
||||
UNSET = "unset"
|
||||
SUCCESS = "success"
|
||||
FAILURE = "failure"
|
||||
ERROR = "error"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Override the __str__ method to return the value of the Enum, mimicking the behavior of StrEnum."""
|
||||
return self.value
|
||||
|
||||
|
||||
class TestResult(BaseModel):
|
||||
|
@ -15,13 +33,20 @@ class TestResult(BaseModel):
|
|||
|
||||
Attributes
|
||||
----------
|
||||
name: Device name where the test has run.
|
||||
test: Test name runs on the device.
|
||||
categories: List of categories the TestResult belongs to, by default the AntaTest categories.
|
||||
description: TestResult description, by default the AntaTest description.
|
||||
result: Result of the test. Can be one of "unset", "success", "failure", "error" or "skipped".
|
||||
messages: Message to report after the test if any.
|
||||
custom_field: Custom field to store a string for flexibility in integrating with ANTA
|
||||
name : str
|
||||
Name of the device where the test was run.
|
||||
test : str
|
||||
Name of the test run on the device.
|
||||
categories : list[str]
|
||||
List of categories the TestResult belongs to. Defaults to the AntaTest categories.
|
||||
description : str
|
||||
Description of the TestResult. Defaults to the AntaTest description.
|
||||
result : AntaTestStatus
|
||||
Result of the test. Must be one of the AntaTestStatus Enum values: unset, success, failure, error or skipped.
|
||||
messages : list[str]
|
||||
Messages to report after the test, if any.
|
||||
custom_field : str | None
|
||||
Custom field to store a string for flexibility in integrating with ANTA.
|
||||
|
||||
"""
|
||||
|
||||
|
@ -29,57 +54,63 @@ class TestResult(BaseModel):
|
|||
test: str
|
||||
categories: list[str]
|
||||
description: str
|
||||
result: TestStatus = "unset"
|
||||
result: AntaTestStatus = AntaTestStatus.UNSET
|
||||
messages: list[str] = []
|
||||
custom_field: str | None = None
|
||||
|
||||
def is_success(self, message: str | None = None) -> None:
|
||||
"""Set status to success.
|
||||
|
||||
Args:
|
||||
----
|
||||
message: Optional message related to the test
|
||||
Parameters
|
||||
----------
|
||||
message
|
||||
Optional message related to the test.
|
||||
|
||||
"""
|
||||
self._set_status("success", message)
|
||||
self._set_status(AntaTestStatus.SUCCESS, message)
|
||||
|
||||
def is_failure(self, message: str | None = None) -> None:
|
||||
"""Set status to failure.
|
||||
|
||||
Args:
|
||||
----
|
||||
message: Optional message related to the test
|
||||
Parameters
|
||||
----------
|
||||
message
|
||||
Optional message related to the test.
|
||||
|
||||
"""
|
||||
self._set_status("failure", message)
|
||||
self._set_status(AntaTestStatus.FAILURE, message)
|
||||
|
||||
def is_skipped(self, message: str | None = None) -> None:
|
||||
"""Set status to skipped.
|
||||
|
||||
Args:
|
||||
----
|
||||
message: Optional message related to the test
|
||||
Parameters
|
||||
----------
|
||||
message
|
||||
Optional message related to the test.
|
||||
|
||||
"""
|
||||
self._set_status("skipped", message)
|
||||
self._set_status(AntaTestStatus.SKIPPED, message)
|
||||
|
||||
def is_error(self, message: str | None = None) -> None:
|
||||
"""Set status to error.
|
||||
|
||||
Args:
|
||||
----
|
||||
message: Optional message related to the test
|
||||
Parameters
|
||||
----------
|
||||
message
|
||||
Optional message related to the test.
|
||||
|
||||
"""
|
||||
self._set_status("error", message)
|
||||
self._set_status(AntaTestStatus.ERROR, message)
|
||||
|
||||
def _set_status(self, status: TestStatus, message: str | None = None) -> None:
|
||||
def _set_status(self, status: AntaTestStatus, message: str | None = None) -> None:
|
||||
"""Set status and insert optional message.
|
||||
|
||||
Args:
|
||||
----
|
||||
status: status of the test
|
||||
message: optional message
|
||||
Parameters
|
||||
----------
|
||||
status
|
||||
Status of the test.
|
||||
message
|
||||
Optional message.
|
||||
|
||||
"""
|
||||
self.result = status
|
||||
|
@ -89,3 +120,42 @@ class TestResult(BaseModel):
|
|||
def __str__(self) -> str:
|
||||
"""Return a human readable string of this TestResult."""
|
||||
return f"Test '{self.test}' (on '{self.name}'): Result '{self.result}'\nMessages: {self.messages}"
|
||||
|
||||
|
||||
# Pylint does not treat dataclasses differently: https://github.com/pylint-dev/pylint/issues/9058
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
@dataclass
|
||||
class DeviceStats:
|
||||
"""Device statistics for a run of tests."""
|
||||
|
||||
tests_success_count: int = 0
|
||||
tests_skipped_count: int = 0
|
||||
tests_failure_count: int = 0
|
||||
tests_error_count: int = 0
|
||||
tests_unset_count: int = 0
|
||||
tests_failure: set[str] = field(default_factory=set)
|
||||
categories_failed: set[str] = field(default_factory=set)
|
||||
categories_skipped: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CategoryStats:
|
||||
"""Category statistics for a run of tests."""
|
||||
|
||||
tests_success_count: int = 0
|
||||
tests_skipped_count: int = 0
|
||||
tests_failure_count: int = 0
|
||||
tests_error_count: int = 0
|
||||
tests_unset_count: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestStats:
|
||||
"""Test statistics for a run of tests."""
|
||||
|
||||
devices_success_count: int = 0
|
||||
devices_skipped_count: int = 0
|
||||
devices_failure_count: int = 0
|
||||
devices_error_count: int = 0
|
||||
devices_unset_count: int = 0
|
||||
devices_failure: set[str] = field(default_factory=set)
|
||||
|
|
119
anta/runner.py
119
anta/runner.py
|
@ -40,7 +40,8 @@ def adjust_rlimit_nofile() -> tuple[int, int]:
|
|||
|
||||
Returns
|
||||
-------
|
||||
tuple[int, int]: The new soft and hard limits for open file descriptors.
|
||||
tuple[int, int]
|
||||
The new soft and hard limits for open file descriptors.
|
||||
"""
|
||||
try:
|
||||
nofile = int(os.environ.get("ANTA_NOFILE", DEFAULT_NOFILE))
|
||||
|
@ -50,7 +51,7 @@ def adjust_rlimit_nofile() -> tuple[int, int]:
|
|||
|
||||
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]
|
||||
nofile = min(limits[1], nofile)
|
||||
logger.debug("Setting soft limit for open file descriptors for the current ANTA process to %s", nofile)
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (nofile, limits[1]))
|
||||
return resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||
|
@ -59,9 +60,10 @@ def adjust_rlimit_nofile() -> tuple[int, int]:
|
|||
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.
|
||||
Parameters
|
||||
----------
|
||||
devices
|
||||
List of devices in the inventory.
|
||||
"""
|
||||
for device in devices:
|
||||
if device.cache_statistics is not None:
|
||||
|
@ -78,15 +80,21 @@ def log_cache_statistics(devices: list[AntaDevice]) -> None:
|
|||
async def setup_inventory(inventory: AntaInventory, tags: set[str] | None, devices: set[str] | None, *, established_only: bool) -> AntaInventory | None:
|
||||
"""Set up the inventory for the ANTA run.
|
||||
|
||||
Args:
|
||||
----
|
||||
inventory: AntaInventory object that includes the device(s).
|
||||
tags: Tags to filter devices from the inventory.
|
||||
devices: Devices on which to run tests. None means all devices.
|
||||
Parameters
|
||||
----------
|
||||
inventory
|
||||
AntaInventory object that includes the device(s).
|
||||
tags
|
||||
Tags to filter devices from the inventory.
|
||||
devices
|
||||
Devices on which to run tests. None means all devices.
|
||||
established_only
|
||||
If True use return only devices where a connection is established.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AntaInventory | None: The filtered inventory or None if there are no devices to run tests on.
|
||||
AntaInventory | None
|
||||
The filtered inventory or None if there are no devices to run tests on.
|
||||
"""
|
||||
if len(inventory) == 0:
|
||||
logger.info("The inventory is empty, exiting")
|
||||
|
@ -116,15 +124,20 @@ def prepare_tests(
|
|||
) -> defaultdict[AntaDevice, set[AntaTestDefinition]] | None:
|
||||
"""Prepare the tests to run.
|
||||
|
||||
Args:
|
||||
----
|
||||
inventory: AntaInventory object that includes the device(s).
|
||||
catalog: AntaCatalog object that includes the list of tests.
|
||||
tests: Tests to run against devices. None means all tests.
|
||||
tags: Tags to filter devices from the inventory.
|
||||
Parameters
|
||||
----------
|
||||
inventory
|
||||
AntaInventory object that includes the device(s).
|
||||
catalog
|
||||
AntaCatalog object that includes the list of tests.
|
||||
tests
|
||||
Tests to run against devices. None means all tests.
|
||||
tags
|
||||
Tags to filter devices from the inventory.
|
||||
|
||||
Returns
|
||||
-------
|
||||
defaultdict[AntaDevice, set[AntaTestDefinition]] | None
|
||||
A mapping of devices to the tests to run or None if there are no tests to run.
|
||||
"""
|
||||
# Build indexes for the catalog. If `tests` is set, filter the indexes based on these tests
|
||||
|
@ -133,21 +146,20 @@ def prepare_tests(
|
|||
# Using a set to avoid inserting duplicate tests
|
||||
device_to_tests: defaultdict[AntaDevice, set[AntaTestDefinition]] = defaultdict(set)
|
||||
|
||||
# Create AntaTestRunner tuples from the tags
|
||||
# Create the device to tests mapping from the tags
|
||||
for device in inventory.devices:
|
||||
if tags:
|
||||
# If there are CLI tags, only execute tests with matching tags
|
||||
device_to_tests[device].update(catalog.get_tests_by_tags(tags))
|
||||
if not any(tag in device.tags for tag in tags):
|
||||
# The device does not have any selected tag, skipping
|
||||
continue
|
||||
else:
|
||||
# If there is no CLI tags, execute all tests that do not have any tags
|
||||
device_to_tests[device].update(catalog.tag_to_tests[None])
|
||||
|
||||
# Then add the tests with matching tags from device tags
|
||||
device_to_tests[device].update(catalog.get_tests_by_tags(device.tags))
|
||||
# Add the tests with matching tags from device tags
|
||||
device_to_tests[device].update(catalog.get_tests_by_tags(device.tags))
|
||||
|
||||
catalog.final_tests_count += len(device_to_tests[device])
|
||||
|
||||
if catalog.final_tests_count == 0:
|
||||
if len(device_to_tests.values()) == 0:
|
||||
msg = (
|
||||
f"There are no tests{f' matching the tags {tags} ' if tags else ' '}to run in the current test catalog and device inventory, please verify your inputs."
|
||||
)
|
||||
|
@ -157,15 +169,19 @@ def prepare_tests(
|
|||
return device_to_tests
|
||||
|
||||
|
||||
def get_coroutines(selected_tests: defaultdict[AntaDevice, set[AntaTestDefinition]]) -> list[Coroutine[Any, Any, TestResult]]:
|
||||
def get_coroutines(selected_tests: defaultdict[AntaDevice, set[AntaTestDefinition]], manager: ResultManager) -> list[Coroutine[Any, Any, TestResult]]:
|
||||
"""Get the coroutines for the ANTA run.
|
||||
|
||||
Args:
|
||||
----
|
||||
selected_tests: A mapping of devices to the tests to run. The selected tests are generated by the `prepare_tests` function.
|
||||
Parameters
|
||||
----------
|
||||
selected_tests
|
||||
A mapping of devices to the tests to run. The selected tests are generated by the `prepare_tests` function.
|
||||
manager
|
||||
A ResultManager
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[Coroutine[Any, Any, TestResult]]
|
||||
The list of coroutines to run.
|
||||
"""
|
||||
coros = []
|
||||
|
@ -173,13 +189,14 @@ def get_coroutines(selected_tests: defaultdict[AntaDevice, set[AntaTestDefinitio
|
|||
for test in test_definitions:
|
||||
try:
|
||||
test_instance = test.test(device=device, inputs=test.inputs)
|
||||
manager.add(test_instance.result)
|
||||
coros.append(test_instance.test())
|
||||
except Exception as e: # noqa: PERF203, pylint: disable=broad-exception-caught
|
||||
except Exception as e: # noqa: PERF203, BLE001
|
||||
# An AntaTest instance is potentially user-defined code.
|
||||
# We need to catch everything and exit gracefully with an error message.
|
||||
message = "\n".join(
|
||||
[
|
||||
f"There is an error when creating test {test.test.module}.{test.test.__name__}.",
|
||||
f"There is an error when creating test {test.test.__module__}.{test.test.__name__}.",
|
||||
f"If this is not a custom test implementation: {GITHUB_SUGGESTION}",
|
||||
],
|
||||
)
|
||||
|
@ -199,22 +216,29 @@ async def main( # noqa: PLR0913
|
|||
established_only: bool = True,
|
||||
dry_run: bool = False,
|
||||
) -> 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.
|
||||
devices: Devices on which to run tests. None means all devices. These may come from the `--device / -d` CLI option in NRFU.
|
||||
tests: Tests to run against devices. None means all tests. These may come from the `--test / -t` CLI option in NRFU.
|
||||
tags: Tags to filter devices from the inventory. These may come from the `--tags` CLI option in NRFU.
|
||||
established_only: Include only established device(s).
|
||||
dry_run: Build the list of coroutine to run and stop before test execution.
|
||||
Parameters
|
||||
----------
|
||||
manager
|
||||
ResultManager object to populate with the test results.
|
||||
inventory
|
||||
AntaInventory object that includes the device(s).
|
||||
catalog
|
||||
AntaCatalog object that includes the list of tests.
|
||||
devices
|
||||
Devices on which to run tests. None means all devices. These may come from the `--device / -d` CLI option in NRFU.
|
||||
tests
|
||||
Tests to run against devices. None means all tests. These may come from the `--test / -t` CLI option in NRFU.
|
||||
tags
|
||||
Tags to filter devices from the inventory. These may come from the `--tags` CLI option in NRFU.
|
||||
established_only
|
||||
Include only established device(s).
|
||||
dry_run
|
||||
Build the list of coroutine to run and stop before test execution.
|
||||
"""
|
||||
# Adjust the maximum number of open file descriptors for the ANTA process
|
||||
limits = adjust_rlimit_nofile()
|
||||
|
@ -233,25 +257,26 @@ async def main( # noqa: PLR0913
|
|||
selected_tests = prepare_tests(selected_inventory, catalog, tests, tags)
|
||||
if selected_tests is None:
|
||||
return
|
||||
final_tests_count = sum(len(tests) for tests in selected_tests.values())
|
||||
|
||||
run_info = (
|
||||
"--- ANTA NRFU Run Information ---\n"
|
||||
f"Number of devices: {len(inventory)} ({len(selected_inventory)} established)\n"
|
||||
f"Total number of selected tests: {catalog.final_tests_count}\n"
|
||||
f"Total number of selected tests: {final_tests_count}\n"
|
||||
f"Maximum number of open file descriptors for the current ANTA process: {limits[0]}\n"
|
||||
"---------------------------------"
|
||||
)
|
||||
|
||||
logger.info(run_info)
|
||||
|
||||
if catalog.final_tests_count > limits[0]:
|
||||
if final_tests_count > limits[0]:
|
||||
logger.warning(
|
||||
"The number of concurrent tests is higher than the open file descriptors limit for this ANTA process.\n"
|
||||
"Errors may occur while running the tests.\n"
|
||||
"Please consult the ANTA FAQ."
|
||||
)
|
||||
|
||||
coroutines = get_coroutines(selected_tests)
|
||||
coroutines = get_coroutines(selected_tests, manager)
|
||||
|
||||
if dry_run:
|
||||
logger.info("Dry-run mode, exiting before running the tests.")
|
||||
|
@ -263,8 +288,6 @@ async def main( # noqa: PLR0913
|
|||
AntaTest.nrfu_task = AntaTest.progress.add_task("Running NRFU Tests...", total=len(coroutines))
|
||||
|
||||
with Catchtime(logger=logger, message="Running ANTA tests"):
|
||||
test_results = await asyncio.gather(*coroutines)
|
||||
for r in test_results:
|
||||
manager.add(r)
|
||||
await asyncio.gather(*coroutines)
|
||||
|
||||
log_cache_statistics(selected_inventory.devices)
|
||||
|
|
|
@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Any, ClassVar
|
|||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from anta.custom_types import BfdInterval, BfdMultiplier
|
||||
from anta.custom_types import BfdInterval, BfdMultiplier, BfdProtocol
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
from anta.tools import get_value
|
||||
|
||||
|
@ -45,7 +45,7 @@ class VerifyBFDSpecificPeers(AntaTest):
|
|||
name = "VerifyBFDSpecificPeers"
|
||||
description = "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF."
|
||||
categories: ClassVar[list[str]] = ["bfd"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers", revision=4)]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyBFDSpecificPeers test."""
|
||||
|
@ -126,7 +126,7 @@ class VerifyBFDPeersIntervals(AntaTest):
|
|||
name = "VerifyBFDPeersIntervals"
|
||||
description = "Verifies the timers of the IPv4 BFD peers in the specified VRF."
|
||||
categories: ClassVar[list[str]] = ["bfd"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=4)]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyBFDPeersIntervals test."""
|
||||
|
@ -157,34 +157,34 @@ class VerifyBFDPeersIntervals(AntaTest):
|
|||
for bfd_peers in self.inputs.bfd_peers:
|
||||
peer = str(bfd_peers.peer_address)
|
||||
vrf = bfd_peers.vrf
|
||||
|
||||
# Converting milliseconds intervals into actual value
|
||||
tx_interval = bfd_peers.tx_interval * 1000
|
||||
rx_interval = bfd_peers.rx_interval * 1000
|
||||
tx_interval = bfd_peers.tx_interval
|
||||
rx_interval = bfd_peers.rx_interval
|
||||
multiplier = bfd_peers.multiplier
|
||||
|
||||
# Check if BFD peer configured
|
||||
bfd_output = get_value(
|
||||
self.instance_commands[0].json_output,
|
||||
f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..",
|
||||
separator="..",
|
||||
)
|
||||
|
||||
# Check if BFD peer configured
|
||||
if not bfd_output:
|
||||
failures[peer] = {vrf: "Not Configured"}
|
||||
continue
|
||||
|
||||
# Convert interval timer(s) into milliseconds to be consistent with the inputs.
|
||||
bfd_details = bfd_output.get("peerStatsDetail", {})
|
||||
intervals_ok = (
|
||||
bfd_details.get("operTxInterval") == tx_interval and bfd_details.get("operRxInterval") == rx_interval and bfd_details.get("detectMult") == multiplier
|
||||
)
|
||||
op_tx_interval = bfd_details.get("operTxInterval") // 1000
|
||||
op_rx_interval = bfd_details.get("operRxInterval") // 1000
|
||||
detect_multiplier = bfd_details.get("detectMult")
|
||||
intervals_ok = op_tx_interval == tx_interval and op_rx_interval == rx_interval and detect_multiplier == multiplier
|
||||
|
||||
# Check timers of BFD peer
|
||||
if not intervals_ok:
|
||||
failures[peer] = {
|
||||
vrf: {
|
||||
"tx_interval": bfd_details.get("operTxInterval"),
|
||||
"rx_interval": bfd_details.get("operRxInterval"),
|
||||
"multiplier": bfd_details.get("detectMult"),
|
||||
"tx_interval": op_tx_interval,
|
||||
"rx_interval": op_rx_interval,
|
||||
"multiplier": detect_multiplier,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -285,3 +285,79 @@ class VerifyBFDPeersHealth(AntaTest):
|
|||
if up_failures:
|
||||
up_failures_str = "\n".join(up_failures)
|
||||
self.result.is_failure(f"\nFollowing BFD peers were down:\n{up_failures_str}")
|
||||
|
||||
|
||||
class VerifyBFDPeersRegProtocols(AntaTest):
|
||||
"""Verifies that IPv4 BFD peer(s) have the specified protocol(s) registered.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if IPv4 BFD peers are registered with the specified protocol(s).
|
||||
* Failure: The test will fail if IPv4 BFD peers are not found or the specified protocol(s) are not registered for the BFD peer(s).
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.bfd:
|
||||
- VerifyBFDPeersRegProtocols:
|
||||
bfd_peers:
|
||||
- peer_address: 192.0.255.7
|
||||
vrf: default
|
||||
protocols:
|
||||
- bgp
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyBFDPeersRegProtocols"
|
||||
description = "Verifies that IPv4 BFD peer(s) have the specified protocol(s) registered."
|
||||
categories: ClassVar[list[str]] = ["bfd"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyBFDPeersRegProtocols test."""
|
||||
|
||||
bfd_peers: list[BFDPeer]
|
||||
"""List of IPv4 BFD peers."""
|
||||
|
||||
class BFDPeer(BaseModel):
|
||||
"""Model for an IPv4 BFD peer."""
|
||||
|
||||
peer_address: IPv4Address
|
||||
"""IPv4 address of a BFD peer."""
|
||||
vrf: str = "default"
|
||||
"""Optional VRF for BFD peer. If not provided, it defaults to `default`."""
|
||||
protocols: list[BfdProtocol]
|
||||
"""List of protocols to be verified."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyBFDPeersRegProtocols."""
|
||||
# Initialize failure messages
|
||||
failures: dict[Any, Any] = {}
|
||||
|
||||
# Iterating over BFD peers, extract the parameters and command output
|
||||
for bfd_peer in self.inputs.bfd_peers:
|
||||
peer = str(bfd_peer.peer_address)
|
||||
vrf = bfd_peer.vrf
|
||||
protocols = bfd_peer.protocols
|
||||
bfd_output = get_value(
|
||||
self.instance_commands[0].json_output,
|
||||
f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..",
|
||||
separator="..",
|
||||
)
|
||||
|
||||
# Check if BFD peer configured
|
||||
if not bfd_output:
|
||||
failures[peer] = {vrf: "Not Configured"}
|
||||
continue
|
||||
|
||||
# Check registered protocols
|
||||
difference = set(protocols) - set(get_value(bfd_output, "peerStatsDetail.apps"))
|
||||
|
||||
if difference:
|
||||
failures[peer] = {vrf: sorted(difference)}
|
||||
|
||||
if not failures:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(f"The following BFD peers are not configured or have non-registered protocol(s):\n{failures}")
|
||||
|
|
|
@ -33,16 +33,24 @@ class VerifyReachability(AntaTest):
|
|||
- source: Management0
|
||||
destination: 1.1.1.1
|
||||
vrf: MGMT
|
||||
df_bit: True
|
||||
size: 100
|
||||
- source: Management0
|
||||
destination: 8.8.8.8
|
||||
vrf: MGMT
|
||||
df_bit: True
|
||||
size: 100
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyReachability"
|
||||
description = "Test the network reachability to one or many destination IP(s)."
|
||||
categories: ClassVar[list[str]] = ["connectivity"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="ping vrf {vrf} {destination} source {source} repeat {repeat}", revision=1)]
|
||||
# Removing the <space> between '{size}' and '{df_bit}' to compensate the df-bit set default value
|
||||
# i.e if df-bit kept disable then it will add redundant space in between the command
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaTemplate(template="ping vrf {vrf} {destination} source {source} size {size}{df_bit} repeat {repeat}", revision=1)
|
||||
]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyReachability test."""
|
||||
|
@ -61,15 +69,27 @@ class VerifyReachability(AntaTest):
|
|||
"""VRF context. Defaults to `default`."""
|
||||
repeat: int = 2
|
||||
"""Number of ping repetition. Defaults to 2."""
|
||||
size: int = 100
|
||||
"""Specify datagram size. Defaults to 100."""
|
||||
df_bit: bool = False
|
||||
"""Enable do not fragment bit in IP header. Defaults to False."""
|
||||
|
||||
def 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]
|
||||
commands = []
|
||||
for host in self.inputs.hosts:
|
||||
# Enables do not fragment bit in IP header if needed else keeping disable.
|
||||
# Adding the <space> at start to compensate change in AntaTemplate
|
||||
df_bit = " df-bit" if host.df_bit else ""
|
||||
command = template.render(destination=host.destination, source=host.source, vrf=host.vrf, repeat=host.repeat, size=host.size, df_bit=df_bit)
|
||||
commands.append(command)
|
||||
return commands
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyReachability."""
|
||||
failures = []
|
||||
|
||||
for command in self.instance_commands:
|
||||
src = command.params.source
|
||||
dst = command.params.destination
|
||||
|
|
|
@ -196,4 +196,4 @@ class VerifyFieldNotice72Resolution(AntaTest):
|
|||
self.result.is_success("FN72 is mitigated")
|
||||
return
|
||||
# We should never hit this point
|
||||
self.result.is_error("Error in running test - FixedSystemvrm1 not found")
|
||||
self.result.is_failure("Error in running test - Component FixedSystemvrm1 not found in 'show version'")
|
||||
|
|
196
anta/tests/flow_tracking.py
Normal file
196
anta/tests/flow_tracking.py
Normal file
|
@ -0,0 +1,196 @@
|
|||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||
# Use of this source code is governed by the Apache License 2.0
|
||||
# that can be found in the LICENSE file.
|
||||
"""Module related to the flow tracking tests."""
|
||||
|
||||
# Mypy does not understand AntaTest.Input typing
|
||||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from anta.decorators import skip_on_platforms
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
from anta.tools import get_failed_logs
|
||||
|
||||
|
||||
def validate_record_export(record_export: dict[str, str], tracker_info: dict[str, str]) -> str:
|
||||
"""
|
||||
Validate the record export configuration against the tracker info.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
record_export
|
||||
The expected record export configuration.
|
||||
tracker_info
|
||||
The actual tracker info from the command output.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
A failure message if the record export configuration does not match, otherwise blank string.
|
||||
"""
|
||||
failed_log = ""
|
||||
actual_export = {"inactive timeout": tracker_info.get("inactiveTimeout"), "interval": tracker_info.get("activeInterval")}
|
||||
expected_export = {"inactive timeout": record_export.get("on_inactive_timeout"), "interval": record_export.get("on_interval")}
|
||||
if actual_export != expected_export:
|
||||
failed_log = get_failed_logs(expected_export, actual_export)
|
||||
return failed_log
|
||||
|
||||
|
||||
def validate_exporters(exporters: list[dict[str, str]], tracker_info: dict[str, str]) -> str:
|
||||
"""
|
||||
Validate the exporter configurations against the tracker info.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
exporters
|
||||
The list of expected exporter configurations.
|
||||
tracker_info
|
||||
The actual tracker info from the command output.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Failure message if any exporter configuration does not match.
|
||||
"""
|
||||
failed_log = ""
|
||||
for exporter in exporters:
|
||||
exporter_name = exporter["name"]
|
||||
actual_exporter_info = tracker_info["exporters"].get(exporter_name)
|
||||
if not actual_exporter_info:
|
||||
failed_log += f"\nExporter `{exporter_name}` is not configured."
|
||||
continue
|
||||
|
||||
expected_exporter_data = {"local interface": exporter["local_interface"], "template interval": exporter["template_interval"]}
|
||||
actual_exporter_data = {"local interface": actual_exporter_info["localIntf"], "template interval": actual_exporter_info["templateInterval"]}
|
||||
|
||||
if expected_exporter_data != actual_exporter_data:
|
||||
failed_msg = get_failed_logs(expected_exporter_data, actual_exporter_data)
|
||||
failed_log += f"\nExporter `{exporter_name}`: {failed_msg}"
|
||||
return failed_log
|
||||
|
||||
|
||||
class VerifyHardwareFlowTrackerStatus(AntaTest):
|
||||
"""
|
||||
Verifies if hardware flow tracking is running and an input tracker is active.
|
||||
|
||||
This test optionally verifies the tracker interval/timeout and exporter configuration.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if hardware flow tracking is running and an input tracker is active.
|
||||
* Failure: The test will fail if hardware flow tracking is not running, an input tracker is not active,
|
||||
or the tracker interval/timeout and exporter configuration does not match the expected values.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.flow_tracking:
|
||||
- VerifyFlowTrackingHardware:
|
||||
trackers:
|
||||
- name: FLOW-TRACKER
|
||||
record_export:
|
||||
on_inactive_timeout: 70000
|
||||
on_interval: 300000
|
||||
exporters:
|
||||
- name: CV-TELEMETRY
|
||||
local_interface: Loopback0
|
||||
template_interval: 3600000
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyHardwareFlowTrackerStatus"
|
||||
description = (
|
||||
"Verifies if hardware flow tracking is running and an input tracker is active. Optionally verifies the tracker interval/timeout and exporter configuration."
|
||||
)
|
||||
categories: ClassVar[list[str]] = ["flow tracking"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show flow tracking hardware tracker {name}", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyHardwareFlowTrackerStatus test."""
|
||||
|
||||
trackers: list[FlowTracker]
|
||||
"""List of flow trackers to verify."""
|
||||
|
||||
class FlowTracker(BaseModel):
|
||||
"""Detail of a flow tracker."""
|
||||
|
||||
name: str
|
||||
"""Name of the flow tracker."""
|
||||
|
||||
record_export: RecordExport | None = None
|
||||
"""Record export configuration for the flow tracker."""
|
||||
|
||||
exporters: list[Exporter] | None = None
|
||||
"""List of exporters for the flow tracker."""
|
||||
|
||||
class RecordExport(BaseModel):
|
||||
"""Record export configuration."""
|
||||
|
||||
on_inactive_timeout: int
|
||||
"""Timeout in milliseconds for exporting records when inactive."""
|
||||
|
||||
on_interval: int
|
||||
"""Interval in milliseconds for exporting records."""
|
||||
|
||||
class Exporter(BaseModel):
|
||||
"""Detail of an exporter."""
|
||||
|
||||
name: str
|
||||
"""Name of the exporter."""
|
||||
|
||||
local_interface: str
|
||||
"""Local interface used by the exporter."""
|
||||
|
||||
template_interval: int
|
||||
"""Template interval in milliseconds for the exporter."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render the template for each hardware tracker."""
|
||||
return [template.render(name=tracker.name) for tracker in self.inputs.trackers]
|
||||
|
||||
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyHardwareFlowTrackerStatus."""
|
||||
self.result.is_success()
|
||||
for command, tracker_input in zip(self.instance_commands, self.inputs.trackers):
|
||||
hardware_tracker_name = command.params.name
|
||||
record_export = tracker_input.record_export.model_dump() if tracker_input.record_export else None
|
||||
exporters = [exporter.model_dump() for exporter in tracker_input.exporters] if tracker_input.exporters else None
|
||||
command_output = command.json_output
|
||||
|
||||
# Check if hardware flow tracking is configured
|
||||
if not command_output.get("running"):
|
||||
self.result.is_failure("Hardware flow tracking is not running.")
|
||||
return
|
||||
|
||||
# Check if the input hardware tracker is configured
|
||||
tracker_info = command_output["trackers"].get(hardware_tracker_name)
|
||||
if not tracker_info:
|
||||
self.result.is_failure(f"Hardware flow tracker `{hardware_tracker_name}` is not configured.")
|
||||
continue
|
||||
|
||||
# Check if the input hardware tracker is active
|
||||
if not tracker_info.get("active"):
|
||||
self.result.is_failure(f"Hardware flow tracker `{hardware_tracker_name}` is not active.")
|
||||
continue
|
||||
|
||||
# Check the input hardware tracker timeouts
|
||||
failure_msg = ""
|
||||
if record_export:
|
||||
record_export_failure = validate_record_export(record_export, tracker_info)
|
||||
if record_export_failure:
|
||||
failure_msg += record_export_failure
|
||||
|
||||
# Check the input hardware tracker exporters' configuration
|
||||
if exporters:
|
||||
exporters_failure = validate_exporters(exporters, tracker_info)
|
||||
if exporters_failure:
|
||||
failure_msg += exporters_failure
|
||||
|
||||
if failure_msg:
|
||||
self.result.is_failure(f"{hardware_tracker_name}: {failure_msg}\n")
|
|
@ -15,7 +15,7 @@ from pydantic import BaseModel, Field
|
|||
from pydantic_extra_types.mac_address import MacAddress
|
||||
|
||||
from anta import GITHUB_SUGGESTION
|
||||
from anta.custom_types import EthernetInterface, Interface, Percent, PositiveInteger
|
||||
from anta.custom_types import EthernetInterface, Interface, Percent, PortChannelInterface, PositiveInteger
|
||||
from anta.decorators import skip_on_platforms
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
from anta.tools import custom_division, get_failed_logs, get_item, get_value
|
||||
|
@ -71,7 +71,7 @@ class VerifyInterfaceUtilization(AntaTest):
|
|||
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.")
|
||||
self.result.is_failure(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:
|
||||
|
@ -705,7 +705,7 @@ class VerifyInterfaceIPv4(AntaTest):
|
|||
input_interface_detail = interface
|
||||
break
|
||||
else:
|
||||
self.result.is_error(f"Could not find `{intf}` in the input interfaces. {GITHUB_SUGGESTION}")
|
||||
self.result.is_failure(f"Could not find `{intf}` in the input interfaces. {GITHUB_SUGGESTION}")
|
||||
continue
|
||||
|
||||
input_primary_ip = str(input_interface_detail.primary_ip)
|
||||
|
@ -883,3 +883,107 @@ class VerifyInterfacesSpeed(AntaTest):
|
|||
output["speed"] = f"{custom_division(output['speed'], BPS_GBPS_CONVERSIONS)}Gbps"
|
||||
failed_log = get_failed_logs(expected_interface_output, actual_interface_output)
|
||||
self.result.is_failure(f"For interface {intf}:{failed_log}\n")
|
||||
|
||||
|
||||
class VerifyLACPInterfacesStatus(AntaTest):
|
||||
"""Verifies the Link Aggregation Control Protocol (LACP) status of the provided interfaces.
|
||||
|
||||
- Verifies that the interface is a member of the LACP port channel.
|
||||
- Ensures that the synchronization is established.
|
||||
- Ensures the interfaces are in the correct state for collecting and distributing traffic.
|
||||
- Validates that LACP settings, such as timeouts, are correctly configured. (i.e The long timeout mode, also known as "slow" mode, is the default setting.)
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the provided interfaces are bundled in port channel and all specified parameters are correct.
|
||||
* Failure: The test will fail if any interface is not bundled in port channel or any of specified parameter is not correct.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.interfaces:
|
||||
- VerifyLACPInterfacesStatus:
|
||||
interfaces:
|
||||
- name: Ethernet1
|
||||
portchannel: Port-Channel100
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLACPInterfacesStatus"
|
||||
description = "Verifies the Link Aggregation Control Protocol(LACP) status of the provided interfaces."
|
||||
categories: ClassVar[list[str]] = ["interfaces"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show lacp interface {interface}", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyLACPInterfacesStatus test."""
|
||||
|
||||
interfaces: list[LACPInterface]
|
||||
"""List of LACP member interface."""
|
||||
|
||||
class LACPInterface(BaseModel):
|
||||
"""Model for an LACP member interface."""
|
||||
|
||||
name: EthernetInterface
|
||||
"""Ethernet interface to validate."""
|
||||
portchannel: PortChannelInterface
|
||||
"""Port Channel in which the interface is bundled."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""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 VerifyLACPInterfacesStatus."""
|
||||
self.result.is_success()
|
||||
|
||||
# Member port verification parameters.
|
||||
member_port_details = ["activity", "aggregation", "synchronization", "collecting", "distributing", "timeout"]
|
||||
|
||||
# Iterating over command output for different interfaces
|
||||
for command, input_entry in zip(self.instance_commands, self.inputs.interfaces):
|
||||
interface = input_entry.name
|
||||
portchannel = input_entry.portchannel
|
||||
|
||||
# Verify if a PortChannel is configured with the provided interface
|
||||
if not (interface_details := get_value(command.json_output, f"portChannels.{portchannel}.interfaces.{interface}")):
|
||||
self.result.is_failure(f"Interface '{interface}' is not configured to be a member of LACP '{portchannel}'.")
|
||||
continue
|
||||
|
||||
# Verify the interface is bundled in port channel.
|
||||
actor_port_status = interface_details.get("actorPortStatus")
|
||||
if actor_port_status != "bundled":
|
||||
message = f"For Interface {interface}:\nExpected `bundled` as the local port status, but found `{actor_port_status}` instead.\n"
|
||||
self.result.is_failure(message)
|
||||
continue
|
||||
|
||||
# Collecting actor and partner port details
|
||||
actor_port_details = interface_details.get("actorPortState", {})
|
||||
partner_port_details = interface_details.get("partnerPortState", {})
|
||||
|
||||
# Collecting actual interface details
|
||||
actual_interface_output = {
|
||||
"actor_port_details": {param: actor_port_details.get(param, "NotFound") for param in member_port_details},
|
||||
"partner_port_details": {param: partner_port_details.get(param, "NotFound") for param in member_port_details},
|
||||
}
|
||||
|
||||
# Forming expected interface details
|
||||
expected_details = {param: param != "timeout" for param in member_port_details}
|
||||
expected_interface_output = {"actor_port_details": expected_details, "partner_port_details": expected_details}
|
||||
|
||||
# Forming failure message
|
||||
if actual_interface_output != expected_interface_output:
|
||||
message = f"For Interface {interface}:\n"
|
||||
actor_port_failed_log = get_failed_logs(
|
||||
expected_interface_output.get("actor_port_details", {}), actual_interface_output.get("actor_port_details", {})
|
||||
)
|
||||
partner_port_failed_log = get_failed_logs(
|
||||
expected_interface_output.get("partner_port_details", {}), actual_interface_output.get("partner_port_details", {})
|
||||
)
|
||||
|
||||
if actor_port_failed_log:
|
||||
message += f"Actor port details:{actor_port_failed_log}\n"
|
||||
if partner_port_failed_log:
|
||||
message += f"Partner port details:{partner_port_failed_log}\n"
|
||||
|
||||
self.result.is_failure(message)
|
||||
|
|
|
@ -25,14 +25,17 @@ if TYPE_CHECKING:
|
|||
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.
|
||||
|
||||
Args:
|
||||
----
|
||||
logger: The logger object.
|
||||
command_output: The `show logging` output.
|
||||
Parameters
|
||||
----------
|
||||
logger
|
||||
The logger object.
|
||||
command_output
|
||||
The `show logging` output.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str: The operational logging states.
|
||||
str
|
||||
The operational logging states.
|
||||
|
||||
"""
|
||||
log_states = command_output.partition("\n\nExternal configuration:")[0]
|
||||
|
@ -97,13 +100,13 @@ class VerifyLoggingSourceIntf(AntaTest):
|
|||
```
|
||||
"""
|
||||
|
||||
name = "VerifyLoggingSourceInt"
|
||||
name = "VerifyLoggingSourceIntf"
|
||||
description = "Verifies logging source-interface for a specified VRF."
|
||||
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."""
|
||||
"""Input model for the VerifyLoggingSourceIntf test."""
|
||||
|
||||
interface: str
|
||||
"""Source-interface to use as source IP of log messages."""
|
||||
|
@ -112,7 +115,7 @@ class VerifyLoggingSourceIntf(AntaTest):
|
|||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoggingSourceInt."""
|
||||
"""Main test function for VerifyLoggingSourceIntf."""
|
||||
output = self.instance_commands[0].text_output
|
||||
pattern = rf"Logging source-interface '{self.inputs.interface}'.*VRF {self.inputs.vrf}"
|
||||
if re.search(pattern, _get_logging_states(self.logger, output)):
|
||||
|
@ -268,7 +271,7 @@ class VerifyLoggingTimestamp(AntaTest):
|
|||
"""
|
||||
|
||||
name = "VerifyLoggingTimestamp"
|
||||
description = "Verifies if logs are generated with the riate timestamp."
|
||||
description = "Verifies if logs are generated with the appropriate timestamp."
|
||||
categories: ClassVar[list[str]] = ["logging"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation", ofmt="text"),
|
||||
|
@ -279,7 +282,7 @@ class VerifyLoggingTimestamp(AntaTest):
|
|||
def test(self) -> None:
|
||||
"""Main test function for VerifyLoggingTimestamp."""
|
||||
log_pattern = r"ANTA VerifyLoggingTimestamp validation"
|
||||
timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}-\d{2}:\d{2}"
|
||||
timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}[+-]\d{2}:\d{2}"
|
||||
output = self.instance_commands[1].text_output
|
||||
lines = output.strip().split("\n")[::-1]
|
||||
last_line_with_pattern = ""
|
||||
|
|
|
@ -123,10 +123,7 @@ class VerifyMlagConfigSanity(AntaTest):
|
|||
def test(self) -> None:
|
||||
"""Main test function for VerifyMlagConfigSanity."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if (mlag_status := get_value(command_output, "mlagActive")) is None:
|
||||
self.result.is_error(message="Incorrect JSON response - 'mlagActive' state was not found")
|
||||
return
|
||||
if mlag_status is False:
|
||||
if command_output["mlagActive"] is False:
|
||||
self.result.is_skipped("MLAG is disabled")
|
||||
return
|
||||
keys_to_verify = ["globalConfiguration", "interfaceConfiguration"]
|
||||
|
|
|
@ -8,49 +8,64 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address, IPv4Network, IPv6Address
|
||||
from typing import Any, ClassVar
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from pydantic import BaseModel, Field, PositiveInt, model_validator
|
||||
from pydantic.v1.utils import deep_update
|
||||
from pydantic_extra_types.mac_address import MacAddress
|
||||
|
||||
from anta.custom_types import Afi, MultiProtocolCaps, Safi, Vni
|
||||
from anta.custom_types import Afi, BgpDropStats, BgpUpdateError, MultiProtocolCaps, Safi, Vni
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
from anta.tools import get_item, get_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sys
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self
|
||||
else:
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
def _add_bgp_failures(failures: dict[tuple[str, str | None], dict[str, Any]], afi: Afi, safi: Safi | None, vrf: str, issue: str | dict[str, Any]) -> None:
|
||||
"""Add a BGP failure entry to the given `failures` dictionary.
|
||||
|
||||
Note: This function modifies `failures` in-place.
|
||||
|
||||
Args:
|
||||
----
|
||||
failures: The dictionary to which the failure will be added.
|
||||
afi: The address family identifier.
|
||||
vrf: The VRF name.
|
||||
safi: The subsequent address family identifier.
|
||||
issue: A description of the issue. Can be of any type.
|
||||
Parameters
|
||||
----------
|
||||
failures
|
||||
The dictionary to which the failure will be added.
|
||||
afi
|
||||
The address family identifier.
|
||||
vrf
|
||||
The VRF name.
|
||||
safi
|
||||
The subsequent address family identifier.
|
||||
issue
|
||||
A description of the issue. Can be of any type.
|
||||
|
||||
Example:
|
||||
Example
|
||||
-------
|
||||
The `failures` dictionary will have the following structure:
|
||||
{
|
||||
('afi1', 'safi1'): {
|
||||
'afi': 'afi1',
|
||||
'safi': 'safi1',
|
||||
'vrfs': {
|
||||
'vrf1': issue1,
|
||||
'vrf2': issue2
|
||||
}
|
||||
},
|
||||
('afi2', None): {
|
||||
'afi': 'afi2',
|
||||
'vrfs': {
|
||||
'vrf1': issue3
|
||||
}
|
||||
```
|
||||
{
|
||||
('afi1', 'safi1'): {
|
||||
'afi': 'afi1',
|
||||
'safi': 'safi1',
|
||||
'vrfs': {
|
||||
'vrf1': issue1,
|
||||
'vrf2': issue2
|
||||
}
|
||||
},
|
||||
('afi2', None): {
|
||||
'afi': 'afi2',
|
||||
'vrfs': {
|
||||
'vrf1': issue3
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
"""
|
||||
key = (afi, safi)
|
||||
|
@ -63,23 +78,29 @@ def _add_bgp_failures(failures: dict[tuple[str, str | None], dict[str, Any]], af
|
|||
def _check_peer_issues(peer_data: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Check for issues in BGP peer data.
|
||||
|
||||
Args:
|
||||
----
|
||||
peer_data: The BGP peer data dictionary nested in the `show bgp <afi> <safi> summary` command.
|
||||
Parameters
|
||||
----------
|
||||
peer_data
|
||||
The BGP peer data dictionary nested in the `show bgp <afi> <safi> summary` command.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict: Dictionary with keys indicating issues or an empty dictionary if no issues.
|
||||
dict
|
||||
Dictionary with keys indicating issues or an empty dictionary if no issues.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError: If any of the required keys ("peerState", "inMsgQueue", "outMsgQueue") are missing in `peer_data`, i.e. invalid BGP peer data.
|
||||
ValueError
|
||||
If any of the required keys ("peerState", "inMsgQueue", "outMsgQueue") are missing in `peer_data`, i.e. invalid BGP peer data.
|
||||
|
||||
Example:
|
||||
Example
|
||||
-------
|
||||
{"peerNotFound": True}
|
||||
{"peerState": "Idle", "inMsgQueue": 2, "outMsgQueue": 0}
|
||||
{}
|
||||
This can for instance return
|
||||
```
|
||||
{"peerNotFound": True}
|
||||
{"peerState": "Idle", "inMsgQueue": 2, "outMsgQueue": 0}
|
||||
{}
|
||||
```
|
||||
|
||||
"""
|
||||
if peer_data is None:
|
||||
|
@ -104,17 +125,23 @@ def _add_bgp_routes_failure(
|
|||
|
||||
It identifies any missing routes as well as any routes that are invalid or inactive. The results are returned in a dictionary.
|
||||
|
||||
Args:
|
||||
----
|
||||
bgp_routes: The list of expected routes.
|
||||
bgp_output: The BGP output from the device.
|
||||
peer: The IP address of the BGP peer.
|
||||
vrf: The name of the VRF for which the routes need to be verified.
|
||||
route_type: The type of BGP routes. Defaults to 'advertised_routes'.
|
||||
Parameters
|
||||
----------
|
||||
bgp_routes
|
||||
The list of expected routes.
|
||||
bgp_output
|
||||
The BGP output from the device.
|
||||
peer
|
||||
The IP address of the BGP peer.
|
||||
vrf
|
||||
The name of the VRF for which the routes need to be verified.
|
||||
route_type
|
||||
The type of BGP routes. Defaults to 'advertised_routes'.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict[str, dict[str, dict[str, dict[str, list[str]]]]]: A dictionary containing the missing routes and invalid or inactive routes.
|
||||
dict[str, dict[str, dict[str, dict[str, list[str]]]]]
|
||||
A dictionary containing the missing routes and invalid or inactive routes.
|
||||
|
||||
"""
|
||||
# Prepare the failure routes dictionary
|
||||
|
@ -123,7 +150,7 @@ def _add_bgp_routes_failure(
|
|||
# Iterate over the expected BGP routes
|
||||
for route in bgp_routes:
|
||||
str_route = str(route)
|
||||
failure = {"bgp_peers": {peer: {vrf: {route_type: {str_route: Any}}}}}
|
||||
failure: dict[str, Any] = {"bgp_peers": {peer: {vrf: {route_type: {}}}}}
|
||||
|
||||
# Check if the route is missing in the BGP output
|
||||
if str_route not in bgp_output:
|
||||
|
@ -216,7 +243,7 @@ class VerifyBGPPeerCount(AntaTest):
|
|||
"""Number of expected BGP peer(s)."""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_inputs(self: BaseModel) -> BaseModel:
|
||||
def validate_inputs(self) -> Self:
|
||||
"""Validate the inputs provided to the BgpAfi class.
|
||||
|
||||
If afi is either ipv4 or ipv6, safi must be provided.
|
||||
|
@ -356,7 +383,7 @@ class VerifyBGPPeersHealth(AntaTest):
|
|||
"""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_inputs(self: BaseModel) -> BaseModel:
|
||||
def validate_inputs(self) -> Self:
|
||||
"""Validate the inputs provided to the BgpAfi class.
|
||||
|
||||
If afi is either ipv4 or ipv6, safi must be provided.
|
||||
|
@ -503,7 +530,7 @@ class VerifyBGPSpecificPeers(AntaTest):
|
|||
"""List of BGP IPv4 or IPv6 peer."""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_inputs(self: BaseModel) -> BaseModel:
|
||||
def validate_inputs(self) -> Self:
|
||||
"""Validate the inputs provided to the BgpAfi class.
|
||||
|
||||
If afi is either ipv4 or ipv6, safi must be provided and vrf must NOT be all.
|
||||
|
@ -685,6 +712,8 @@ class VerifyBGPExchangedRoutes(AntaTest):
|
|||
class VerifyBGPPeerMPCaps(AntaTest):
|
||||
"""Verifies the multiprotocol capabilities of a BGP peer in a specified VRF.
|
||||
|
||||
Supports `strict: True` to verify that only the specified capabilities are configured, requiring an exact match.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the BGP peer's multiprotocol capabilities are advertised, received, and enabled in the specified VRF.
|
||||
|
@ -699,6 +728,7 @@ class VerifyBGPPeerMPCaps(AntaTest):
|
|||
bgp_peers:
|
||||
- peer_address: 172.30.11.1
|
||||
vrf: default
|
||||
strict: False
|
||||
capabilities:
|
||||
- ipv4Unicast
|
||||
```
|
||||
|
@ -722,6 +752,8 @@ class VerifyBGPPeerMPCaps(AntaTest):
|
|||
"""IPv4 address of a BGP peer."""
|
||||
vrf: str = "default"
|
||||
"""Optional VRF for BGP peer. If not provided, it defaults to `default`."""
|
||||
strict: bool = False
|
||||
"""If True, requires exact matching of provided capabilities. Defaults to False."""
|
||||
capabilities: list[MultiProtocolCaps]
|
||||
"""List of multiprotocol capabilities to be verified."""
|
||||
|
||||
|
@ -730,14 +762,14 @@ class VerifyBGPPeerMPCaps(AntaTest):
|
|||
"""Main test function for VerifyBGPPeerMPCaps."""
|
||||
failures: dict[str, Any] = {"bgp_peers": {}}
|
||||
|
||||
# Iterate over each bgp peer
|
||||
# Iterate over each bgp peer.
|
||||
for bgp_peer in self.inputs.bgp_peers:
|
||||
peer = str(bgp_peer.peer_address)
|
||||
vrf = bgp_peer.vrf
|
||||
capabilities = bgp_peer.capabilities
|
||||
failure: dict[str, dict[str, dict[str, Any]]] = {"bgp_peers": {peer: {vrf: {}}}}
|
||||
|
||||
# Check if BGP output exists
|
||||
# Check if BGP output exists.
|
||||
if (
|
||||
not (bgp_output := get_value(self.instance_commands[0].json_output, f"vrfs.{vrf}.peerList"))
|
||||
or (bgp_output := get_item(bgp_output, "peerAddress", peer)) is None
|
||||
|
@ -746,8 +778,17 @@ class VerifyBGPPeerMPCaps(AntaTest):
|
|||
failures = deep_update(failures, failure)
|
||||
continue
|
||||
|
||||
# Check each capability
|
||||
# Fetching the capabilities output.
|
||||
bgp_output = get_value(bgp_output, "neighborCapabilities.multiprotocolCaps")
|
||||
|
||||
if bgp_peer.strict and sorted(capabilities) != sorted(bgp_output):
|
||||
failure["bgp_peers"][peer][vrf] = {
|
||||
"status": f"Expected only `{', '.join(capabilities)}` capabilities should be listed but found `{', '.join(bgp_output)}` instead."
|
||||
}
|
||||
failures = deep_update(failures, failure)
|
||||
continue
|
||||
|
||||
# Check each capability
|
||||
for capability in capabilities:
|
||||
capability_output = bgp_output.get(capability)
|
||||
|
||||
|
@ -1226,3 +1267,364 @@ class VerifyBGPTimers(AntaTest):
|
|||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(f"Following BGP peers are not configured or hold and keep-alive timers are not correct:\n{failures}")
|
||||
|
||||
|
||||
class VerifyBGPPeerDropStats(AntaTest):
|
||||
"""Verifies BGP NLRI drop statistics for the provided BGP IPv4 peer(s).
|
||||
|
||||
By default, all drop statistics counters will be checked for any non-zero values.
|
||||
An optional list of specific drop statistics can be provided for granular testing.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the BGP peer's drop statistic(s) are zero.
|
||||
* Failure: The test will fail if the BGP peer's drop statistic(s) are non-zero/Not Found or peer is not configured.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
bgp:
|
||||
- VerifyBGPPeerDropStats:
|
||||
bgp_peers:
|
||||
- peer_address: 172.30.11.1
|
||||
vrf: default
|
||||
drop_stats:
|
||||
- inDropAsloop
|
||||
- prefixEvpnDroppedUnsupportedRouteType
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyBGPPeerDropStats"
|
||||
description = "Verifies the NLRI drop statistics of a BGP IPv4 peer(s)."
|
||||
categories: ClassVar[list[str]] = ["bgp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show bgp neighbors {peer} vrf {vrf}", revision=3)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyBGPPeerDropStats test."""
|
||||
|
||||
bgp_peers: list[BgpPeer]
|
||||
"""List of BGP peers"""
|
||||
|
||||
class BgpPeer(BaseModel):
|
||||
"""Model for a BGP peer."""
|
||||
|
||||
peer_address: IPv4Address
|
||||
"""IPv4 address of a BGP peer."""
|
||||
vrf: str = "default"
|
||||
"""Optional VRF for BGP peer. If not provided, it defaults to `default`."""
|
||||
drop_stats: list[BgpDropStats] | None = None
|
||||
"""Optional list of drop statistics to be verified. If not provided, test will verifies all the drop statistics."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render the template for each BGP peer in the input list."""
|
||||
return [template.render(peer=str(bgp_peer.peer_address), vrf=bgp_peer.vrf) for bgp_peer in self.inputs.bgp_peers]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyBGPPeerDropStats."""
|
||||
failures: dict[Any, Any] = {}
|
||||
|
||||
for command, input_entry in zip(self.instance_commands, self.inputs.bgp_peers):
|
||||
peer = command.params.peer
|
||||
vrf = command.params.vrf
|
||||
drop_statistics = input_entry.drop_stats
|
||||
|
||||
# Verify BGP peer
|
||||
if not (peer_list := get_value(command.json_output, f"vrfs.{vrf}.peerList")) or (peer_detail := get_item(peer_list, "peerAddress", peer)) is None:
|
||||
failures[peer] = {vrf: "Not configured"}
|
||||
continue
|
||||
|
||||
# Verify BGP peer's drop stats
|
||||
drop_stats_output = peer_detail.get("dropStats", {})
|
||||
|
||||
# In case drop stats not provided, It will check all drop statistics
|
||||
if not drop_statistics:
|
||||
drop_statistics = drop_stats_output
|
||||
|
||||
# Verify BGP peer's drop stats
|
||||
drop_stats_not_ok = {
|
||||
drop_stat: drop_stats_output.get(drop_stat, "Not Found") for drop_stat in drop_statistics if drop_stats_output.get(drop_stat, "Not Found")
|
||||
}
|
||||
if any(drop_stats_not_ok):
|
||||
failures[peer] = {vrf: drop_stats_not_ok}
|
||||
|
||||
# Check if any failures
|
||||
if not failures:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(f"The following BGP peers are not configured or have non-zero NLRI drop statistics counters:\n{failures}")
|
||||
|
||||
|
||||
class VerifyBGPPeerUpdateErrors(AntaTest):
|
||||
"""Verifies BGP update error counters for the provided BGP IPv4 peer(s).
|
||||
|
||||
By default, all update error counters will be checked for any non-zero values.
|
||||
An optional list of specific update error counters can be provided for granular testing.
|
||||
|
||||
Note: For "disabledAfiSafi" error counter field, checking that it's not "None" versus 0.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the BGP peer's update error counter(s) are zero/None.
|
||||
* Failure: The test will fail if the BGP peer's update error counter(s) are non-zero/not None/Not Found or
|
||||
peer is not configured.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
bgp:
|
||||
- VerifyBGPPeerUpdateErrors:
|
||||
bgp_peers:
|
||||
- peer_address: 172.30.11.1
|
||||
vrf: default
|
||||
update_error_filter:
|
||||
- inUpdErrWithdraw
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyBGPPeerUpdateErrors"
|
||||
description = "Verifies the update error counters of a BGP IPv4 peer."
|
||||
categories: ClassVar[list[str]] = ["bgp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show bgp neighbors {peer} vrf {vrf}", revision=3)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyBGPPeerUpdateErrors test."""
|
||||
|
||||
bgp_peers: list[BgpPeer]
|
||||
"""List of BGP peers"""
|
||||
|
||||
class BgpPeer(BaseModel):
|
||||
"""Model for a BGP peer."""
|
||||
|
||||
peer_address: IPv4Address
|
||||
"""IPv4 address of a BGP peer."""
|
||||
vrf: str = "default"
|
||||
"""Optional VRF for BGP peer. If not provided, it defaults to `default`."""
|
||||
update_errors: list[BgpUpdateError] | None = None
|
||||
"""Optional list of update error counters to be verified. If not provided, test will verifies all the update error counters."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render the template for each BGP peer in the input list."""
|
||||
return [template.render(peer=str(bgp_peer.peer_address), vrf=bgp_peer.vrf) for bgp_peer in self.inputs.bgp_peers]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyBGPPeerUpdateErrors."""
|
||||
failures: dict[Any, Any] = {}
|
||||
|
||||
for command, input_entry in zip(self.instance_commands, self.inputs.bgp_peers):
|
||||
peer = command.params.peer
|
||||
vrf = command.params.vrf
|
||||
update_error_counters = input_entry.update_errors
|
||||
|
||||
# Verify BGP peer.
|
||||
if not (peer_list := get_value(command.json_output, f"vrfs.{vrf}.peerList")) or (peer_detail := get_item(peer_list, "peerAddress", peer)) is None:
|
||||
failures[peer] = {vrf: "Not configured"}
|
||||
continue
|
||||
|
||||
# Getting the BGP peer's error counters output.
|
||||
error_counters_output = peer_detail.get("peerInUpdateErrors", {})
|
||||
|
||||
# In case update error counters not provided, It will check all the update error counters.
|
||||
if not update_error_counters:
|
||||
update_error_counters = error_counters_output
|
||||
|
||||
# verifying the error counters.
|
||||
error_counters_not_ok = {
|
||||
("disabledAfiSafi" if error_counter == "disabledAfiSafi" else error_counter): value
|
||||
for error_counter in update_error_counters
|
||||
if (value := error_counters_output.get(error_counter, "Not Found")) != "None" and value != 0
|
||||
}
|
||||
if error_counters_not_ok:
|
||||
failures[peer] = {vrf: error_counters_not_ok}
|
||||
|
||||
# Check if any failures
|
||||
if not failures:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(f"The following BGP peers are not configured or have non-zero update error counters:\n{failures}")
|
||||
|
||||
|
||||
class VerifyBgpRouteMaps(AntaTest):
|
||||
"""Verifies BGP inbound and outbound route-maps of BGP IPv4 peer(s).
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the correct route maps are applied in the correct direction (inbound or outbound) for IPv4 BGP peers in the specified VRF.
|
||||
* Failure: The test will fail if BGP peers are not configured or any neighbor has an incorrect or missing route map in either the inbound or outbound direction.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
bgp:
|
||||
- VerifyBgpRouteMaps:
|
||||
bgp_peers:
|
||||
- peer_address: 172.30.11.1
|
||||
vrf: default
|
||||
inbound_route_map: RM-MLAG-PEER-IN
|
||||
outbound_route_map: RM-MLAG-PEER-OUT
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyBgpRouteMaps"
|
||||
description = "Verifies BGP inbound and outbound route-maps of BGP IPv4 peer(s)."
|
||||
categories: ClassVar[list[str]] = ["bgp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show bgp neighbors {peer} vrf {vrf}", revision=3)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyBgpRouteMaps test."""
|
||||
|
||||
bgp_peers: list[BgpPeer]
|
||||
"""List of BGP peers"""
|
||||
|
||||
class BgpPeer(BaseModel):
|
||||
"""Model for a BGP peer."""
|
||||
|
||||
peer_address: IPv4Address
|
||||
"""IPv4 address of a BGP peer."""
|
||||
vrf: str = "default"
|
||||
"""Optional VRF for BGP peer. If not provided, it defaults to `default`."""
|
||||
inbound_route_map: str | None = None
|
||||
"""Inbound route map applied, defaults to None."""
|
||||
outbound_route_map: str | None = None
|
||||
"""Outbound route map applied, defaults to None."""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_inputs(self) -> Self:
|
||||
"""Validate the inputs provided to the BgpPeer class.
|
||||
|
||||
At least one of 'inbound' or 'outbound' route-map must be provided.
|
||||
"""
|
||||
if not (self.inbound_route_map or self.outbound_route_map):
|
||||
msg = "At least one of 'inbound_route_map' or 'outbound_route_map' must be provided."
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render the template for each BGP peer in the input list."""
|
||||
return [template.render(peer=str(bgp_peer.peer_address), vrf=bgp_peer.vrf) for bgp_peer in self.inputs.bgp_peers]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyBgpRouteMaps."""
|
||||
failures: dict[Any, Any] = {}
|
||||
|
||||
for command, input_entry in zip(self.instance_commands, self.inputs.bgp_peers):
|
||||
peer = str(input_entry.peer_address)
|
||||
vrf = input_entry.vrf
|
||||
inbound_route_map = input_entry.inbound_route_map
|
||||
outbound_route_map = input_entry.outbound_route_map
|
||||
failure: dict[Any, Any] = {vrf: {}}
|
||||
|
||||
# Verify BGP peer.
|
||||
if not (peer_list := get_value(command.json_output, f"vrfs.{vrf}.peerList")) or (peer_detail := get_item(peer_list, "peerAddress", peer)) is None:
|
||||
failures[peer] = {vrf: "Not configured"}
|
||||
continue
|
||||
|
||||
# Verify Inbound route-map
|
||||
if inbound_route_map and (inbound_map := peer_detail.get("routeMapInbound", "Not Configured")) != inbound_route_map:
|
||||
failure[vrf].update({"Inbound route-map": inbound_map})
|
||||
|
||||
# Verify Outbound route-map
|
||||
if outbound_route_map and (outbound_map := peer_detail.get("routeMapOutbound", "Not Configured")) != outbound_route_map:
|
||||
failure[vrf].update({"Outbound route-map": outbound_map})
|
||||
|
||||
if failure[vrf]:
|
||||
failures[peer] = failure
|
||||
|
||||
# Check if any failures
|
||||
if not failures:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(
|
||||
f"The following BGP peers are not configured or has an incorrect or missing route map in either the inbound or outbound direction:\n{failures}"
|
||||
)
|
||||
|
||||
|
||||
class VerifyBGPPeerRouteLimit(AntaTest):
|
||||
"""Verifies the maximum routes and optionally verifies the maximum routes warning limit for the provided BGP IPv4 peer(s).
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the BGP peer's maximum routes and, if provided, the maximum routes warning limit are equal to the given limits.
|
||||
* Failure: The test will fail if the BGP peer's maximum routes do not match the given limit, or if the maximum routes warning limit is provided
|
||||
and does not match the given limit, or if the peer is not configured.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.routing:
|
||||
bgp:
|
||||
- VerifyBGPPeerRouteLimit:
|
||||
bgp_peers:
|
||||
- peer_address: 172.30.11.1
|
||||
vrf: default
|
||||
maximum_routes: 12000
|
||||
warning_limit: 10000
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyBGPPeerRouteLimit"
|
||||
description = "Verifies maximum routes and maximum routes warning limit for the provided BGP IPv4 peer(s)."
|
||||
categories: ClassVar[list[str]] = ["bgp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show bgp neighbors {peer} vrf {vrf}", revision=3)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyBGPPeerRouteLimit test."""
|
||||
|
||||
bgp_peers: list[BgpPeer]
|
||||
"""List of BGP peers"""
|
||||
|
||||
class BgpPeer(BaseModel):
|
||||
"""Model for a BGP peer."""
|
||||
|
||||
peer_address: IPv4Address
|
||||
"""IPv4 address of a BGP peer."""
|
||||
vrf: str = "default"
|
||||
"""Optional VRF for BGP peer. If not provided, it defaults to `default`."""
|
||||
maximum_routes: int = Field(ge=0, le=4294967294)
|
||||
"""The maximum allowable number of BGP routes, `0` means unlimited."""
|
||||
warning_limit: int = Field(default=0, ge=0, le=4294967294)
|
||||
"""Optional maximum routes warning limit. If not provided, it defaults to `0` meaning no warning limit."""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""Render the template for each BGP peer in the input list."""
|
||||
return [template.render(peer=str(bgp_peer.peer_address), vrf=bgp_peer.vrf) for bgp_peer in self.inputs.bgp_peers]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyBGPPeerRouteLimit."""
|
||||
failures: dict[Any, Any] = {}
|
||||
|
||||
for command, input_entry in zip(self.instance_commands, self.inputs.bgp_peers):
|
||||
peer = str(input_entry.peer_address)
|
||||
vrf = input_entry.vrf
|
||||
maximum_routes = input_entry.maximum_routes
|
||||
warning_limit = input_entry.warning_limit
|
||||
failure: dict[Any, Any] = {}
|
||||
|
||||
# Verify BGP peer.
|
||||
if not (peer_list := get_value(command.json_output, f"vrfs.{vrf}.peerList")) or (peer_detail := get_item(peer_list, "peerAddress", peer)) is None:
|
||||
failures[peer] = {vrf: "Not configured"}
|
||||
continue
|
||||
|
||||
# Verify maximum routes configured.
|
||||
if (actual_routes := peer_detail.get("maxTotalRoutes", "Not Found")) != maximum_routes:
|
||||
failure["Maximum total routes"] = actual_routes
|
||||
|
||||
# Verify warning limit if given.
|
||||
if warning_limit and (actual_warning_limit := peer_detail.get("totalRoutesWarnLimit", "Not Found")) != warning_limit:
|
||||
failure["Warning limit"] = actual_warning_limit
|
||||
|
||||
# Updated failures if any.
|
||||
if failure:
|
||||
failures[peer] = {vrf: failure}
|
||||
|
||||
# Check if any failures
|
||||
if not failures:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(f"The following BGP peer(s) are not configured or maximum routes and maximum routes warning limit is not correct:\n{failures}")
|
||||
|
|
|
@ -7,13 +7,23 @@
|
|||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address, ip_interface
|
||||
from typing import ClassVar, Literal
|
||||
from functools import cache
|
||||
from ipaddress import IPv4Address, IPv4Interface
|
||||
from typing import TYPE_CHECKING, ClassVar, Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
|
||||
from anta.custom_types import PositiveInteger
|
||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sys
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self
|
||||
else:
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
class VerifyRoutingProtocolModel(AntaTest):
|
||||
"""Verifies the configured routing protocol model is the one we expect.
|
||||
|
@ -83,13 +93,13 @@ class VerifyRoutingTableSize(AntaTest):
|
|||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyRoutingTableSize test."""
|
||||
|
||||
minimum: int
|
||||
minimum: PositiveInteger
|
||||
"""Expected minimum routing table size."""
|
||||
maximum: int
|
||||
maximum: PositiveInteger
|
||||
"""Expected maximum routing table size."""
|
||||
|
||||
@model_validator(mode="after") # type: ignore[misc]
|
||||
def check_min_max(self) -> AntaTest.Input:
|
||||
@model_validator(mode="after")
|
||||
def check_min_max(self) -> Self:
|
||||
"""Validate that maximum is greater than minimum."""
|
||||
if self.minimum > self.maximum:
|
||||
msg = f"Minimum {self.minimum} is greater than maximum {self.maximum}"
|
||||
|
@ -131,7 +141,10 @@ class VerifyRoutingTableEntry(AntaTest):
|
|||
name = "VerifyRoutingTableEntry"
|
||||
description = "Verifies that the provided routes are present in the routing table of a specified VRF."
|
||||
categories: ClassVar[list[str]] = ["routing"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip route vrf {vrf} {route}", revision=4)]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
|
||||
AntaTemplate(template="show ip route vrf {vrf} {route}", revision=4),
|
||||
AntaTemplate(template="show ip route vrf {vrf}", revision=4),
|
||||
]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyRoutingTableEntry test."""
|
||||
|
@ -140,20 +153,35 @@ class VerifyRoutingTableEntry(AntaTest):
|
|||
"""VRF context. Defaults to `default` VRF."""
|
||||
routes: list[IPv4Address]
|
||||
"""List of routes to verify."""
|
||||
collect: Literal["one", "all"] = "one"
|
||||
"""Route collect behavior: one=one route per command, all=all routes in vrf per command. Defaults to `one`"""
|
||||
|
||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||
"""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]
|
||||
"""Render the template for the input vrf."""
|
||||
if template == VerifyRoutingTableEntry.commands[0] and self.inputs.collect == "one":
|
||||
return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes]
|
||||
|
||||
if template == VerifyRoutingTableEntry.commands[1] and self.inputs.collect == "all":
|
||||
return [template.render(vrf=self.inputs.vrf)]
|
||||
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
@cache
|
||||
def ip_interface_ip(route: str) -> IPv4Address:
|
||||
"""Return the IP address of the provided ip route with mask."""
|
||||
return IPv4Interface(route).ip
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyRoutingTableEntry."""
|
||||
missing_routes = []
|
||||
commands_output_route_ips = set()
|
||||
|
||||
for command in self.instance_commands:
|
||||
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))
|
||||
command_output_vrf = command.json_output["vrfs"][self.inputs.vrf]
|
||||
commands_output_route_ips |= {self.ip_interface_ip(route) for route in command_output_vrf["routes"]}
|
||||
|
||||
missing_routes = [str(route) for route in self.inputs.routes if route not in commands_output_route_ips]
|
||||
|
||||
if not missing_routes:
|
||||
self.result.is_success()
|
||||
|
|
|
@ -20,13 +20,15 @@ from anta.tools import get_value
|
|||
def _count_isis_neighbor(isis_neighbor_json: dict[str, Any]) -> int:
|
||||
"""Count the number of isis neighbors.
|
||||
|
||||
Args
|
||||
----
|
||||
isis_neighbor_json: The JSON output of the `show isis neighbors` command.
|
||||
Parameters
|
||||
----------
|
||||
isis_neighbor_json
|
||||
The JSON output of the `show isis neighbors` command.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int: The number of isis neighbors.
|
||||
int
|
||||
The number of isis neighbors.
|
||||
|
||||
"""
|
||||
count = 0
|
||||
|
@ -39,13 +41,15 @@ def _count_isis_neighbor(isis_neighbor_json: dict[str, Any]) -> int:
|
|||
def _get_not_full_isis_neighbors(isis_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return the isis neighbors whose adjacency state is not `up`.
|
||||
|
||||
Args
|
||||
----
|
||||
isis_neighbor_json: The JSON output of the `show isis neighbors` command.
|
||||
Parameters
|
||||
----------
|
||||
isis_neighbor_json
|
||||
The JSON output of the `show isis neighbors` command.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict[str, Any]]: A list of isis neighbors whose adjacency state is not `UP`.
|
||||
list[dict[str, Any]]
|
||||
A list of isis neighbors whose adjacency state is not `UP`.
|
||||
|
||||
"""
|
||||
return [
|
||||
|
@ -66,14 +70,17 @@ def _get_not_full_isis_neighbors(isis_neighbor_json: dict[str, Any]) -> list[dic
|
|||
def _get_full_isis_neighbors(isis_neighbor_json: dict[str, Any], neighbor_state: Literal["up", "down"] = "up") -> list[dict[str, Any]]:
|
||||
"""Return the isis neighbors whose adjacency state is `up`.
|
||||
|
||||
Args
|
||||
----
|
||||
isis_neighbor_json: The JSON output of the `show isis neighbors` command.
|
||||
neighbor_state: Value of the neihbor state we are looking for. Default up
|
||||
Parameters
|
||||
----------
|
||||
isis_neighbor_json
|
||||
The JSON output of the `show isis neighbors` command.
|
||||
neighbor_state
|
||||
Value of the neihbor state we are looking for. Defaults to `up`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict[str, Any]]: A list of isis neighbors whose adjacency state is not `UP`.
|
||||
list[dict[str, Any]]
|
||||
A list of isis neighbors whose adjacency state is not `UP`.
|
||||
|
||||
"""
|
||||
return [
|
||||
|
@ -597,10 +604,6 @@ class VerifyISISSegmentRoutingTunnels(AntaTest):
|
|||
|
||||
This method performs the main test logic for verifying ISIS Segment Routing tunnels.
|
||||
It checks the command output, initiates defaults, and performs various checks on the tunnels.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
self.result.is_success()
|
||||
|
@ -638,13 +641,17 @@ class VerifyISISSegmentRoutingTunnels(AntaTest):
|
|||
"""
|
||||
Check if the tunnel type specified in `via_input` matches any of the tunnel types in `eos_entry`.
|
||||
|
||||
Args:
|
||||
via_input (VerifyISISSegmentRoutingTunnels.Input.Entry.Vias): The input tunnel type to check.
|
||||
eos_entry (dict[str, Any]): The EOS entry containing the tunnel types.
|
||||
Parameters
|
||||
----------
|
||||
via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
|
||||
The input tunnel type to check.
|
||||
eos_entry : dict[str, Any]
|
||||
The EOS entry containing the tunnel types.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool: True if the tunnel type matches any of the tunnel types in `eos_entry`, False otherwise.
|
||||
bool
|
||||
True if the tunnel type matches any of the tunnel types in `eos_entry`, False otherwise.
|
||||
"""
|
||||
if via_input.type is not None:
|
||||
return any(
|
||||
|
@ -662,13 +669,17 @@ class VerifyISISSegmentRoutingTunnels(AntaTest):
|
|||
"""
|
||||
Check if the tunnel nexthop matches the given input.
|
||||
|
||||
Args:
|
||||
via_input (VerifyISISSegmentRoutingTunnels.Input.Entry.Vias): The input via object.
|
||||
eos_entry (dict[str, Any]): The EOS entry dictionary.
|
||||
Parameters
|
||||
----------
|
||||
via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
|
||||
The input via object.
|
||||
eos_entry : dict[str, Any]
|
||||
The EOS entry dictionary.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool: True if the tunnel nexthop matches, False otherwise.
|
||||
bool
|
||||
True if the tunnel nexthop matches, False otherwise.
|
||||
"""
|
||||
if via_input.nexthop is not None:
|
||||
return any(
|
||||
|
@ -686,13 +697,17 @@ class VerifyISISSegmentRoutingTunnels(AntaTest):
|
|||
"""
|
||||
Check if the tunnel interface exists in the given EOS entry.
|
||||
|
||||
Args:
|
||||
via_input (VerifyISISSegmentRoutingTunnels.Input.Entry.Vias): The input via object.
|
||||
eos_entry (dict[str, Any]): The EOS entry dictionary.
|
||||
Parameters
|
||||
----------
|
||||
via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
|
||||
The input via object.
|
||||
eos_entry : dict[str, Any]
|
||||
The EOS entry dictionary.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool: True if the tunnel interface exists, False otherwise.
|
||||
bool
|
||||
True if the tunnel interface exists, False otherwise.
|
||||
"""
|
||||
if via_input.interface is not None:
|
||||
return any(
|
||||
|
@ -710,13 +725,17 @@ class VerifyISISSegmentRoutingTunnels(AntaTest):
|
|||
"""
|
||||
Check if the tunnel ID matches any of the tunnel IDs in the EOS entry's vias.
|
||||
|
||||
Args:
|
||||
via_input (VerifyISISSegmentRoutingTunnels.Input.Entry.Vias): The input vias to check.
|
||||
eos_entry (dict[str, Any]): The EOS entry to compare against.
|
||||
Parameters
|
||||
----------
|
||||
via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
|
||||
The input vias to check.
|
||||
eos_entry : dict[str, Any])
|
||||
The EOS entry to compare against.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool: True if the tunnel ID matches any of the tunnel IDs in the EOS entry's vias, False otherwise.
|
||||
bool
|
||||
True if the tunnel ID matches any of the tunnel IDs in the EOS entry's vias, False otherwise.
|
||||
"""
|
||||
if via_input.tunnel_id is not None:
|
||||
return any(
|
||||
|
|
|
@ -18,13 +18,15 @@ if TYPE_CHECKING:
|
|||
def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int:
|
||||
"""Count the number of OSPF neighbors.
|
||||
|
||||
Args:
|
||||
----
|
||||
ospf_neighbor_json: The JSON output of the `show ip ospf neighbor` command.
|
||||
Parameters
|
||||
----------
|
||||
ospf_neighbor_json
|
||||
The JSON output of the `show ip ospf neighbor` command.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int: The number of OSPF neighbors.
|
||||
int
|
||||
The number of OSPF neighbors.
|
||||
|
||||
"""
|
||||
count = 0
|
||||
|
@ -37,13 +39,15 @@ def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int:
|
|||
def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return the OSPF neighbors whose adjacency state is not `full`.
|
||||
|
||||
Args:
|
||||
----
|
||||
ospf_neighbor_json: The JSON output of the `show ip ospf neighbor` command.
|
||||
Parameters
|
||||
----------
|
||||
ospf_neighbor_json
|
||||
The JSON output of the `show ip ospf neighbor` command.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict[str, Any]]: A list of OSPF neighbors whose adjacency state is not `full`.
|
||||
list[dict[str, Any]]
|
||||
A list of OSPF neighbors whose adjacency state is not `full`.
|
||||
|
||||
"""
|
||||
return [
|
||||
|
@ -63,13 +67,15 @@ def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dic
|
|||
def _get_ospf_max_lsa_info(ospf_process_json: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return information about OSPF instances and their LSAs.
|
||||
|
||||
Args:
|
||||
----
|
||||
ospf_process_json: OSPF process information in JSON format.
|
||||
Parameters
|
||||
----------
|
||||
ospf_process_json
|
||||
OSPF process information in JSON format.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict[str, Any]]: A list of dictionaries containing OSPF LSAs information.
|
||||
list[dict[str, Any]]
|
||||
A list of dictionaries containing OSPF LSAs information.
|
||||
|
||||
"""
|
||||
return [
|
||||
|
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
|||
# mypy: disable-error-code=attr-defined
|
||||
from datetime import datetime, timezone
|
||||
from ipaddress import IPv4Address
|
||||
from typing import ClassVar
|
||||
from typing import TYPE_CHECKING, ClassVar, get_args
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
@ -17,6 +17,14 @@ from anta.custom_types import EcdsaKeySize, EncryptionAlgorithm, PositiveInteger
|
|||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
||||
from anta.tools import get_failed_logs, get_item, get_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sys
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self
|
||||
else:
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
class VerifySSHStatus(AntaTest):
|
||||
"""Verifies if the SSHD agent is disabled in the default VRF.
|
||||
|
@ -47,9 +55,9 @@ class VerifySSHStatus(AntaTest):
|
|||
try:
|
||||
line = next(line for line in command_output.split("\n") if line.startswith("SSHD status"))
|
||||
except StopIteration:
|
||||
self.result.is_error("Could not find SSH status in returned output.")
|
||||
self.result.is_failure("Could not find SSH status in returned output.")
|
||||
return
|
||||
status = line.split("is ")[1]
|
||||
status = line.split()[-1]
|
||||
|
||||
if status == "disabled":
|
||||
self.result.is_success()
|
||||
|
@ -416,19 +424,19 @@ class VerifyAPISSLCertificate(AntaTest):
|
|||
"""The encryption algorithm key size of the certificate."""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_inputs(self: BaseModel) -> BaseModel:
|
||||
def validate_inputs(self) -> Self:
|
||||
"""Validate the key size provided to the APISSLCertificates class.
|
||||
|
||||
If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}.
|
||||
|
||||
If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}.
|
||||
"""
|
||||
if self.encryption_algorithm == "RSA" and self.key_size not in RsaKeySize.__args__:
|
||||
msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {RsaKeySize.__args__}."
|
||||
if self.encryption_algorithm == "RSA" and self.key_size not in get_args(RsaKeySize):
|
||||
msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {get_args(RsaKeySize)}."
|
||||
raise ValueError(msg)
|
||||
|
||||
if self.encryption_algorithm == "ECDSA" and self.key_size not in EcdsaKeySize.__args__:
|
||||
msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {EcdsaKeySize.__args__}."
|
||||
if self.encryption_algorithm == "ECDSA" and self.key_size not in get_args(EcdsaKeySize):
|
||||
msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {get_args(EcdsaKeySize)}."
|
||||
raise ValueError(msg)
|
||||
|
||||
return self
|
||||
|
@ -820,3 +828,37 @@ class VerifySpecificIPSecConn(AntaTest):
|
|||
self.result.is_failure(
|
||||
f"IPv4 security connection `source:{source_input} destination:{destination_input} vrf:{vrf}` for peer `{peer}` is not found."
|
||||
)
|
||||
|
||||
|
||||
class VerifyHardwareEntropy(AntaTest):
|
||||
"""
|
||||
Verifies hardware entropy generation is enabled on device.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if hardware entropy generation is enabled.
|
||||
* Failure: The test will fail if hardware entropy generation is not enabled.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.security:
|
||||
- VerifyHardwareEntropy:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyHardwareEntropy"
|
||||
description = "Verifies hardware entropy generation is enabled on device."
|
||||
categories: ClassVar[list[str]] = ["security"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management security")]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyHardwareEntropy."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
# Check if hardware entropy generation is enabled.
|
||||
if not command_output.get("hardwareEntropyEnabled"):
|
||||
self.result.is_failure("Hardware entropy generation is disabled.")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
|
|
@ -7,10 +7,11 @@
|
|||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
from typing import TYPE_CHECKING, ClassVar, get_args
|
||||
|
||||
from anta.custom_types import PositiveInteger
|
||||
from anta.custom_types import PositiveInteger, SnmpErrorCounter, SnmpPdu
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
from anta.tools import get_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
@ -183,8 +184,12 @@ class VerifySnmpLocation(AntaTest):
|
|||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySnmpLocation."""
|
||||
location = self.instance_commands[0].json_output["location"]["location"]
|
||||
# Verifies the SNMP location is configured.
|
||||
if not (location := get_value(self.instance_commands[0].json_output, "location.location")):
|
||||
self.result.is_failure("SNMP location is not configured.")
|
||||
return
|
||||
|
||||
# Verifies the expected SNMP location.
|
||||
if location != self.inputs.location:
|
||||
self.result.is_failure(f"Expected `{self.inputs.location}` as the location, but found `{location}` instead.")
|
||||
else:
|
||||
|
@ -222,9 +227,126 @@ class VerifySnmpContact(AntaTest):
|
|||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySnmpContact."""
|
||||
contact = self.instance_commands[0].json_output["contact"]["contact"]
|
||||
# Verifies the SNMP contact is configured.
|
||||
if not (contact := get_value(self.instance_commands[0].json_output, "contact.contact")):
|
||||
self.result.is_failure("SNMP contact is not configured.")
|
||||
return
|
||||
|
||||
# Verifies the expected SNMP contact.
|
||||
if contact != self.inputs.contact:
|
||||
self.result.is_failure(f"Expected `{self.inputs.contact}` as the contact, but found `{contact}` instead.")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifySnmpPDUCounters(AntaTest):
|
||||
"""Verifies the SNMP PDU counters.
|
||||
|
||||
By default, all SNMP PDU counters will be checked for any non-zero values.
|
||||
An optional list of specific SNMP PDU(s) can be provided for granular testing.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SNMP PDU counter(s) are non-zero/greater than zero.
|
||||
* Failure: The test will fail if the SNMP PDU counter(s) are zero/None/Not Found.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.snmp:
|
||||
- VerifySnmpPDUCounters:
|
||||
pdus:
|
||||
- outTrapPdus
|
||||
- inGetNextPdus
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifySnmpPDUCounters"
|
||||
description = "Verifies the SNMP PDU counters."
|
||||
categories: ClassVar[list[str]] = ["snmp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySnmpPDUCounters test."""
|
||||
|
||||
pdus: list[SnmpPdu] | None = None
|
||||
"""Optional list of SNMP PDU counters to be verified. If not provided, test will verifies all PDU counters."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySnmpPDUCounters."""
|
||||
snmp_pdus = self.inputs.pdus
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
# Verify SNMP PDU counters.
|
||||
if not (pdu_counters := get_value(command_output, "counters")):
|
||||
self.result.is_failure("SNMP counters not found.")
|
||||
return
|
||||
|
||||
# In case SNMP PDUs not provided, It will check all the update error counters.
|
||||
if not snmp_pdus:
|
||||
snmp_pdus = list(get_args(SnmpPdu))
|
||||
|
||||
failures = {pdu: value for pdu in snmp_pdus if (value := pdu_counters.get(pdu, "Not Found")) == "Not Found" or value == 0}
|
||||
|
||||
# Check if any failures
|
||||
if not failures:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(f"The following SNMP PDU counters are not found or have zero PDU counters:\n{failures}")
|
||||
|
||||
|
||||
class VerifySnmpErrorCounters(AntaTest):
|
||||
"""Verifies the SNMP error counters.
|
||||
|
||||
By default, all error counters will be checked for any non-zero values.
|
||||
An optional list of specific error counters can be provided for granular testing.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the SNMP error counter(s) are zero/None.
|
||||
* Failure: The test will fail if the SNMP error counter(s) are non-zero/not None/Not Found or is not configured.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.snmp:
|
||||
- VerifySnmpErrorCounters:
|
||||
error_counters:
|
||||
- inVersionErrs
|
||||
- inBadCommunityNames
|
||||
"""
|
||||
|
||||
name = "VerifySnmpErrorCounters"
|
||||
description = "Verifies the SNMP error counters."
|
||||
categories: ClassVar[list[str]] = ["snmp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifySnmpErrorCounters test."""
|
||||
|
||||
error_counters: list[SnmpErrorCounter] | None = None
|
||||
"""Optional list of SNMP error counters to be verified. If not provided, test will verifies all error counters."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifySnmpErrorCounters."""
|
||||
error_counters = self.inputs.error_counters
|
||||
command_output = self.instance_commands[0].json_output
|
||||
|
||||
# Verify SNMP PDU counters.
|
||||
if not (snmp_counters := get_value(command_output, "counters")):
|
||||
self.result.is_failure("SNMP counters not found.")
|
||||
return
|
||||
|
||||
# In case SNMP error counters not provided, It will check all the error counters.
|
||||
if not error_counters:
|
||||
error_counters = list(get_args(SnmpErrorCounter))
|
||||
|
||||
error_counters_not_ok = {counter: value for counter in error_counters if (value := snmp_counters.get(counter))}
|
||||
|
||||
# Check if any failures
|
||||
if not error_counters_not_ok:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(f"The following SNMP error counters are not found or have non-zero error counters:\n{error_counters_not_ok}")
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
# mypy: disable-error-code=attr-defined
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar, Literal
|
||||
from typing import Any, ClassVar, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
|
@ -259,3 +259,64 @@ class VerifySTPRootPriority(AntaTest):
|
|||
self.result.is_failure(f"The following instance(s) have the wrong STP root priority configured: {wrong_priority_instances}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
||||
|
||||
class VerifyStpTopologyChanges(AntaTest):
|
||||
"""Verifies the number of changes across all interfaces in the Spanning Tree Protocol (STP) topology is below a threshold.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the total number of changes across all interfaces is less than the specified threshold.
|
||||
* Failure: The test will fail if the total number of changes across all interfaces meets or exceeds the specified threshold,
|
||||
indicating potential instability in the topology.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.stp:
|
||||
- VerifyStpTopologyChanges:
|
||||
threshold: 10
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyStpTopologyChanges"
|
||||
description = "Verifies the number of changes across all interfaces in the Spanning Tree Protocol (STP) topology is below a threshold."
|
||||
categories: ClassVar[list[str]] = ["stp"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree topology status detail", revision=1)]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyStpTopologyChanges test."""
|
||||
|
||||
threshold: int
|
||||
"""The threshold number of changes in the STP topology."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyStpTopologyChanges."""
|
||||
failures: dict[str, Any] = {"topologies": {}}
|
||||
|
||||
command_output = self.instance_commands[0].json_output
|
||||
stp_topologies = command_output.get("topologies", {})
|
||||
|
||||
# verifies all available topologies except the "NoStp" topology.
|
||||
stp_topologies.pop("NoStp", None)
|
||||
|
||||
# Verify the STP topology(s).
|
||||
if not stp_topologies:
|
||||
self.result.is_failure("STP is not configured.")
|
||||
return
|
||||
|
||||
# Verifies the number of changes across all interfaces
|
||||
for topology, topology_details in stp_topologies.items():
|
||||
interfaces = {
|
||||
interface: {"Number of changes": num_of_changes}
|
||||
for interface, details in topology_details.get("interfaces", {}).items()
|
||||
if (num_of_changes := details.get("numChanges")) > self.inputs.threshold
|
||||
}
|
||||
if interfaces:
|
||||
failures["topologies"][topology] = interfaces
|
||||
|
||||
if failures["topologies"]:
|
||||
self.result.is_failure(f"The following STP topologies are not configured or number of changes not within the threshold:\n{failures}")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
|
|
@ -115,3 +115,42 @@ class VerifyStunClient(AntaTest):
|
|||
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}")
|
||||
|
||||
|
||||
class VerifyStunServer(AntaTest):
|
||||
"""
|
||||
Verifies the STUN server status is enabled and running.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the STUN server status is enabled and running.
|
||||
* Failure: The test will fail if the STUN server is disabled or not running.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.stun:
|
||||
- VerifyStunServer:
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyStunServer"
|
||||
description = "Verifies the STUN server status is enabled and running."
|
||||
categories: ClassVar[list[str]] = ["stun"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show stun server status", revision=1)]
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyStunServer."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
status_disabled = not command_output.get("enabled")
|
||||
not_running = command_output.get("pid") == 0
|
||||
|
||||
if status_disabled and not_running:
|
||||
self.result.is_failure("STUN server status is disabled and not running.")
|
||||
elif status_disabled:
|
||||
self.result.is_failure("STUN server status is disabled.")
|
||||
elif not_running:
|
||||
self.result.is_failure("STUN server is not running.")
|
||||
else:
|
||||
self.result.is_success()
|
||||
|
|
|
@ -8,10 +8,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from ipaddress import IPv4Address
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from anta.custom_types import PositiveInteger
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from anta.custom_types import Hostname, PositiveInteger
|
||||
from anta.models import AntaCommand, AntaTest
|
||||
from anta.tools import get_failed_logs, get_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anta.models import AntaTemplate
|
||||
|
@ -85,9 +89,6 @@ class VerifyReloadCause(AntaTest):
|
|||
def test(self) -> None:
|
||||
"""Main test function for VerifyReloadCause."""
|
||||
command_output = self.instance_commands[0].json_output
|
||||
if "resetCauses" not in command_output:
|
||||
self.result.is_error(message="No reload causes available")
|
||||
return
|
||||
if len(command_output["resetCauses"]) == 0:
|
||||
# No reload causes
|
||||
self.result.is_success()
|
||||
|
@ -299,3 +300,93 @@ class VerifyNTP(AntaTest):
|
|||
else:
|
||||
data = command_output.split("\n")[0]
|
||||
self.result.is_failure(f"The device is not synchronized with the configured NTP server(s): '{data}'")
|
||||
|
||||
|
||||
class VerifyNTPAssociations(AntaTest):
|
||||
"""Verifies the Network Time Protocol (NTP) associations.
|
||||
|
||||
Expected Results
|
||||
----------------
|
||||
* Success: The test will pass if the Primary NTP server (marked as preferred) has the condition 'sys.peer' and
|
||||
all other NTP servers have the condition 'candidate'.
|
||||
* Failure: The test will fail if the Primary NTP server (marked as preferred) does not have the condition 'sys.peer' or
|
||||
if any other NTP server does not have the condition 'candidate'.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```yaml
|
||||
anta.tests.system:
|
||||
- VerifyNTPAssociations:
|
||||
ntp_servers:
|
||||
- server_address: 1.1.1.1
|
||||
preferred: True
|
||||
stratum: 1
|
||||
- server_address: 2.2.2.2
|
||||
stratum: 2
|
||||
- server_address: 3.3.3.3
|
||||
stratum: 2
|
||||
```
|
||||
"""
|
||||
|
||||
name = "VerifyNTPAssociations"
|
||||
description = "Verifies the Network Time Protocol (NTP) associations."
|
||||
categories: ClassVar[list[str]] = ["system"]
|
||||
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ntp associations")]
|
||||
|
||||
class Input(AntaTest.Input):
|
||||
"""Input model for the VerifyNTPAssociations test."""
|
||||
|
||||
ntp_servers: list[NTPServer]
|
||||
"""List of NTP servers."""
|
||||
|
||||
class NTPServer(BaseModel):
|
||||
"""Model for a NTP server."""
|
||||
|
||||
server_address: Hostname | IPv4Address
|
||||
"""The NTP server address as an IPv4 address or hostname. The NTP server name defined in the running configuration
|
||||
of the device may change during DNS resolution, which is not handled in ANTA. Please provide the DNS-resolved server name.
|
||||
For example, 'ntp.example.com' in the configuration might resolve to 'ntp3.example.com' in the device output."""
|
||||
preferred: bool = False
|
||||
"""Optional preferred for NTP server. If not provided, it defaults to `False`."""
|
||||
stratum: int = Field(ge=0, le=16)
|
||||
"""NTP stratum level (0 to 15) where 0 is the reference clock and 16 indicates unsynchronized.
|
||||
Values should be between 0 and 15 for valid synchronization and 16 represents an out-of-sync state."""
|
||||
|
||||
@AntaTest.anta_test
|
||||
def test(self) -> None:
|
||||
"""Main test function for VerifyNTPAssociations."""
|
||||
failures: str = ""
|
||||
|
||||
if not (peer_details := get_value(self.instance_commands[0].json_output, "peers")):
|
||||
self.result.is_failure("None of NTP peers are not configured.")
|
||||
return
|
||||
|
||||
# Iterate over each NTP server.
|
||||
for ntp_server in self.inputs.ntp_servers:
|
||||
server_address = str(ntp_server.server_address)
|
||||
preferred = ntp_server.preferred
|
||||
stratum = ntp_server.stratum
|
||||
|
||||
# Check if NTP server details exists.
|
||||
if (peer_detail := get_value(peer_details, server_address, separator="..")) is None:
|
||||
failures += f"NTP peer {server_address} is not configured.\n"
|
||||
continue
|
||||
|
||||
# Collecting the expected NTP peer details.
|
||||
expected_peer_details = {"condition": "candidate", "stratum": stratum}
|
||||
if preferred:
|
||||
expected_peer_details["condition"] = "sys.peer"
|
||||
|
||||
# Collecting the actual NTP peer details.
|
||||
actual_peer_details = {"condition": get_value(peer_detail, "condition"), "stratum": get_value(peer_detail, "stratumLevel")}
|
||||
|
||||
# Collecting failures logs if any.
|
||||
failure_logs = get_failed_logs(expected_peer_details, actual_peer_details)
|
||||
if failure_logs:
|
||||
failures += f"For NTP peer {server_address}:{failure_logs}\n"
|
||||
|
||||
# Check if there are any failures.
|
||||
if not failures:
|
||||
self.result.is_success()
|
||||
else:
|
||||
self.result.is_failure(failures)
|
||||
|
|
|
@ -8,10 +8,13 @@ from __future__ import annotations
|
|||
import cProfile
|
||||
import os
|
||||
import pstats
|
||||
import re
|
||||
from functools import wraps
|
||||
from time import perf_counter
|
||||
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
|
||||
|
||||
from anta.constants import ACRONYM_CATEGORIES
|
||||
from anta.custom_types import REGEXP_PATH_MARKERS
|
||||
from anta.logger import format_td
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
@ -32,14 +35,17 @@ def get_failed_logs(expected_output: dict[Any, Any], actual_output: dict[Any, An
|
|||
|
||||
Returns the failed log or an empty string if there is no difference between the expected and actual output.
|
||||
|
||||
Args:
|
||||
----
|
||||
expected_output (dict): Expected output of a test.
|
||||
actual_output (dict): Actual output of a test
|
||||
Parameters
|
||||
----------
|
||||
expected_output
|
||||
Expected output of a test.
|
||||
actual_output
|
||||
Actual output of a test
|
||||
|
||||
Returns
|
||||
-------
|
||||
str: Failed log of a test.
|
||||
str
|
||||
Failed log of a test.
|
||||
|
||||
"""
|
||||
failed_logs = []
|
||||
|
@ -65,18 +71,20 @@ def custom_division(numerator: float, denominator: float) -> int | float:
|
|||
|
||||
Parameters
|
||||
----------
|
||||
numerator: The numerator.
|
||||
denominator: The denominator.
|
||||
numerator
|
||||
The numerator.
|
||||
denominator
|
||||
The denominator.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Union[int, float]: The result of the division.
|
||||
Union[int, float]
|
||||
The result of the division.
|
||||
"""
|
||||
result = numerator / denominator
|
||||
return int(result) if result.is_integer() else result
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def get_dict_superset(
|
||||
list_of_dicts: list[dict[Any, Any]],
|
||||
input_dict: dict[Any, Any],
|
||||
|
@ -136,7 +144,6 @@ def get_dict_superset(
|
|||
return default
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def get_value(
|
||||
dictionary: dict[Any, Any],
|
||||
key: str,
|
||||
|
@ -193,7 +200,6 @@ def get_value(
|
|||
return value
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def get_item(
|
||||
list_of_dicts: list[dict[Any, Any]],
|
||||
key: Any,
|
||||
|
@ -302,13 +308,15 @@ def cprofile(sort_by: str = "cumtime") -> Callable[[F], F]:
|
|||
profile is conditionally enabled based on the presence of ANTA_CPROFILE environment variable.
|
||||
Expect to decorate an async function.
|
||||
|
||||
Args:
|
||||
----
|
||||
sort_by (str): The criterion to sort the profiling results. Default is 'cumtime'.
|
||||
Parameters
|
||||
----------
|
||||
sort_by
|
||||
The criterion to sort the profiling results. Default is 'cumtime'.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Callable: The decorated function with conditional profiling.
|
||||
Callable
|
||||
The decorated function with conditional profiling.
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
|
@ -318,13 +326,16 @@ def cprofile(sort_by: str = "cumtime") -> Callable[[F], F]:
|
|||
|
||||
If `ANTA_CPROFILE` is set, cProfile is enabled and dumps the stats to the file.
|
||||
|
||||
Args:
|
||||
----
|
||||
*args: Arbitrary positional arguments.
|
||||
**kwargs: Arbitrary keyword arguments.
|
||||
Parameters
|
||||
----------
|
||||
*args
|
||||
Arbitrary positional arguments.
|
||||
**kwargs
|
||||
Arbitrary keyword arguments.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Any
|
||||
The result of the function call.
|
||||
"""
|
||||
cprofile_file = os.environ.get("ANTA_CPROFILE")
|
||||
|
@ -346,3 +357,41 @@ def cprofile(sort_by: str = "cumtime") -> Callable[[F], F]:
|
|||
return cast(F, wrapper)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def safe_command(command: str) -> str:
|
||||
"""Return a sanitized command.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
command
|
||||
The command to sanitize.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The sanitized command.
|
||||
"""
|
||||
return re.sub(rf"{REGEXP_PATH_MARKERS}", "_", command)
|
||||
|
||||
|
||||
def convert_categories(categories: list[str]) -> list[str]:
|
||||
"""Convert categories for reports.
|
||||
|
||||
if the category is part of the defined acronym, transform it to upper case
|
||||
otherwise capitalize the first letter.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
categories
|
||||
A list of categories
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[str]
|
||||
The list of converted categories
|
||||
"""
|
||||
if isinstance(categories, list):
|
||||
return [" ".join(word.upper() if word.lower() in ACRONYM_CATEGORIES else word.title() for word in category.split()) for category in categories]
|
||||
msg = f"Wrong input type '{type(categories)}' for convert_categories."
|
||||
raise TypeError(msg)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue