Merging upstream version 1.1.0.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-05 11:54:55 +01:00
parent 50f8dbf7e8
commit 2044ea6182
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
196 changed files with 10121 additions and 3780 deletions

View file

@ -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

View file

@ -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."

View file

@ -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}",

View file

@ -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]))

View file

@ -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)

View file

@ -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."""

View file

@ -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)

View file

@ -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():

View file

@ -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:

View file

@ -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)

View file

@ -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)

View file

@ -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]

View file

@ -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
View 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."""

View file

@ -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"
]

View file

@ -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.
"""

View file

@ -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(

View file

@ -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

View file

@ -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.

View file

@ -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:

View file

@ -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

View file

@ -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
"""

View 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

View 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)

View file

@ -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}

View file

@ -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)

View file

@ -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)

View file

@ -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}")

View file

@ -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

View file

@ -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
View 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")

View file

@ -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)

View file

@ -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 = ""

View file

@ -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"]

View file

@ -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}")

View file

@ -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()

View file

@ -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(

View file

@ -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 [

View file

@ -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()

View file

@ -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}")

View file

@ -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()

View file

@ -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()

View file

@ -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)

View file

@ -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)