1
0
Fork 0

Adding upstream version 0.15.0.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-05 11:39:42 +01:00
parent 6721599912
commit a1777afd4b
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
103 changed files with 79620 additions and 742 deletions

View file

@ -22,7 +22,7 @@ jobs:
steps:
# Please look up the latest version from
# https://github.com/amannn/action-semantic-pull-request/releases
- uses: amannn/action-semantic-pull-request@v5.4.0
- uses: amannn/action-semantic-pull-request@v5.5.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:

View file

@ -1,15 +1,17 @@
---
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
files: ^(anta|docs|scripts|tests)/
files: ^(anta|docs|scripts|tests|asynceapi)/
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
exclude: docs/.*.svg
- id: end-of-file-fixer
- id: check-added-large-files
exclude: tests/data/.*$
- id: check-merge-conflict
- repo: https://github.com/Lucas-C/pre-commit-hooks
@ -41,7 +43,7 @@ repos:
- '<!--| ~| -->'
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.5
rev: v0.4.2
hooks:
- id: ruff
name: Run Ruff linter
@ -72,25 +74,16 @@ repos:
types: [text]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.9.0
rev: v1.10.0
hooks:
- id: mypy
name: Check typing with mypy
args:
- --config-file=pyproject.toml
additional_dependencies:
- "aio-eapi==0.3.0"
- "click==8.1.3"
- "click-help-colors==0.9.1"
- "cvprac~=1.3"
- "netaddr==0.8.0"
- "pydantic~=2.0"
- "PyYAML==6.0"
- "requests>=2.27"
- "rich~=13.4"
- "asyncssh==2.13.1"
- "Jinja2==3.1.2"
- anta[cli]
- types-PyYAML
- types-paramiko
- types-requests
- types-pyOpenSSL
- pytest
files: ^(anta|tests)/

View file

@ -3,23 +3,30 @@ ARG IMG_OPTION=alpine
### BUILDER
FROM python:${PYTHON_VER}-${IMG_OPTION} as BUILDER
FROM python:${PYTHON_VER}-${IMG_OPTION} AS BUILDER
RUN pip install --upgrade pip
WORKDIR /local
COPY . /local
ENV PYTHONPATH=/local
ENV PATH=$PATH:/root/.local/bin
RUN python -m venv /opt/venv
RUN pip --no-cache-dir install --user .
ENV PATH="/opt/venv/bin:$PATH"
RUN apk add --no-cache build-base # Add build-base package
RUN pip --no-cache-dir install "." &&\
pip --no-cache-dir install ".[cli]"
# ----------------------------------- #
### BASE
FROM python:${PYTHON_VER}-${IMG_OPTION} as BASE
FROM python:${PYTHON_VER}-${IMG_OPTION} AS BASE
# Add a system user
RUN adduser --system anta
# Opencontainer labels
# Labels version and revision will be updating
@ -40,7 +47,12 @@ LABEL "org.opencontainers.image.title"="anta" \
"org.opencontainers.image.revision"="dev" \
"org.opencontainers.image.version"="dev"
COPY --from=BUILDER /root/.local/ /root/.local
ENV PATH=$PATH:/root/.local/bin
# Copy artifacts from builder
COPY --from=BUILDER /opt/venv /opt/venv
ENTRYPOINT [ "/root/.local/bin/anta" ]
# Define PATH and default user
ENV PATH="/opt/venv/bin:$PATH"
USER anta
ENTRYPOINT [ "/opt/venv/bin/anta" ]

28
NOTICE Normal file
View file

@ -0,0 +1,28 @@
ANTA Project
Copyright 2024 Arista Networks
This product includes software developed at Arista Networks.
------------------------------------------------------------------------
This product includes software developed by contributors from the
following projects, which are also licensed under the Apache License, Version 2.0:
1. aio-eapi
- Copyright 2024 Jeremy Schulman
- URL: https://github.com/jeremyschulman/aio-eapi
------------------------------------------------------------------------
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -17,7 +17,7 @@ __credits__ = [
"Guillaume Mulocher",
"Thomas Grimonet",
]
__copyright__ = "Copyright 2022, Arista EMEA AS"
__copyright__ = "Copyright 2022-2024, Arista Networks, Inc."
# ANTA Debug Mode environment variable
__DEBUG__ = bool(os.environ.get("ANTA_DEBUG", "").lower() == "true")

View file

@ -1,106 +0,0 @@
# Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Patch for aioeapi waiting for https://github.com/jeremyschulman/aio-eapi/pull/13."""
from __future__ import annotations
from typing import Any, AnyStr
import aioeapi
Device = aioeapi.Device
class EapiCommandError(RuntimeError):
"""Exception class for EAPI command errors.
Attributes
----------
failed: str - the failed command
errmsg: str - a description of the failure reason
errors: list[str] - the command failure details
passed: list[dict] - a list of command results of the commands that passed
not_exec: list[str] - a list of commands that were not executed
"""
# pylint: disable=too-many-arguments
def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]) -> None:
"""Initializer for the EapiCommandError exception."""
self.failed = failed
self.errmsg = errmsg
self.errors = errors
self.passed = passed
self.not_exec = not_exec
super().__init__()
def __str__(self) -> str:
"""Returns the error message associated with the exception."""
return self.errmsg
aioeapi.EapiCommandError = EapiCommandError
async def jsonrpc_exec(self, jsonrpc: dict) -> list[dict | AnyStr]: # type: ignore
"""Execute the JSON-RPC dictionary object.
Parameters
----------
jsonrpc: dict
The JSON-RPC as created by the `meth`:jsonrpc_command().
Raises
------
EapiCommandError
In the event that a command resulted in an error response.
Returns
-------
The list of command results; either dict or text depending on the
JSON-RPC format pameter.
"""
res = await self.post("/command-api", json=jsonrpc)
res.raise_for_status()
body = res.json()
commands = jsonrpc["params"]["cmds"]
ofmt = jsonrpc["params"]["format"]
get_output = (lambda _r: _r["output"]) if ofmt == "text" else (lambda _r: _r)
# if there are no errors then return the list of command results.
if (err_data := body.get("error")) is None:
return [get_output(cmd_res) for cmd_res in body["result"]]
# ---------------------------------------------------------------------
# if we are here, then there were some command errors. Raise a
# EapiCommandError exception with args (commands that failed, passed,
# not-executed).
# ---------------------------------------------------------------------
# -------------------------- eAPI specification ----------------------
# On an error, no result object is present, only an error object, which
# is guaranteed to have the following attributes: code, messages, and
# data. Similar to the result object in the successful response, the
# data object is a list of objects corresponding to the results of all
# commands up to, and including, the failed command. If there was a an
# error before any commands were executed (e.g. bad credentials), data
# will be empty. The last object in the data array will always
# correspond to the failed command. The command failure details are
# always stored in the errors array.
cmd_data = err_data["data"]
len_data = len(cmd_data)
err_at = len_data - 1
err_msg = err_data["message"]
raise EapiCommandError(
passed=[get_output(cmd_data[cmd_i]) for cmd_i, cmd in enumerate(commands[:err_at])],
failed=commands[err_at]["cmd"],
errors=cmd_data[err_at]["errors"],
errmsg=err_msg,
not_exec=commands[err_at + 1 :],
)
aioeapi.Device.jsonrpc_exec = jsonrpc_exec

View file

@ -7,11 +7,14 @@ from __future__ import annotations
import importlib
import logging
import math
from collections import defaultdict
from inspect import isclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Union
from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_validator
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
@ -43,6 +46,22 @@ class AntaTestDefinition(BaseModel):
test: type[AntaTest]
inputs: AntaTest.Input
@model_serializer()
def serialize_model(self) -> dict[str, AntaTest.Input]:
"""Serialize the AntaTestDefinition model.
The dictionary representing the model will be look like:
```
<AntaTest subclass name>:
<AntaTest.Input compliant dictionary>
```
Returns
-------
A dictionary representing the model.
"""
return {self.test.__name__: self.inputs}
def __init__(self, **data: type[AntaTest] | AntaTest.Input | dict[str, Any] | None) -> None:
"""Inject test in the context to allow to instantiate Input in the BeforeValidator.
@ -157,12 +176,12 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
if isinstance(tests, dict):
# This is an inner Python module
modules.update(AntaCatalogFile.flatten_modules(data=tests, package=module.__name__))
else:
if not isinstance(tests, list):
msg = f"Syntax error when parsing: {tests}\nIt must be a list of ANTA tests. Check the test catalog."
raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError
elif isinstance(tests, list):
# This is a list of AntaTestDefinition
modules[module] = tests
else:
msg = f"Syntax error when parsing: {tests}\nIt must be a list of ANTA tests. Check the test catalog."
raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError
return modules
# ANN401 - Any ok for this validator as we are validating the received data
@ -177,10 +196,15 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
with provided value to validate test inputs.
"""
if isinstance(data, dict):
if not data:
return data
typed_data: dict[ModuleType, list[Any]] = AntaCatalogFile.flatten_modules(data)
for module, tests in typed_data.items():
test_definitions: list[AntaTestDefinition] = []
for test_definition in tests:
if isinstance(test_definition, AntaTestDefinition):
test_definitions.append(test_definition)
continue
if not isinstance(test_definition, dict):
msg = f"Syntax error when parsing: {test_definition}\nIt must be a dictionary. Check the test catalog."
raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError
@ -201,6 +225,20 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
test_definitions.append(AntaTestDefinition(test=test, inputs=test_inputs))
typed_data[module] = test_definitions
return typed_data
return data
def yaml(self) -> str:
"""Return a YAML representation string of this model.
Returns
-------
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)
class AntaCatalog:
@ -232,6 +270,12 @@ 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
@property
def filename(self) -> Path | None:
"""Path of the file used to create this AntaCatalog instance."""
@ -297,7 +341,7 @@ class AntaCatalog:
raise TypeError(msg)
try:
catalog_data = AntaCatalogFile(**data) # type: ignore[arg-type]
catalog_data = AntaCatalogFile(data) # type: ignore[arg-type]
except ValidationError as e:
anta_log_exception(
e,
@ -328,40 +372,85 @@ class AntaCatalog:
raise
return AntaCatalog(tests)
def get_tests_by_tags(self, tags: set[str], *, strict: bool = False) -> list[AntaTestDefinition]:
"""Return all the tests that have matching tags in their input filters.
If strict=True, return only tests that match all the tags provided as input.
If strict=False, return all the tests that match at least one tag provided as input.
def merge(self, catalog: AntaCatalog) -> AntaCatalog:
"""Merge two AntaCatalog instances.
Args:
----
tags: Tags of the tests to get.
strict: Specify if the returned tests must match all the tags provided.
catalog: AntaCatalog instance to merge to this instance.
Returns
-------
List of AntaTestDefinition that match the tags
A new AntaCatalog instance containing the tests of the two instances.
"""
result: list[AntaTestDefinition] = []
return AntaCatalog(tests=self.tests + catalog.tests)
def dump(self) -> AntaCatalogFile:
"""Return an AntaCatalogFile instance from this AntaCatalog instance.
Returns
-------
An AntaCatalogFile instance containing tests of this AntaCatalog instance.
"""
root: dict[ImportString[Any], list[AntaTestDefinition]] = {}
for test in self.tests:
if test.inputs.filters and (f := test.inputs.filters.tags):
if strict:
if all(t in tags for t in f):
result.append(test)
elif any(t in tags for t in f):
result.append(test)
return result
# Cannot use AntaTest.module property as the class is not instantiated
root.setdefault(test.test.__module__, []).append(test)
return AntaCatalogFile(root=root)
def get_tests_by_names(self, names: set[str]) -> list[AntaTestDefinition]:
"""Return all the tests that have matching a list of tests names.
def build_indexes(self, filtered_tests: set[str] | None = None) -> None:
"""Indexes tests by their tags for quick access during filtering operations.
If a `filtered_tests` set is provided, only the tests in this set will be indexed.
This method populates 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.
Once the indexes are built, the `indexes_built` attribute is set to True.
"""
for test in self.tests:
# Skip tests that are not in the specified filtered_tests set
if filtered_tests and test.test.name not in filtered_tests:
continue
# Indexing by tag
if test.inputs.filters and (test_tags := test.inputs.filters.tags):
for tag in test_tags:
self.tag_to_tests[tag].add(test)
else:
self.tests_without_tags.add(test)
self.tag_to_tests[None] = self.tests_without_tags
self.indexes_built = True
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:
----
names: Names of the tests to get.
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
-------
List of AntaTestDefinition that match the names
set[AntaTestDefinition]: A set of tests that match the given tags.
Raises
------
ValueError: If the indexes have not been built prior to method call.
"""
return [test for test in self.tests if test.test.name in names]
if not self.indexes_built:
msg = "Indexes have not been built yet. Call build_indexes() first."
raise ValueError(msg)
if not tags:
return self.tag_to_tests[None]
filtered_sets = [self.tag_to_tests[tag] for tag in tags if tag in self.tag_to_tests]
if not filtered_sets:
return set()
if strict:
return set.intersection(*filtered_sets)
return set.union(*filtered_sets)

View file

@ -5,70 +5,37 @@
from __future__ import annotations
import logging
import pathlib
import sys
from typing import Callable
import click
from anta import __DEBUG__
from anta import GITHUB_SUGGESTION, __version__
from anta.cli.check import check as check_command
from anta.cli.debug import debug as debug_command
from anta.cli.exec import _exec as exec_command
from anta.cli.get import get as get_command
from anta.cli.nrfu import nrfu as nrfu_command
from anta.cli.utils import AliasedGroup, ExitCode
from anta.logger import Log, LogLevel, anta_log_exception, setup_logging
# Note: need to separate this file from _main to be able to fail on the import.
try:
from ._main import anta, cli
logger = logging.getLogger(__name__)
except ImportError as exc:
def build_cli(exception: Exception) -> Callable[[], None]:
"""Build CLI function using the caught exception."""
@click.group(cls=AliasedGroup)
@click.pass_context
@click.version_option(__version__)
@click.option(
"--log-file",
help="Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout.",
show_envvar=True,
type=click.Path(file_okay=True, dir_okay=False, writable=True, path_type=pathlib.Path),
)
@click.option(
"--log-level",
"-l",
help="ANTA logging level",
default=logging.getLevelName(logging.INFO),
show_envvar=True,
show_default=True,
type=click.Choice(
[Log.CRITICAL, Log.ERROR, Log.WARNING, Log.INFO, Log.DEBUG],
case_sensitive=False,
),
)
def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> None:
"""Arista Network Test Automation (ANTA) CLI."""
ctx.ensure_object(dict)
setup_logging(log_level, log_file)
anta.add_command(nrfu_command)
anta.add_command(check_command)
anta.add_command(exec_command)
anta.add_command(get_command)
anta.add_command(debug_command)
def cli() -> None:
"""Entrypoint for pyproject.toml."""
try:
anta(obj={}, auto_envvar_prefix="ANTA")
except Exception as exc: # pylint: disable=broad-exception-caught
anta_log_exception(
exc,
f"Uncaught Exception when running ANTA CLI\n{GITHUB_SUGGESTION}",
logger,
def wrap() -> None:
"""Error message if any CLI dependency is missing."""
print(
"The ANTA command line client could not run because the required "
"dependencies were not installed.\nMake sure you've installed "
"everything with: pip install 'anta[cli]'"
)
sys.exit(ExitCode.INTERNAL_ERROR)
if __DEBUG__:
print(f"The caught exception was: {exception}")
sys.exit(1)
return wrap
cli = build_cli(exc)
__all__ = ["cli", "anta"]
if __name__ == "__main__":
cli()

70
anta/cli/_main.py Normal file
View file

@ -0,0 +1,70 @@
# Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""ANTA CLI."""
from __future__ import annotations
import logging
import pathlib
import sys
import click
from anta import GITHUB_SUGGESTION, __version__
from anta.cli.check import check as check_command
from anta.cli.debug import debug as debug_command
from anta.cli.exec import _exec as exec_command
from anta.cli.get import get as get_command
from anta.cli.nrfu import nrfu as nrfu_command
from anta.cli.utils import AliasedGroup, ExitCode
from anta.logger import Log, LogLevel, anta_log_exception, setup_logging
logger = logging.getLogger(__name__)
@click.group(cls=AliasedGroup)
@click.pass_context
@click.version_option(__version__)
@click.option(
"--log-file",
help="Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout.",
show_envvar=True,
type=click.Path(file_okay=True, dir_okay=False, writable=True, path_type=pathlib.Path),
)
@click.option(
"--log-level",
"-l",
help="ANTA logging level",
default=logging.getLevelName(logging.INFO),
show_envvar=True,
show_default=True,
type=click.Choice(
[Log.CRITICAL, Log.ERROR, Log.WARNING, Log.INFO, Log.DEBUG],
case_sensitive=False,
),
)
def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> None:
"""Arista Network Test Automation (ANTA) CLI."""
ctx.ensure_object(dict)
setup_logging(log_level, log_file)
anta.add_command(nrfu_command)
anta.add_command(check_command)
anta.add_command(exec_command)
anta.add_command(get_command)
anta.add_command(debug_command)
def cli() -> None:
"""Entrypoint for pyproject.toml."""
try:
anta(obj={}, auto_envvar_prefix="ANTA")
except Exception as exc: # pylint: disable=broad-exception-caught
anta_log_exception(
exc,
f"Uncaught Exception when running ANTA CLI\n{GITHUB_SUGGESTION}",
logger,
)
sys.exit(ExitCode.INTERNAL_ERROR)

View file

@ -51,11 +51,8 @@ def debug_options(f: Callable[..., Any]) -> Callable[..., Any]:
# TODO: @gmuloc - tags come from context https://github.com/arista-netdevops-community/anta/issues/584
# pylint: disable=unused-argument
# ruff: noqa: ARG001
try:
d = inventory[device]
except KeyError as e:
message = f"Device {device} does not exist in Inventory"
logger.error(e, message)
if (d := inventory.get(device)) is None:
logger.error("Device '%s' does not exist in Inventory", device)
ctx.exit(ExitCode.USAGE_ERROR)
return f(*args, device=d, **kwargs)

View file

@ -16,7 +16,7 @@ import click
from yaml import safe_load
from anta.cli.console import console
from anta.cli.exec.utils import clear_counters_utils, collect_commands, collect_scheduled_show_tech
from anta.cli.exec import utils
from anta.cli.utils import inventory_options
if TYPE_CHECKING:
@ -29,7 +29,7 @@ logger = logging.getLogger(__name__)
@inventory_options
def clear_counters(inventory: AntaInventory, tags: set[str] | None) -> None:
"""Clear counter statistics on EOS devices."""
asyncio.run(clear_counters_utils(inventory, tags=tags))
asyncio.run(utils.clear_counters(inventory, tags=tags))
@click.command()
@ -62,7 +62,7 @@ def snapshot(inventory: AntaInventory, tags: set[str] | None, commands_list: Pat
except FileNotFoundError:
logger.error("Error reading %s", commands_list)
sys.exit(1)
asyncio.run(collect_commands(inventory, eos_commands, output, tags=tags))
asyncio.run(utils.collect_commands(inventory, eos_commands, output, tags=tags))
@click.command()
@ -98,4 +98,4 @@ def collect_tech_support(
configure: bool,
) -> None:
"""Collect scheduled tech-support from EOS devices."""
asyncio.run(collect_scheduled_show_tech(inventory, output, configure=configure, tags=tags, latest=latest))
asyncio.run(utils.collect_show_tech(inventory, output, configure=configure, tags=tags, latest=latest))

View file

@ -14,12 +14,13 @@ import re
from pathlib import Path
from typing import TYPE_CHECKING, Literal
from aioeapi import EapiCommandError
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 asynceapi import EapiCommandError
if TYPE_CHECKING:
from anta.inventory import AntaInventory
@ -29,7 +30,7 @@ INVALID_CHAR = "`~!@#$/"
logger = logging.getLogger(__name__)
async def clear_counters_utils(anta_inventory: AntaInventory, tags: set[str] | None = None) -> None:
async def clear_counters(anta_inventory: AntaInventory, tags: set[str] | None = None) -> None:
"""Clear counters."""
async def clear(dev: AntaDevice) -> None:
@ -60,7 +61,7 @@ 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(r"(/|\|$)", "_", command)
safe_command = re.sub(rf"{REGEXP_PATH_MARKERS}", "_", command)
c = AntaCommand(command=command, ofmt=outformat)
await dev.collect(c)
if not c.collected:
@ -72,6 +73,9 @@ async def collect_commands(
elif c.ofmt == "text":
outfile = outdir / f"{safe_command}.log"
content = c.text_output
else:
logger.error("Command outformat is not in ['json', 'text'] for command '%s'", command)
return
with outfile.open(mode="w", encoding="UTF-8") as f:
f.write(content)
logger.info("Collected command '%s' from device %s (%s)", command, dev.name, dev.hw_model)
@ -91,7 +95,7 @@ async def collect_commands(
logger.error("Error when collecting commands: %s", str(r))
async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bool, tags: set[str] | None = None, latest: int | None = None) -> None:
async def collect_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bool, tags: set[str] | None = None, latest: int | None = None) -> None:
"""Collect scheduled show-tech on devices."""
async def collect(device: AntaDevice) -> None:
@ -103,12 +107,12 @@ async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, *, con
cmd += f" | head -{latest}"
command = AntaCommand(command=cmd, ofmt="text")
await device.collect(command=command)
if command.collected and command.text_output:
filenames = [Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}") for f in command.text_output.splitlines()]
else:
if not (command.collected and command.text_output):
logger.error("Unable to get tech-support filenames on %s: verify that %s is not empty", device.name, EOS_SCHEDULED_TECH_SUPPORT)
return
filenames = [Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}") for f in command.text_output.splitlines()]
# Create directories
outdir = Path() / root_dir / f"{device.name.lower()}"
outdir.mkdir(parents=True, exist_ok=True)
@ -119,7 +123,10 @@ async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, *, con
if command.collected and not command.text_output:
logger.debug("'aaa authorization exec default local' is not configured on device %s", device.name)
if configure:
if not configure:
logger.error("Unable to collect tech-support on %s: configuration 'aaa authorization exec default local' is not present", device.name)
return
commands = []
# TODO: @mtache - add `config` field to `AntaCommand` object to handle this use case.
# Otherwise mypy complains about enable as it is only implemented for AsyncEOSDevice
@ -141,9 +148,7 @@ async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, *, con
command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text")
await device._session.cli(commands=commands) # pylint: disable=protected-access
logger.info("Configured 'aaa authorization exec default local' on device %s", device.name)
else:
logger.error("Unable to collect tech-support on %s: configuration 'aaa authorization exec default local' is not present", device.name)
return
logger.debug("'aaa authorization exec default local' is already configured on device %s", device.name)
await device.copy(sources=filenames, destination=outdir, direction="from")

View file

@ -76,7 +76,11 @@ def from_cvp(ctx: click.Context, output: Path, host: str, username: str, passwor
required=True,
)
def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_inventory: Path) -> None:
"""Build ANTA inventory from an ansible inventory YAML file."""
"""Build ANTA inventory from an ansible inventory YAML file.
NOTE: This command does not support inline vaulted variables. Make sure to comment them out.
"""
logger.info("Building inventory from ansible file '%s'", ansible_inventory)
try:
create_inventory_from_ansible(

View file

@ -154,6 +154,15 @@ def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group:
try:
with inventory.open(encoding="utf-8") as inv:
ansible_inventory = yaml.safe_load(inv)
except yaml.constructor.ConstructorError as exc:
if exc.problem and "!vault" in exc.problem:
logger.error(
"`anta get from-ansible` does not support inline vaulted variables, comment them out to generate your inventory. "
"If the vaulted variable is necessary to build the inventory (e.g. `ansible_host`), it needs to be unvaulted for "
"`from-ansible` command to work."
)
msg = f"Could not parse {inventory}."
raise ValueError(msg) from exc
except OSError as exc:
msg = f"Could not parse {inventory}."
raise ValueError(msg) from exc

View file

@ -99,6 +99,14 @@ HIDE_STATUS.remove("unset")
help="Group result by test or device.",
required=False,
)
@click.option(
"--dry-run",
help="Run anta nrfu command but stop before starting to execute the tests. Considers all devices as connected.",
type=str,
show_envvar=True,
is_flag=True,
default=False,
)
# pylint: disable=too-many-arguments
def nrfu(
ctx: click.Context,
@ -111,6 +119,7 @@ def nrfu(
*,
ignore_status: bool,
ignore_error: bool,
dry_run: bool,
) -> None:
"""Run ANTA tests on selected inventory devices."""
# If help is invoke somewhere, skip the command
@ -124,7 +133,19 @@ def nrfu(
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))
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
# Invoke `anta nrfu table` if no command is passed
if ctx.invoked_subcommand is None:
ctx.invoke(commands.table)

View file

@ -38,7 +38,7 @@ def print_settings(
catalog: AntaCatalog,
) -> None:
"""Print ANTA settings before running tests."""
message = f"Running ANTA tests:\n- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests"
message = f"- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests"
console.print(Panel.fit(message, style="cyan", title="[green]Settings"))
console.print()

View file

@ -12,7 +12,6 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
import click
from pydantic import ValidationError
from yaml import YAMLError
from anta.catalog import AntaCatalog
@ -254,7 +253,7 @@ def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]:
insecure=insecure,
disable_cache=disable_cache,
)
except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError):
except (TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError):
ctx.exit(ExitCode.USAGE_ERROR)
return f(*args, inventory=i, tags=tags, **kwargs)
@ -292,7 +291,7 @@ def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]:
return f(*args, catalog=None, **kwargs)
try:
c = AntaCatalog.parse(catalog)
except (ValidationError, TypeError, ValueError, YAMLError, OSError):
except (TypeError, ValueError, YAMLError, OSError):
ctx.exit(ExitCode.USAGE_ERROR)
return f(*args, catalog=c, **kwargs)

View file

@ -9,6 +9,31 @@ from typing import Annotated, Literal
from pydantic import Field
from pydantic.functional_validators import AfterValidator, BeforeValidator
# Regular Expression definition
# TODO: make this configurable - with an env var maybe?
REGEXP_EOS_BLACKLIST_CMDS = [r"^reload.*", r"^conf\w*\s*(terminal|session)*", r"^wr\w*\s*\w+"]
"""List of regular expressions to blacklist from eos commands."""
REGEXP_PATH_MARKERS = r"[\\\/\s]"
"""Match directory path from string."""
REGEXP_INTERFACE_ID = r"\d+(\/\d+)*(\.\d+)?"
"""Match Interface ID lilke 1/1.1."""
REGEXP_TYPE_EOS_INTERFACE = r"^(Dps|Ethernet|Fabric|Loopback|Management|Port-Channel|Tunnel|Vlan|Vxlan)[0-9]+(\/[0-9]+)*(\.[0-9]+)?$"
"""Match EOS interface types like Ethernet1/1, Vlan1, Loopback1, etc."""
REGEXP_TYPE_VXLAN_SRC_INTERFACE = r"^(Loopback)([0-9]|[1-9][0-9]{1,2}|[1-7][0-9]{3}|8[01][0-9]{2}|819[01])$"
"""Match Vxlan source interface like Loopback10."""
REGEXP_TYPE_HOSTNAME = r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$"
"""Match hostname like `my-hostname`, `my-hostname-1`, `my-hostname-1-2`."""
# Regexp BGP AFI/SAFI
REGEXP_BGP_L2VPN_AFI = r"\b(l2[\s\-]?vpn[\s\-]?evpn)\b"
"""Match L2VPN EVPN AFI."""
REGEXP_BGP_IPV4_MPLS_LABELS = r"\b(ipv4[\s\-]?mpls[\s\-]?label(s)?)\b"
"""Match IPv4 MPLS Labels."""
REGEX_BGP_IPV4_MPLS_VPN = r"\b(ipv4[\s\-]?mpls[\s\-]?vpn)\b"
"""Match IPv4 MPLS VPN."""
REGEX_BGP_IPV4_UNICAST = r"\b(ipv4[\s\-]?uni[\s\-]?cast)\b"
"""Match IPv4 Unicast."""
def aaa_group_prefix(v: str) -> str:
"""Prefix the AAA method with 'group' if it is known."""
@ -24,7 +49,7 @@ def interface_autocomplete(v: str) -> str:
- `po` will be changed to `Port-Channel`
- `lo` will be changed to `Loopback`
"""
intf_id_re = re.compile(r"[0-9]+(\/[0-9]+)*(\.[0-9]+)?")
intf_id_re = re.compile(REGEXP_INTERFACE_ID)
m = intf_id_re.search(v)
if m is None:
msg = f"Could not parse interface ID in interface '{v}'"
@ -33,11 +58,7 @@ def interface_autocomplete(v: str) -> str:
alias_map = {"et": "Ethernet", "eth": "Ethernet", "po": "Port-Channel", "lo": "Loopback"}
for alias, full_name in alias_map.items():
if v.lower().startswith(alias):
return f"{full_name}{intf_id}"
return v
return next((f"{full_name}{intf_id}" for alias, full_name in alias_map.items() if v.lower().startswith(alias)), v)
def interface_case_sensitivity(v: str) -> str:
@ -50,7 +71,7 @@ def interface_case_sensitivity(v: str) -> str:
- loopback -> Loopback
"""
if isinstance(v, str) and len(v) > 0 and not v[0].isupper():
if isinstance(v, str) and v != "" and not v[0].isupper():
return f"{v[0].upper()}{v[1:]}"
return v
@ -67,10 +88,10 @@ def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str:
"""
patterns = {
r"\b(l2[\s\-]?vpn[\s\-]?evpn)\b": "l2VpnEvpn",
r"\bipv4[\s_-]?mpls[\s_-]?label(s)?\b": "ipv4MplsLabels",
r"\bipv4[\s_-]?mpls[\s_-]?vpn\b": "ipv4MplsVpn",
r"\bipv4[\s_-]?uni[\s_-]?cast\b": "ipv4Unicast",
REGEXP_BGP_L2VPN_AFI: "l2VpnEvpn",
REGEXP_BGP_IPV4_MPLS_LABELS: "ipv4MplsLabels",
REGEX_BGP_IPV4_MPLS_VPN: "ipv4MplsVpn",
REGEX_BGP_IPV4_UNICAST: "ipv4Unicast",
}
for pattern, replacement in patterns.items():
@ -81,6 +102,16 @@ def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str:
return value
def validate_regex(value: str) -> str:
"""Validate that the input value is a valid regex format."""
try:
re.compile(value)
except re.error as e:
msg = f"Invalid regex: {e}"
raise ValueError(msg) from e
return value
# ANTA framework
TestStatus = Literal["unset", "success", "failure", "error", "skipped"]
@ -91,13 +122,19 @@ MlagPriority = Annotated[int, Field(ge=1, le=32767)]
Vni = Annotated[int, Field(ge=1, le=16777215)]
Interface = Annotated[
str,
Field(pattern=r"^(Dps|Ethernet|Fabric|Loopback|Management|Port-Channel|Tunnel|Vlan|Vxlan)[0-9]+(\/[0-9]+)*(\.[0-9]+)?$"),
Field(pattern=REGEXP_TYPE_EOS_INTERFACE),
BeforeValidator(interface_autocomplete),
BeforeValidator(interface_case_sensitivity),
]
EthernetInterface = Annotated[
str,
Field(pattern=r"^Ethernet[0-9]+(\/[0-9]+)*$"),
BeforeValidator(interface_autocomplete),
BeforeValidator(interface_case_sensitivity),
]
VxlanSrcIntf = Annotated[
str,
Field(pattern=r"^(Loopback)([0-9]|[1-9][0-9]{1,2}|[1-7][0-9]{3}|8[01][0-9]{2}|819[01])$"),
Field(pattern=REGEXP_TYPE_VXLAN_SRC_INTERFACE),
BeforeValidator(interface_autocomplete),
BeforeValidator(interface_case_sensitivity),
]
@ -105,7 +142,7 @@ Afi = Literal["ipv4", "ipv6", "vpn-ipv4", "vpn-ipv6", "evpn", "rt-membership", "
Safi = Literal["unicast", "multicast", "labeled-unicast", "sr-te"]
EncryptionAlgorithm = Literal["RSA", "ECDSA"]
RsaKeySize = Literal[2048, 3072, 4096]
EcdsaKeySize = Literal[256, 384, 521]
EcdsaKeySize = Literal[256, 384, 512]
MultiProtocolCaps = Annotated[str, BeforeValidator(bgp_multiprotocol_capabilities_abbreviations)]
BfdInterval = Annotated[int, Field(ge=50, le=60000)]
BfdMultiplier = Annotated[int, Field(ge=3, le=50)]
@ -127,5 +164,6 @@ ErrDisableInterval = Annotated[int, Field(ge=30, le=86400)]
Percent = Annotated[float, Field(ge=0.0, le=100.0)]
PositiveInteger = Annotated[int, Field(ge=0)]
Revision = Annotated[int, Field(ge=1, le=99)]
Hostname = Annotated[str, Field(pattern=r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$")]
Hostname = Annotated[str, Field(pattern=REGEXP_TYPE_HOSTNAME)]
Port = Annotated[int, Field(ge=1, le=65535)]
RegexString = Annotated[str, AfterValidator(validate_regex)]

View file

@ -18,7 +18,8 @@ from aiocache.plugins import HitMissRatioPlugin
from asyncssh import SSHClientConnection, SSHClientConnectionOptions
from httpx import ConnectError, HTTPError, TimeoutException
from anta import __DEBUG__, aioeapi
import asynceapi
from anta import __DEBUG__
from anta.logger import anta_log_exception, exc_to_str
from anta.models import AntaCommand
@ -116,7 +117,7 @@ class AntaDevice(ABC):
yield "disable_cache", self.cache is None
@abstractmethod
async def _collect(self, command: AntaCommand) -> None:
async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None:
"""Collect device command output.
This abstract coroutine can be used to implement any command collection method
@ -131,11 +132,11 @@ class AntaDevice(ABC):
Args:
----
command: the command to collect
command: The command to collect.
collection_id: An identifier used to build the eAPI request ID.
"""
async def collect(self, command: AntaCommand) -> None:
async def collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None:
"""Collect the output for a specified command.
When caching is activated on both the device and the command,
@ -148,8 +149,8 @@ class AntaDevice(ABC):
Args:
----
command (AntaCommand): The command to process.
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
@ -161,20 +162,20 @@ class AntaDevice(ABC):
logger.debug("Cache hit for %s on %s", command.command, self.name)
command.output = cached_output
else:
await self._collect(command=command)
await self._collect(command=command, collection_id=collection_id)
await self.cache.set(command.uid, command.output) # pylint: disable=no-member
else:
await self._collect(command=command)
await self._collect(command=command, collection_id=collection_id)
async def collect_commands(self, commands: list[AntaCommand]) -> None:
async def collect_commands(self, commands: list[AntaCommand], *, collection_id: str | None = None) -> None:
"""Collect multiple commands.
Args:
----
commands: the commands to collect
commands: The commands to collect.
collection_id: An identifier used to build the eAPI request ID.
"""
await asyncio.gather(*(self.collect(command=command) for command in commands))
await asyncio.gather(*(self.collect(command=command, collection_id=collection_id) for command in commands))
@abstractmethod
async def refresh(self) -> None:
@ -270,7 +271,7 @@ class AsyncEOSDevice(AntaDevice):
raise ValueError(message)
self.enable = enable
self._enable_password = enable_password
self._session: aioeapi.Device = aioeapi.Device(host=host, port=port, username=username, password=password, proto=proto, timeout=timeout)
self._session: asynceapi.Device = asynceapi.Device(host=host, port=port, username=username, password=password, proto=proto, timeout=timeout)
ssh_params: dict[str, Any] = {}
if insecure:
ssh_params["known_hosts"] = None
@ -305,7 +306,7 @@ class AsyncEOSDevice(AntaDevice):
"""
return (self._session.host, self._session.port)
async def _collect(self, command: AntaCommand) -> None: # noqa: C901 function is too complex - because of many required except blocks
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
"""Collect device command output from EOS using aio-eapi.
Supports outformat `json` and `text` as output structure.
@ -314,9 +315,10 @@ class AsyncEOSDevice(AntaDevice):
Args:
----
command: the AntaCommand to collect.
command: The command to collect.
collection_id: An identifier used to build the eAPI request ID.
"""
commands: list[dict[str, Any]] = []
commands: list[dict[str, str | int]] = []
if self.enable and self._enable_password is not None:
commands.append(
{
@ -329,14 +331,15 @@ class AsyncEOSDevice(AntaDevice):
commands.append({"cmd": "enable"})
commands += [{"cmd": command.command, "revision": command.revision}] if command.revision else [{"cmd": command.command}]
try:
response: list[dict[str, Any]] = await self._session.cli(
response: list[dict[str, Any] | str] = await self._session.cli(
commands=commands,
ofmt=command.ofmt,
version=command.version,
)
req_id=f"ANTA-{collection_id}-{id(command)}" if collection_id else f"ANTA-{id(command)}",
) # type: ignore[assignment] # multiple commands returns a list
# Do not keep response of 'enable' command
command.output = response[-1]
except aioeapi.EapiCommandError as e:
except asynceapi.EapiCommandError as e:
# This block catches exceptions related to EOS issuing an error.
command.errors = e.errors
if command.requires_privileges:

View file

@ -7,6 +7,7 @@ from __future__ import annotations
import logging
import traceback
from datetime import timedelta
from enum import Enum
from typing import TYPE_CHECKING, Literal
@ -87,6 +88,12 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None:
logger.debug("ANTA Debug Mode enabled")
def format_td(seconds: float, digits: int = 3) -> str:
"""Return a formatted string from a float number representing seconds and a number of digits."""
isec, fsec = divmod(round(seconds * 10**digits), 10**digits)
return f"{timedelta(seconds=isec)}.{fsec:0{digits}.0f}"
def exc_to_str(exception: BaseException) -> str:
"""Return a human readable string from an BaseException object."""
return f"{type(exception).__name__}{f': {exception}' if str(exception) else ''}"

View file

@ -8,10 +8,7 @@ from __future__ import annotations
import hashlib
import logging
import re
import time
from abc import ABC, abstractmethod
from copy import deepcopy
from datetime import timedelta
from functools import wraps
from string import Formatter
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, TypeVar
@ -19,7 +16,7 @@ from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, TypeVar
from pydantic import BaseModel, ConfigDict, ValidationError, create_model
from anta import GITHUB_SUGGESTION
from anta.custom_types import Revision
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
@ -35,9 +32,6 @@ F = TypeVar("F", bound=Callable[..., Any])
# This would imply overhead to define classes
# https://stackoverflow.com/questions/74103528/type-hinting-an-instance-of-a-nested-class
# TODO: make this configurable - with an env var maybe?
BLACKLIST_REGEX = [r"^reload.*", r"^conf\w*\s*(terminal|session)*", r"^wr\w*\s*\w+"]
logger = logging.getLogger(__name__)
@ -46,19 +40,8 @@ class AntaParamsBaseModel(BaseModel):
model_config = ConfigDict(extra="forbid")
if not TYPE_CHECKING:
# Following pydantic declaration and keeping __getattr__ only when TYPE_CHECKING is false.
# Disabling 1 Dynamically typed expressions (typing.Any) are disallowed in `__getattr__
# ruff: noqa: ANN401
def __getattr__(self, item: str) -> Any:
"""For AntaParams if we try to access an attribute that is not present We want it to be None."""
try:
return super().__getattr__(item)
except AttributeError:
return None
class AntaTemplate(BaseModel):
class AntaTemplate:
"""Class to define a command template as Python f-string.
Can render a command from parameters.
@ -70,14 +53,42 @@ class AntaTemplate(BaseModel):
revision: Revision of the command. Valid values are 1 to 99. Revision has precedence over version.
ofmt: eAPI output - json or text.
use_cache: Enable or disable caching for this AntaTemplate if the AntaDevice supports it.
"""
template: str
version: Literal[1, "latest"] = "latest"
revision: Revision | None = None
ofmt: Literal["json", "text"] = "json"
use_cache: bool = True
# pylint: disable=too-few-public-methods
def __init__( # noqa: PLR0913
self,
template: str,
version: Literal[1, "latest"] = "latest",
revision: Revision | None = None,
ofmt: Literal["json", "text"] = "json",
*,
use_cache: bool = True,
) -> None:
# pylint: disable=too-many-arguments
self.template = template
self.version = version
self.revision = revision
self.ofmt = ofmt
self.use_cache = use_cache
# Create a AntaTemplateParams model to elegantly store AntaTemplate variables
field_names = [fname for _, fname, _, _ in Formatter().parse(self.template) if fname]
# Extracting the type from the params based on the expected field_names from the template
fields: dict[str, Any] = {key: (Any, ...) for key in field_names}
self.params_schema = create_model(
"AntaParams",
__base__=AntaParamsBaseModel,
**fields,
)
def __repr__(self) -> str:
"""Return the representation of the class.
Copying pydantic model style, excluding `params_schema`
"""
return " ".join(f"{a}={v!r}" for a, v in vars(self).items() if a != "params_schema")
def render(self, **params: str | int | bool) -> AntaCommand:
"""Render an AntaCommand from an AntaTemplate instance.
@ -90,34 +101,28 @@ class AntaTemplate(BaseModel):
Returns
-------
command: The rendered 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.
"""
# Create params schema on the fly
field_names = [fname for _, fname, _, _ in Formatter().parse(self.template) if fname]
# Extracting the type from the params based on the expected field_names from the template
fields: dict[str, Any] = {key: (type(params.get(key)), ...) for key in field_names}
# Accepting ParamsSchema as non lowercase variable
ParamsSchema = create_model( # noqa: N806
"ParamsSchema",
__base__=AntaParamsBaseModel,
**fields,
)
try:
command = self.template.format(**params)
except (KeyError, SyntaxError) as e:
raise AntaTemplateRenderError(self, e.args[0]) from e
return AntaCommand(
command=self.template.format(**params),
command=command,
ofmt=self.ofmt,
version=self.version,
revision=self.revision,
template=self,
params=ParamsSchema(**params),
params=self.params_schema(**params),
use_cache=self.use_cache,
)
except KeyError as e:
raise AntaTemplateRenderError(self, e.args[0]) from e
class AntaCommand(BaseModel):
@ -148,6 +153,8 @@ class AntaCommand(BaseModel):
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
command: str
version: Literal[1, "latest"] = "latest"
revision: Revision | None = None
@ -273,14 +280,13 @@ class AntaTest(ABC):
vrf: str = "default"
def render(self, template: AntaTemplate) -> list[AntaCommand]:
return [template.render({"dst": host.dst, "src": host.src, "vrf": host.vrf}) for host in self.inputs.hosts]
return [template.render(dst=host.dst, src=host.src, vrf=host.vrf) for host in self.inputs.hosts]
@AntaTest.anta_test
def test(self) -> None:
failures = []
for command in self.instance_commands:
if command.params and ("src" and "dst") in command.params:
src, dst = command.params["src"], command.params["dst"]
src, dst = command.params.src, command.params.dst
if "2 received" not in command.json_output["messages"][0]:
failures.append((str(src), str(dst)))
if not failures:
@ -288,13 +294,14 @@ class AntaTest(ABC):
else:
self.result.is_failure(f"Connectivity test failed for the following source-destination pairs: {failures}")
```
Attributes:
Attributes
----------
device: AntaDevice instance on which this test is run
inputs: AntaTest.Input instance carrying the test inputs
instance_commands: List of AntaCommand instances of this test
result: TestResult instance representing the result of this test
logger: Python logger for this test instance
"""
# Mandatory class attributes
@ -322,9 +329,10 @@ class AntaTest(ABC):
description: "Test with overwritten description"
custom_field: "Test run by John Doe"
```
Attributes:
result_overwrite: Define fields to overwrite in the TestResult object
Attributes
----------
result_overwrite: Define fields to overwrite in the TestResult object
"""
model_config = ConfigDict(extra="forbid")
@ -360,7 +368,6 @@ class AntaTest(ABC):
Attributes
----------
tags: Tag of devices on which to run the test.
"""
model_config = ConfigDict(extra="forbid")
@ -380,9 +387,8 @@ class AntaTest(ABC):
inputs: dictionary of attributes used to instantiate the AntaTest.Input instance
eos_data: Populate outputs of the test commands instead of collecting from devices.
This list must have the same length and order than the `instance_commands` instance attribute.
"""
self.logger: logging.Logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}")
self.logger: logging.Logger = logging.getLogger(f"{self.module}.{self.__class__.__name__}")
self.device: AntaDevice = device
self.inputs: AntaTest.Input
self.instance_commands: list[AntaCommand] = []
@ -411,7 +417,7 @@ class AntaTest(ABC):
elif isinstance(inputs, dict):
self.inputs = self.Input(**inputs)
except ValidationError as e:
message = f"{self.__module__}.{self.__class__.__name__}: Inputs are not valid\n{e}"
message = f"{self.module}.{self.name}: Inputs are not valid\n{e}"
self.logger.error(message)
self.result.is_error(message=message)
return
@ -434,7 +440,7 @@ class AntaTest(ABC):
if self.__class__.commands:
for cmd in self.__class__.commands:
if isinstance(cmd, AntaCommand):
self.instance_commands.append(deepcopy(cmd))
self.instance_commands.append(cmd.model_copy())
elif isinstance(cmd, AntaTemplate):
try:
self.instance_commands.extend(self.render(cmd))
@ -448,7 +454,7 @@ class AntaTest(ABC):
# render() is user-defined code.
# We need to catch everything if we want the AntaTest object
# to live until the reporting
message = f"Exception in {self.__module__}.{self.__class__.__name__}.render()"
message = f"Exception in {self.module}.{self.__class__.__name__}.render()"
anta_log_exception(e, message, self.logger)
self.result.is_error(message=f"{message}: {exc_to_str(e)}")
return
@ -476,14 +482,19 @@ class AntaTest(ABC):
msg = f"Class {cls.__module__}.{cls.__name__} is missing required class attribute {attr}"
raise NotImplementedError(msg)
@property
def module(self) -> str:
"""Return the Python module in which this AntaTest class is defined."""
return self.__module__
@property
def collected(self) -> bool:
"""Returns True if all commands for this test have been collected."""
"""Return True if all commands for this test have been collected."""
return all(command.collected for command in self.instance_commands)
@property
def failed_commands(self) -> list[AntaCommand]:
"""Returns a list of all the commands that have failed."""
"""Return a list of all the commands that have failed."""
return [command for command in self.instance_commands if command.error]
def render(self, template: AntaTemplate) -> list[AntaCommand]:
@ -493,7 +504,7 @@ class AntaTest(ABC):
no AntaTemplate for this test.
"""
_ = template
msg = f"AntaTemplate are provided but render() method has not been implemented for {self.__module__}.{self.name}"
msg = f"AntaTemplate are provided but render() method has not been implemented for {self.module}.{self.__class__.__name__}"
raise NotImplementedError(msg)
@property
@ -501,12 +512,12 @@ class AntaTest(ABC):
"""Check if CLI commands contain a blocked keyword."""
state = False
for command in self.instance_commands:
for pattern in BLACKLIST_REGEX:
for pattern in REGEXP_EOS_BLACKLIST_CMDS:
if re.match(pattern, command.command):
self.logger.error(
"Command <%s> is blocked for security reason matching %s",
command.command,
BLACKLIST_REGEX,
REGEXP_EOS_BLACKLIST_CMDS,
)
self.result.is_error(f"<{command.command}> is blocked for security reason")
state = True
@ -516,7 +527,7 @@ class AntaTest(ABC):
"""Collect outputs of all commands of this test class from the device of this test instance."""
try:
if self.blocked is False:
await self.device.collect_commands(self.instance_commands)
await self.device.collect_commands(self.instance_commands, collection_id=self.name)
except Exception as e: # pylint: disable=broad-exception-caught
# device._collect() is user-defined code.
# We need to catch everything if we want the AntaTest object
@ -557,12 +568,6 @@ class AntaTest(ABC):
result: TestResult instance attribute populated with error status if any
"""
def format_td(seconds: float, digits: int = 3) -> str:
isec, fsec = divmod(round(seconds * 10**digits), 10**digits)
return f"{timedelta(seconds=isec)}.{fsec:0{digits}.0f}"
start_time = time.time()
if self.result.result != "unset":
return self.result
@ -575,6 +580,7 @@ class AntaTest(ABC):
if not self.collected:
await self.collect()
if self.result.result != "unset":
AntaTest.update_progress()
return self.result
if cmds := self.failed_commands:
@ -583,8 +589,9 @@ class AntaTest(ABC):
msg = f"Test {self.name} has been skipped because it is not supported on {self.device.hw_model}: {GITHUB_SUGGESTION}"
self.logger.warning(msg)
self.result.is_skipped("\n".join(unsupported_commands))
return self.result
else:
self.result.is_error(message="\n".join([f"{c.command} has failed: {', '.join(c.errors)}" for c in cmds]))
AntaTest.update_progress()
return self.result
try:
@ -597,10 +604,7 @@ class AntaTest(ABC):
anta_log_exception(e, message, self.logger)
self.result.is_error(message=exc_to_str(e))
test_duration = time.time() - start_time
msg = f"Executing test {self.name} on device {self.device.name} took {format_td(test_duration)}"
self.logger.debug(msg)
# TODO: find a correct way to time test execution
AntaTest.update_progress()
return self.result

View file

@ -215,12 +215,12 @@ class ReportJinja:
def __init__(self, template_path: pathlib.Path) -> None:
"""Create a ReportJinja instance."""
if template_path.is_file():
self.tempalte_path = template_path
else:
if not template_path.is_file():
msg = f"template file is not found: {template_path}"
raise FileNotFoundError(msg)
self.template_path = template_path
def render(self, data: list[dict[str, Any]], *, trim_blocks: bool = True, lstrip_blocks: bool = True) -> str:
"""Build a report based on a Jinja2 template.
@ -250,7 +250,7 @@ class ReportJinja:
Rendered template
"""
with self.tempalte_path.open(encoding="utf-8") as file_:
with self.template_path.open(encoding="utf-8") as file_:
template = Template(file_.read(), trim_blocks=trim_blocks, lstrip_blocks=lstrip_blocks)
return template.render({"data": data})

View file

@ -48,19 +48,25 @@ class ResultManager:
manager.results
[
TestResult(
host=IPv4Address('192.168.0.10'),
test='VerifyNTP',
result='failure',
message="device is not running NTP correctly"
name="pf1",
test="VerifyZeroTouch",
categories=["configuration"],
description="Verifies ZeroTouch is disabled",
result="success",
messages=[],
custom_field=None,
),
TestResult(
host=IPv4Address('192.168.0.10'),
test='VerifyEOSVersion',
result='success',
message=None
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:

View file

@ -1,7 +1,6 @@
# Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
# pylint: disable=too-many-branches
"""ANTA runner function."""
from __future__ import annotations
@ -10,31 +9,51 @@ import asyncio
import logging
import os
import resource
from typing import TYPE_CHECKING
from collections import defaultdict
from typing import TYPE_CHECKING, Any
from anta import GITHUB_SUGGESTION
from anta.catalog import AntaCatalog, AntaTestDefinition
from anta.device import AntaDevice
from anta.logger import anta_log_exception, exc_to_str
from anta.models import AntaTest
from anta.tools import Catchtime, cprofile
if TYPE_CHECKING:
from collections.abc import Coroutine
from anta.catalog import AntaCatalog, AntaTestDefinition
from anta.device import AntaDevice
from anta.inventory import AntaInventory
from anta.result_manager import ResultManager
from anta.result_manager.models import TestResult
logger = logging.getLogger(__name__)
AntaTestRunner = tuple[AntaTestDefinition, AntaDevice]
# Environment variable to set ANTA's maximum number of open file descriptors.
# Maximum number of file descriptor the ANTA process will be able to open.
# This limit is independent from the system's hard limit, the lower will be used.
DEFAULT_NOFILE = 16384
try:
__NOFILE__ = int(os.environ.get("ANTA_NOFILE", DEFAULT_NOFILE))
except ValueError as exception:
def adjust_rlimit_nofile() -> tuple[int, int]:
"""Adjust the maximum number of open file descriptors for the ANTA process.
The limit is set to the lower of the current hard limit and the value of the ANTA_NOFILE environment variable.
If the `ANTA_NOFILE` environment variable is not set or is invalid, `DEFAULT_NOFILE` is used.
Returns
-------
tuple[int, int]: The new soft and hard limits for open file descriptors.
"""
try:
nofile = int(os.environ.get("ANTA_NOFILE", DEFAULT_NOFILE))
except ValueError as exception:
logger.warning("The ANTA_NOFILE environment variable value is invalid: %s\nDefault to %s.", exc_to_str(exception), DEFAULT_NOFILE)
__NOFILE__ = DEFAULT_NOFILE
nofile = DEFAULT_NOFILE
limits = resource.getrlimit(resource.RLIMIT_NOFILE)
logger.debug("Initial limit numbers for open file descriptors for the current ANTA process: Soft Limit: %s | Hard Limit: %s", limits[0], limits[1])
nofile = nofile if limits[1] > nofile else limits[1]
logger.debug("Setting soft limit for open file descriptors for the current ANTA process to %s", nofile)
resource.setrlimit(resource.RLIMIT_NOFILE, (nofile, limits[1]))
return resource.getrlimit(resource.RLIMIT_NOFILE)
def log_cache_statistics(devices: list[AntaDevice]) -> None:
@ -56,7 +75,120 @@ def log_cache_statistics(devices: list[AntaDevice]) -> None:
logger.info("Caching is not enabled on %s", device.name)
async def main( # noqa: PLR0912 PLR0913 too-many-branches too-many-arguments - keep the main method readable
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.
Returns
-------
AntaInventory | None: The filtered inventory or None if there are no devices to run tests on.
"""
if len(inventory) == 0:
logger.info("The inventory is empty, exiting")
return None
# Filter the inventory based on the CLI provided tags and devices if any
selected_inventory = inventory.get_inventory(tags=tags, devices=devices) if tags or devices else inventory
with Catchtime(logger=logger, message="Connecting to devices"):
# Connect to the devices
await selected_inventory.connect_inventory()
# Remove devices that are unreachable
selected_inventory = selected_inventory.get_inventory(established_only=established_only)
# If there are no devices in the inventory after filtering, exit
if not selected_inventory.devices:
msg = f'No reachable device {f"matching the tags {tags} " if tags else ""}was found.{f" Selected devices: {devices} " if devices is not None else ""}'
logger.warning(msg)
return None
return selected_inventory
def prepare_tests(
inventory: AntaInventory, catalog: AntaCatalog, tests: set[str] | None, tags: set[str] | None
) -> defaultdict[AntaDevice, set[AntaTestDefinition]] | None:
"""Prepare the tests to run.
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.
Returns
-------
A mapping of devices to the tests to run or None if there are no tests to run.
"""
# Build indexes for the catalog. If `tests` is set, filter the indexes based on these tests
catalog.build_indexes(filtered_tests=tests)
# Using a set to avoid inserting duplicate tests
device_to_tests: defaultdict[AntaDevice, set[AntaTestDefinition]] = defaultdict(set)
# Create AntaTestRunner tuples 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))
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))
catalog.final_tests_count += len(device_to_tests[device])
if catalog.final_tests_count == 0:
msg = (
f"There are no tests{f' matching the tags {tags} ' if tags else ' '}to run in the current test catalog and device inventory, please verify your inputs."
)
logger.warning(msg)
return None
return device_to_tests
def get_coroutines(selected_tests: defaultdict[AntaDevice, set[AntaTestDefinition]]) -> 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.
Returns
-------
The list of coroutines to run.
"""
coros = []
for device, test_definitions in selected_tests.items():
for test in test_definitions:
try:
test_instance = test.test(device=device, inputs=test.inputs)
coros.append(test_instance.test())
except Exception as e: # noqa: PERF203, pylint: disable=broad-exception-caught
# An AntaTest instance is potentially user-defined code.
# We need to catch everything and exit gracefully with an error message.
message = "\n".join(
[
f"There is an error when creating test {test.test.module}.{test.test.__name__}.",
f"If this is not a custom test implementation: {GITHUB_SUGGESTION}",
],
)
anta_log_exception(e, message, logger)
return coros
@cprofile()
async def main( # noqa: PLR0913
manager: ResultManager,
inventory: AntaInventory,
catalog: AntaCatalog,
@ -65,6 +197,7 @@ async def main( # noqa: PLR0912 PLR0913 too-many-branches too-many-arguments -
tags: set[str] | None = None,
*,
established_only: bool = True,
dry_run: bool = False,
) -> None:
# pylint: disable=too-many-arguments
"""Run ANTA.
@ -77,103 +210,61 @@ async def main( # noqa: PLR0912 PLR0913 too-many-branches too-many-arguments -
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.
tests: tests to run against devices. None means all tests.
tags: Tags to filter devices from the inventory.
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.
"""
limits = resource.getrlimit(resource.RLIMIT_NOFILE)
logger.debug("Initial limit numbers for open file descriptors for the current ANTA process: Soft Limit: %s | Hard Limit: %s", limits[0], limits[1])
nofile = __NOFILE__ if limits[1] > __NOFILE__ else limits[1]
logger.debug("Setting soft limit for open file descriptors for the current ANTA process to %s", nofile)
resource.setrlimit(resource.RLIMIT_NOFILE, (nofile, limits[1]))
limits = resource.getrlimit(resource.RLIMIT_NOFILE)
# Adjust the maximum number of open file descriptors for the ANTA process
limits = adjust_rlimit_nofile()
if not catalog.tests:
logger.info("The list of tests is empty, exiting")
return
if len(inventory) == 0:
logger.info("The inventory is empty, exiting")
with Catchtime(logger=logger, message="Preparing ANTA NRFU Run"):
# Setup the inventory
selected_inventory = inventory if dry_run else await setup_inventory(inventory, tags, devices, established_only=established_only)
if selected_inventory is None:
return
# Filter the inventory based on tags and devices parameters
selected_inventory = inventory.get_inventory(
tags=tags,
devices=devices,
)
await selected_inventory.connect_inventory()
# Remove devices that are unreachable
inventory = selected_inventory.get_inventory(established_only=established_only)
if not inventory.devices:
msg = f'No reachable device {f"matching the tags {tags} " if tags else ""}was found.{f" Selected devices: {devices} " if devices is not None else ""}'
logger.warning(msg)
return
coros = []
# Select the tests from the catalog
if tests:
catalog = AntaCatalog(catalog.get_tests_by_names(tests))
# Using a set to avoid inserting duplicate tests
selected_tests: set[AntaTestRunner] = set()
# Create AntaTestRunner tuples from the tags
for device in inventory.devices:
if tags:
# If there are CLI tags, only execute tests with matching tags
selected_tests.update((test, device) for test in catalog.get_tests_by_tags(tags))
else:
# If there is no CLI tags, execute all tests that do not have any filters
selected_tests.update((t, device) for t in catalog.tests if t.inputs.filters is None or t.inputs.filters.tags is None)
# Then add the tests with matching tags from device tags
selected_tests.update((t, device) for t in catalog.get_tests_by_tags(device.tags))
if not selected_tests:
msg = f"There is no tests{f' matching the tags {tags} ' if tags else ' '}to run in the current test catalog and device inventory, please verify your inputs."
logger.warning(msg)
with Catchtime(logger=logger, message="Preparing the tests"):
selected_tests = prepare_tests(selected_inventory, catalog, tests, tags)
if selected_tests is None:
return
run_info = (
"--- ANTA NRFU Run Information ---\n"
f"Number of devices: {len(selected_inventory)} ({len(inventory)} established)\n"
f"Total number of selected tests: {len(selected_tests)}\n"
f"Number of devices: {len(inventory)} ({len(selected_inventory)} established)\n"
f"Total number of selected tests: {catalog.final_tests_count}\n"
f"Maximum number of open file descriptors for the current ANTA process: {limits[0]}\n"
"---------------------------------"
)
logger.info(run_info)
if len(selected_tests) > limits[0]:
if catalog.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."
)
for test_definition, device in selected_tests:
try:
test_instance = test_definition.test(device=device, inputs=test_definition.inputs)
coroutines = get_coroutines(selected_tests)
coros.append(test_instance.test())
except Exception as e: # pylint: disable=broad-exception-caught
# An AntaTest instance is potentially user-defined code.
# We need to catch everything and exit gracefully with an
# error message
message = "\n".join(
[
f"There is an error when creating test {test_definition.test.__module__}.{test_definition.test.__name__}.",
f"If this is not a custom test implementation: {GITHUB_SUGGESTION}",
],
)
anta_log_exception(e, message, logger)
if dry_run:
logger.info("Dry-run mode, exiting before running the tests.")
for coro in coroutines:
coro.close()
return
if AntaTest.progress is not None:
AntaTest.nrfu_task = AntaTest.progress.add_task("Running NRFU Tests...", total=len(coros))
AntaTest.nrfu_task = AntaTest.progress.add_task("Running NRFU Tests...", total=len(coroutines))
logger.info("Running ANTA tests...")
test_results = await asyncio.gather(*coros)
with Catchtime(logger=logger, message="Running ANTA tests"):
test_results = await asyncio.gather(*coroutines)
for r in test_results:
manager.add(r)
log_cache_statistics(inventory.devices)
log_cache_statistics(selected_inventory.devices)

234
anta/tests/avt.py Normal file
View file

@ -0,0 +1,234 @@
# Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to Adaptive virtual topology tests."""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
from __future__ import annotations
from ipaddress import IPv4Address
from typing import ClassVar
from pydantic import BaseModel
from anta.decorators import skip_on_platforms
from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_value
class VerifyAVTPathHealth(AntaTest):
"""
Verifies the status of all Adaptive Virtual Topology (AVT) paths for all VRFs.
Expected Results
----------------
* Success: The test will pass if all AVT paths for all VRFs are active and valid.
* Failure: The test will fail if the AVT path is not configured or if any AVT path under any VRF is either inactive or invalid.
Examples
--------
```yaml
anta.tests.avt:
- VerifyAVTPathHealth:
```
"""
name = "VerifyAVTPathHealth"
description = "Verifies the status of all AVT paths for all VRFs."
categories: ClassVar[list[str]] = ["avt"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show adaptive-virtual-topology path")]
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyAVTPathHealth."""
# Initialize the test result as success
self.result.is_success()
# Get the command output
command_output = self.instance_commands[0].json_output.get("vrfs", {})
# Check if AVT is configured
if not command_output:
self.result.is_failure("Adaptive virtual topology paths are not configured.")
return
# Iterate over each VRF
for vrf, vrf_data in command_output.items():
# Iterate over each AVT path
for profile, avt_path in vrf_data.get("avts", {}).items():
for path, flags in avt_path.get("avtPaths", {}).items():
# Get the status of the AVT path
valid = flags["flags"]["valid"]
active = flags["flags"]["active"]
# Check the status of the AVT path
if not valid and not active:
self.result.is_failure(f"AVT path {path} for profile {profile} in VRF {vrf} is invalid and not active.")
elif not valid:
self.result.is_failure(f"AVT path {path} for profile {profile} in VRF {vrf} is invalid.")
elif not active:
self.result.is_failure(f"AVT path {path} for profile {profile} in VRF {vrf} is not active.")
class VerifyAVTSpecificPath(AntaTest):
"""
Verifies the status and type of an Adaptive Virtual Topology (AVT) path for a specified VRF.
Expected Results
----------------
* Success: The test will pass if all AVT paths for the specified VRF are active, valid, and match the specified type (direct/multihop) if provided.
If multiple paths are configured, the test will pass only if all the paths are valid and active.
* Failure: The test will fail if no AVT paths are configured for the specified VRF, or if any configured path is not active, valid,
or does not match the specified type.
Examples
--------
```yaml
anta.tests.avt:
- VerifyAVTSpecificPath:
avt_paths:
- avt_name: CONTROL-PLANE-PROFILE
vrf: default
destination: 10.101.255.2
next_hop: 10.101.255.1
path_type: direct
```
"""
name = "VerifyAVTSpecificPath"
description = "Verifies the status and type of an AVT path for a specified VRF."
categories: ClassVar[list[str]] = ["avt"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaTemplate(template="show adaptive-virtual-topology path vrf {vrf} avt {avt_name} destination {destination}")
]
class Input(AntaTest.Input):
"""Input model for the VerifyAVTSpecificPath test."""
avt_paths: list[AVTPaths]
"""List of AVT paths to verify."""
class AVTPaths(BaseModel):
"""Model for the details of AVT paths."""
vrf: str = "default"
"""The VRF for the AVT path. Defaults to 'default' if not provided."""
avt_name: str
"""Name of the adaptive virtual topology."""
destination: IPv4Address
"""The IPv4 address of the AVT peer."""
next_hop: IPv4Address
"""The IPv4 address of the next hop for the AVT peer."""
path_type: str | None = None
"""The type of the AVT path. If not provided, both 'direct' and 'multihop' paths are considered."""
def render(self, template: AntaTemplate) -> list[AntaCommand]:
"""Render the template for each input AVT path/peer."""
return [template.render(vrf=path.vrf, avt_name=path.avt_name, destination=path.destination) for path in self.inputs.avt_paths]
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyAVTSpecificPath."""
# Assume the test is successful until a failure is detected
self.result.is_success()
# Process each command in the instance
for command, input_avt in zip(self.instance_commands, self.inputs.avt_paths):
# Extract the command output and parameters
vrf = command.params.vrf
avt_name = command.params.avt_name
peer = str(command.params.destination)
command_output = command.json_output.get("vrfs", {})
# If no AVT is configured, mark the test as failed and skip to the next command
if not command_output:
self.result.is_failure(f"AVT configuration for peer '{peer}' under topology '{avt_name}' in VRF '{vrf}' is not found.")
continue
# Extract the AVT paths
avt_paths = get_value(command_output, f"{vrf}.avts.{avt_name}.avtPaths")
next_hop, input_path_type = str(input_avt.next_hop), input_avt.path_type
nexthop_path_found = path_type_found = False
# Check each AVT path
for path, path_data in avt_paths.items():
# If the path does not match the expected next hop, skip to the next path
if path_data.get("nexthopAddr") != next_hop:
continue
nexthop_path_found = True
path_type = "direct" if get_value(path_data, "flags.directPath") else "multihop"
# If the path type does not match the expected path type, skip to the next path
if input_path_type and path_type != input_path_type:
continue
path_type_found = True
valid = get_value(path_data, "flags.valid")
active = get_value(path_data, "flags.active")
# Check the path status and type against the expected values
if not all([valid, active]):
failure_reasons = []
if not get_value(path_data, "flags.active"):
failure_reasons.append("inactive")
if not get_value(path_data, "flags.valid"):
failure_reasons.append("invalid")
# Construct the failure message prefix
failed_log = f"AVT path '{path}' for topology '{avt_name}' in VRF '{vrf}'"
self.result.is_failure(f"{failed_log} is {', '.join(failure_reasons)}.")
# If no matching next hop or path type was found, mark the test as failed
if not nexthop_path_found or not path_type_found:
self.result.is_failure(
f"No '{input_path_type}' path found with next-hop address '{next_hop}' for AVT peer '{peer}' under topology '{avt_name}' in VRF '{vrf}'."
)
class VerifyAVTRole(AntaTest):
"""
Verifies the Adaptive Virtual Topology (AVT) role of a device.
Expected Results
----------------
* Success: The test will pass if the AVT role of the device matches the expected role.
* Failure: The test will fail if the AVT is not configured or if the AVT role does not match the expected role.
Examples
--------
```yaml
anta.tests.avt:
- VerifyAVTRole:
role: edge
```
"""
name = "VerifyAVTRole"
description = "Verifies the AVT role of a device."
categories: ClassVar[list[str]] = ["avt"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show adaptive-virtual-topology path")]
class Input(AntaTest.Input):
"""Input model for the VerifyAVTRole test."""
role: str
"""Expected AVT role of the device."""
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyAVTRole."""
# Initialize the test result as success
self.result.is_success()
# Get the command output
command_output = self.instance_commands[0].json_output
# Check if the AVT role matches the expected role
if self.inputs.role != command_output.get("role"):
self.result.is_failure(f"Expected AVT role as `{self.inputs.role}`, but found `{command_output.get('role')}` instead.")

View file

@ -7,8 +7,10 @@
# mypy: disable-error-code=attr-defined
from __future__ import annotations
import re
from typing import TYPE_CHECKING, ClassVar
from anta.custom_types import RegexString
from anta.models import AntaCommand, AntaTest
if TYPE_CHECKING:
@ -75,3 +77,57 @@ class VerifyRunningConfigDiffs(AntaTest):
self.result.is_success()
else:
self.result.is_failure(command_output)
class VerifyRunningConfigLines(AntaTest):
"""Verifies the given regular expression patterns are present in the running-config.
!!! warning
Since this uses regular expression searches on the whole running-config, it can
drastically impact performance and should only be used if no other test is available.
If possible, try using another ANTA test that is more specific.
Expected Results
----------------
* Success: The test will pass if all the patterns are found in the running-config.
* Failure: The test will fail if any of the patterns are NOT found in the running-config.
Examples
--------
```yaml
anta.tests.configuration:
- VerifyRunningConfigLines:
regex_patterns:
- "^enable password.*$"
- "bla bla"
```
"""
name = "VerifyRunningConfigLines"
description = "Search the Running-Config for the given RegEx patterns."
categories: ClassVar[list[str]] = ["configuration"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config", ofmt="text")]
class Input(AntaTest.Input):
"""Input model for the VerifyRunningConfigLines test."""
regex_patterns: list[RegexString]
"""List of regular expressions."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyRunningConfigLines."""
failure_msgs = []
command_output = self.instance_commands[0].text_output
for pattern in self.inputs.regex_patterns:
re_search = re.compile(pattern, flags=re.MULTILINE)
if not re_search.search(command_output):
failure_msgs.append(f"'{pattern}'")
if not failure_msgs:
self.result.is_success()
else:
self.result.is_failure("Following patterns were not found: " + ",".join(failure_msgs))

View file

@ -103,6 +103,11 @@ class VerifyFieldNotice44Resolution(AntaTest):
for component in command_output["details"]["components"]:
if component["name"] == "Aboot":
aboot_version = component["version"].split("-")[2]
break
else:
self.result.is_failure("Aboot component not found")
return
self.result.is_success()
incorrect_aboot_version = (
aboot_version.startswith("4.0.")
@ -192,4 +197,3 @@ class VerifyFieldNotice72Resolution(AntaTest):
return
# We should never hit this point
self.result.is_error("Error in running test - FixedSystemvrm1 not found")
return

View file

@ -14,10 +14,13 @@ from typing import Any, ClassVar, Literal
from pydantic import BaseModel, Field
from pydantic_extra_types.mac_address import MacAddress
from anta.custom_types import Interface, Percent, PositiveInteger
from anta import GITHUB_SUGGESTION
from anta.custom_types import EthernetInterface, Interface, Percent, PositiveInteger
from anta.decorators import skip_on_platforms
from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_item, get_value
from anta.tools import custom_division, get_failed_logs, get_item, get_value
BPS_GBPS_CONVERSIONS = 1000000000
class VerifyInterfaceUtilization(AntaTest):
@ -427,7 +430,7 @@ class VerifyLoopbackCount(AntaTest):
self.result.is_failure()
if loopback_count != self.inputs.number:
self.result.is_failure(f"Found {loopback_count} Loopbacks when expecting {self.inputs.number}")
elif len(down_loopback_interfaces) != 0:
elif len(down_loopback_interfaces) != 0: # pragma: no branch
self.result.is_failure(f"The following Loopbacks are not up: {down_loopback_interfaces}")
@ -700,6 +703,11 @@ class VerifyInterfaceIPv4(AntaTest):
for interface in self.inputs.interfaces:
if interface.name == intf:
input_interface_detail = interface
break
else:
self.result.is_error(f"Could not find `{intf}` in the input interfaces. {GITHUB_SUGGESTION}")
continue
input_primary_ip = str(input_interface_detail.primary_ip)
failed_messages = []
@ -778,3 +786,100 @@ class VerifyIpVirtualRouterMac(AntaTest):
self.result.is_failure(f"IP virtual router MAC address `{self.inputs.mac_address}` is not configured.")
else:
self.result.is_success()
class VerifyInterfacesSpeed(AntaTest):
"""Verifies the speed, lanes, auto-negotiation status, and mode as full duplex for interfaces.
- If the auto-negotiation status is set to True, verifies that auto-negotiation is successful, the mode is full duplex and the speed/lanes match the input.
- If the auto-negotiation status is set to False, verifies that the mode is full duplex and the speed/lanes match the input.
Expected Results
----------------
* Success: The test will pass if an interface is configured correctly with the specified speed, lanes, auto-negotiation status, and mode as full duplex.
* Failure: The test will fail if an interface is not found, if the speed, lanes, and auto-negotiation status do not match the input, or mode is not full duplex.
Examples
--------
```yaml
anta.tests.interfaces:
- VerifyInterfacesSpeed:
interfaces:
- name: Ethernet2
auto: False
speed: 10
- name: Eth3
auto: True
speed: 100
lanes: 1
- name: Eth2
auto: False
speed: 2.5
```
"""
name = "VerifyInterfacesSpeed"
description = "Verifies the speed, lanes, auto-negotiation status, and mode as full duplex for interfaces."
categories: ClassVar[list[str]] = ["interfaces"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces")]
class Input(AntaTest.Input):
"""Inputs for the VerifyInterfacesSpeed test."""
interfaces: list[InterfaceDetail]
"""List of interfaces to be tested"""
class InterfaceDetail(BaseModel):
"""Detail of an interface."""
name: EthernetInterface
"""The name of the interface."""
auto: bool
"""The auto-negotiation status of the interface."""
speed: float = Field(ge=1, le=1000)
"""The speed of the interface in Gigabits per second. Valid range is 1 to 1000."""
lanes: None | int = Field(None, ge=1, le=8)
"""The number of lanes in the interface. Valid range is 1 to 8. This field is optional."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyInterfacesSpeed."""
self.result.is_success()
command_output = self.instance_commands[0].json_output
# Iterate over all the interfaces
for interface in self.inputs.interfaces:
intf = interface.name
# Check if interface exists
if not (interface_output := get_value(command_output, f"interfaces.{intf}")):
self.result.is_failure(f"Interface `{intf}` is not found.")
continue
auto_negotiation = interface_output.get("autoNegotiate")
actual_lanes = interface_output.get("lanes")
# Collecting actual interface details
actual_interface_output = {
"auto negotiation": auto_negotiation if interface.auto is True else None,
"duplex mode": interface_output.get("duplex"),
"speed": interface_output.get("bandwidth"),
"lanes": actual_lanes if interface.lanes is not None else None,
}
# Forming expected interface details
expected_interface_output = {
"auto negotiation": "success" if interface.auto is True else None,
"duplex mode": "duplexFull",
"speed": interface.speed * BPS_GBPS_CONVERSIONS,
"lanes": interface.lanes,
}
# Forming failure message
if actual_interface_output != expected_interface_output:
for output in [actual_interface_output, expected_interface_output]:
# Convert speed to Gbps for readability
if output["speed"] is not None:
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")

View file

@ -0,0 +1,165 @@
# Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Test functions related to various router path-selection settings."""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
from __future__ import annotations
from ipaddress import IPv4Address
from typing import ClassVar
from pydantic import BaseModel
from anta.decorators import skip_on_platforms
from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_value
class VerifyPathsHealth(AntaTest):
"""
Verifies the path and telemetry state of all paths under router path-selection.
The expected states are 'IPsec established', 'Resolved' for path and 'active' for telemetry.
Expected Results
----------------
* Success: The test will pass if all path states under router path-selection are either 'IPsec established' or 'Resolved'
and their telemetry state as 'active'.
* Failure: The test will fail if router path-selection is not configured or if any path state is not 'IPsec established' or 'Resolved',
or the telemetry state is 'inactive'.
Examples
--------
```yaml
anta.tests.path_selection:
- VerifyPathsHealth:
```
"""
name = "VerifyPathsHealth"
description = "Verifies the path and telemetry state of all paths under router path-selection."
categories: ClassVar[list[str]] = ["path-selection"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show path-selection paths", revision=1)]
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyPathsHealth."""
self.result.is_success()
command_output = self.instance_commands[0].json_output["dpsPeers"]
# If no paths are configured for router path-selection, the test fails
if not command_output:
self.result.is_failure("No path configured for router path-selection.")
return
# Check the state of each path
for peer, peer_data in command_output.items():
for group, group_data in peer_data["dpsGroups"].items():
for path_data in group_data["dpsPaths"].values():
path_state = path_data["state"]
session = path_data["dpsSessions"]["0"]["active"]
# If the path state of any path is not 'ipsecEstablished' or 'routeResolved', the test fails
if path_state not in ["ipsecEstablished", "routeResolved"]:
self.result.is_failure(f"Path state for peer {peer} in path-group {group} is `{path_state}`.")
# If the telemetry state of any path is inactive, the test fails
elif not session:
self.result.is_failure(f"Telemetry state for peer {peer} in path-group {group} is `inactive`.")
class VerifySpecificPath(AntaTest):
"""
Verifies the path and telemetry state of a specific path for an IPv4 peer under router path-selection.
The expected states are 'IPsec established', 'Resolved' for path and 'active' for telemetry.
Expected Results
----------------
* Success: The test will pass if the path state under router path-selection is either 'IPsec established' or 'Resolved'
and telemetry state as 'active'.
* Failure: The test will fail if router path-selection is not configured or if the path state is not 'IPsec established' or 'Resolved',
or if the telemetry state is 'inactive'.
Examples
--------
```yaml
anta.tests.path_selection:
- VerifySpecificPath:
paths:
- peer: 10.255.0.1
path_group: internet
source_address: 100.64.3.2
destination_address: 100.64.1.2
```
"""
name = "VerifySpecificPath"
description = "Verifies the path and telemetry state of a specific path under router path-selection."
categories: ClassVar[list[str]] = ["path-selection"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaTemplate(template="show path-selection paths peer {peer} path-group {group} source {source} destination {destination}", revision=1)
]
class Input(AntaTest.Input):
"""Input model for the VerifySpecificPath test."""
paths: list[RouterPath]
"""List of router paths to verify."""
class RouterPath(BaseModel):
"""Detail of a router path."""
peer: IPv4Address
"""Static peer IPv4 address."""
path_group: str
"""Router path group name."""
source_address: IPv4Address
"""Source IPv4 address of path."""
destination_address: IPv4Address
"""Destination IPv4 address of path."""
def render(self, template: AntaTemplate) -> list[AntaCommand]:
"""Render the template for each router path."""
return [
template.render(peer=path.peer, group=path.path_group, source=path.source_address, destination=path.destination_address) for path in self.inputs.paths
]
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySpecificPath."""
self.result.is_success()
# Check the state of each path
for command in self.instance_commands:
peer = command.params.peer
path_group = command.params.group
source = command.params.source
destination = command.params.destination
command_output = command.json_output.get("dpsPeers", [])
# If the peer is not configured for the path group, the test fails
if not command_output:
self.result.is_failure(f"Path `peer: {peer} source: {source} destination: {destination}` is not configured for path-group `{path_group}`.")
continue
# Extract the state of the path
path_output = get_value(command_output, f"{peer}..dpsGroups..{path_group}..dpsPaths", separator="..")
path_state = next(iter(path_output.values())).get("state")
session = get_value(next(iter(path_output.values())), "dpsSessions.0.active")
# If the state of the path is not 'ipsecEstablished' or 'routeResolved', or the telemetry state is 'inactive', the test fails
if path_state not in ["ipsecEstablished", "routeResolved"]:
self.result.is_failure(f"Path state for `peer: {peer} source: {source} destination: {destination}` in path-group {path_group} is `{path_state}`.")
elif not session:
self.result.is_failure(
f"Telemetry state for path `peer: {peer} source: {source} destination: {destination}` in path-group {path_group} is `inactive`."
)

View file

@ -23,7 +23,7 @@ class VerifyPtpModeStatus(AntaTest):
----------------
* Success: The test will pass if the device is a BC.
* Failure: The test will fail if the device is not a BC.
* Error: The test will error if the 'ptpMode' variable is not present in the command output.
* Skipped: The test will be skipped if PTP is not configured on the device.
Examples
--------
@ -45,7 +45,7 @@ class VerifyPtpModeStatus(AntaTest):
command_output = self.instance_commands[0].json_output
if (ptp_mode := command_output.get("ptpMode")) is None:
self.result.is_error("'ptpMode' variable is not present in the command output")
self.result.is_skipped("PTP is not configured")
return
if ptp_mode != "ptpBoundaryClock":
@ -63,7 +63,7 @@ class VerifyPtpGMStatus(AntaTest):
----------------
* Success: The test will pass if the device is locked to the provided Grandmaster.
* Failure: The test will fail if the device is not locked to the provided Grandmaster.
* Error: The test will error if the 'gmClockIdentity' variable is not present in the command output.
* Skipped: The test will be skipped if PTP is not configured on the device.
Examples
--------
@ -92,7 +92,7 @@ class VerifyPtpGMStatus(AntaTest):
command_output = self.instance_commands[0].json_output
if (ptp_clock_summary := command_output.get("ptpClockSummary")) is None:
self.result.is_error("'ptpClockSummary' variable is not present in the command output")
self.result.is_skipped("PTP is not configured")
return
if ptp_clock_summary["gmClockIdentity"] != self.inputs.gmid:
@ -110,7 +110,7 @@ class VerifyPtpLockStatus(AntaTest):
----------------
* Success: The test will pass if the device was locked to the upstream GM in the last minute.
* Failure: The test will fail if the device was not locked to the upstream GM in the last minute.
* Error: The test will error if the 'lastSyncTime' variable is not present in the command output.
* Skipped: The test will be skipped if PTP is not configured on the device.
Examples
--------
@ -133,7 +133,7 @@ class VerifyPtpLockStatus(AntaTest):
command_output = self.instance_commands[0].json_output
if (ptp_clock_summary := command_output.get("ptpClockSummary")) is None:
self.result.is_error("'ptpClockSummary' variable is not present in the command output")
self.result.is_skipped("PTP is not configured")
return
time_difference = ptp_clock_summary["currentPtpSystemTime"] - ptp_clock_summary["lastSyncTime"]
@ -151,7 +151,7 @@ class VerifyPtpOffset(AntaTest):
----------------
* Success: The test will pass if the PTP timing offset is within +/- 1000ns from the master clock.
* Failure: The test will fail if the PTP timing offset is greater than +/- 1000ns from the master clock.
* Skipped: The test will be skipped if PTP is not configured.
* Skipped: The test will be skipped if PTP is not configured on the device.
Examples
--------

View file

@ -262,8 +262,8 @@ class VerifyBGPPeerCount(AntaTest):
command_output = command.json_output
afi = command.params.afi
safi = command.params.safi
afi_vrf = command.params.vrf or "default"
safi = command.params.safi if hasattr(command.params, "safi") else None
afi_vrf = command.params.vrf if hasattr(command.params, "vrf") else "default"
# Swapping AFI and SAFI in case of SR-TE
if afi == "sr-te":
@ -400,12 +400,12 @@ class VerifyBGPPeersHealth(AntaTest):
command_output = command.json_output
afi = command.params.afi
safi = command.params.safi
safi = command.params.safi if hasattr(command.params, "safi") else None
afi_vrf = command.params.vrf if hasattr(command.params, "vrf") else "default"
# Swapping AFI and SAFI in case of SR-TE
if afi == "sr-te":
afi, safi = safi, afi
afi_vrf = command.params.vrf or "default"
if not (vrfs := command_output.get("vrfs")):
_add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue="Not Configured")
@ -551,8 +551,8 @@ class VerifyBGPSpecificPeers(AntaTest):
command_output = command.json_output
afi = command.params.afi
safi = command.params.safi
afi_vrf = command.params.vrf or "default"
safi = command.params.safi if hasattr(command.params, "safi") else None
afi_vrf = command.params.vrf if hasattr(command.params, "vrf") else "default"
# Swapping AFI and SAFI in case of SR-TE
if afi == "sr-te":

308
anta/tests/routing/isis.py Normal file
View file

@ -0,0 +1,308 @@
# Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to IS-IS tests."""
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
from __future__ import annotations
from typing import Any, ClassVar, Literal
from pydantic import BaseModel
from anta.custom_types import Interface
from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_value
def _count_isis_neighbor(isis_neighbor_json: dict[str, Any]) -> int:
"""Count the number of isis neighbors.
Args
----
isis_neighbor_json: The JSON output of the `show isis neighbors` command.
Returns
-------
int: The number of isis neighbors.
"""
count = 0
for vrf_data in isis_neighbor_json["vrfs"].values():
for instance_data in vrf_data["isisInstances"].values():
count += len(instance_data.get("neighbors", {}))
return count
def _get_not_full_isis_neighbors(isis_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]:
"""Return the isis neighbors whose adjacency state is not `up`.
Args
----
isis_neighbor_json: The JSON output of the `show isis neighbors` command.
Returns
-------
list[dict[str, Any]]: A list of isis neighbors whose adjacency state is not `UP`.
"""
return [
{
"vrf": vrf,
"instance": instance,
"neighbor": adjacency["hostname"],
"state": state,
}
for vrf, vrf_data in isis_neighbor_json["vrfs"].items()
for instance, instance_data in vrf_data.get("isisInstances").items()
for neighbor, neighbor_data in instance_data.get("neighbors").items()
for adjacency in neighbor_data.get("adjacencies")
if (state := adjacency["state"]) != "up"
]
def _get_full_isis_neighbors(isis_neighbor_json: dict[str, Any], neighbor_state: Literal["up", "down"] = "up") -> list[dict[str, Any]]:
"""Return the isis neighbors whose adjacency state is `up`.
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
Returns
-------
list[dict[str, Any]]: A list of isis neighbors whose adjacency state is not `UP`.
"""
return [
{
"vrf": vrf,
"instance": instance,
"neighbor": adjacency["hostname"],
"neighbor_address": adjacency["routerIdV4"],
"interface": adjacency["interfaceName"],
"state": state,
}
for vrf, vrf_data in isis_neighbor_json["vrfs"].items()
for instance, instance_data in vrf_data.get("isisInstances").items()
for neighbor, neighbor_data in instance_data.get("neighbors").items()
for adjacency in neighbor_data.get("adjacencies")
if (state := adjacency["state"]) == neighbor_state
]
def _get_isis_neighbors_count(isis_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]:
"""Count number of IS-IS neighbor of the device."""
return [
{"vrf": vrf, "interface": interface, "mode": mode, "count": int(level_data["numAdjacencies"]), "level": int(level)}
for vrf, vrf_data in isis_neighbor_json["vrfs"].items()
for instance, instance_data in vrf_data.get("isisInstances").items()
for interface, interface_data in instance_data.get("interfaces").items()
for level, level_data in interface_data.get("intfLevels").items()
if (mode := level_data["passive"]) is not True
]
def _get_interface_data(interface: str, vrf: str, command_output: dict[str, Any]) -> dict[str, Any] | None:
"""Extract data related to an IS-IS interface for testing."""
if (vrf_data := get_value(command_output, f"vrfs.{vrf}")) is None:
return None
for instance_data in vrf_data.get("isisInstances").values():
if (intf_dict := get_value(dictionary=instance_data, key="interfaces")) is not None:
try:
return next(ifl_data for ifl, ifl_data in intf_dict.items() if ifl == interface)
except StopIteration:
return None
return None
class VerifyISISNeighborState(AntaTest):
"""Verifies all IS-IS neighbors are in UP state.
Expected Results
----------------
* Success: The test will pass if all IS-IS neighbors are in UP state.
* Failure: The test will fail if some IS-IS neighbors are not in UP state.
* Skipped: The test will be skipped if no IS-IS neighbor is found.
Examples
--------
```yaml
anta.tests.routing:
isis:
- VerifyISISNeighborState:
```
"""
name = "VerifyISISNeighborState"
description = "Verifies all IS-IS neighbors are in UP state."
categories: ClassVar[list[str]] = ["isis"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis neighbors", revision=1)]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyISISNeighborState."""
command_output = self.instance_commands[0].json_output
if _count_isis_neighbor(command_output) == 0:
self.result.is_skipped("No IS-IS neighbor detected")
return
self.result.is_success()
not_full_neighbors = _get_not_full_isis_neighbors(command_output)
if not_full_neighbors:
self.result.is_failure(f"Some neighbors are not in the correct state (UP): {not_full_neighbors}.")
class VerifyISISNeighborCount(AntaTest):
"""Verifies number of IS-IS neighbors per level and per interface.
Expected Results
----------------
* Success: The test will pass if the number of neighbors is correct.
* Failure: The test will fail if the number of neighbors is incorrect.
* Skipped: The test will be skipped if no IS-IS neighbor is found.
Examples
--------
```yaml
anta.tests.routing:
isis:
- VerifyISISNeighborCount:
interfaces:
- name: Ethernet1
level: 1
count: 2
- name: Ethernet2
level: 2
count: 1
- name: Ethernet3
count: 2
# level is set to 2 by default
```
"""
name = "VerifyISISNeighborCount"
description = "Verifies count of IS-IS interface per level"
categories: ClassVar[list[str]] = ["isis"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifyISISNeighborCount test."""
interfaces: list[InterfaceCount]
"""list of interfaces with their information."""
class InterfaceCount(BaseModel):
"""Input model for the VerifyISISNeighborCount test."""
name: Interface
"""Interface name to check."""
level: int = 2
"""IS-IS level to check."""
count: int
"""Number of IS-IS neighbors."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyISISNeighborCount."""
command_output = self.instance_commands[0].json_output
self.result.is_success()
isis_neighbor_count = _get_isis_neighbors_count(command_output)
if len(isis_neighbor_count) == 0:
self.result.is_skipped("No IS-IS neighbor detected")
for interface in self.inputs.interfaces:
eos_data = [ifl_data for ifl_data in isis_neighbor_count if ifl_data["interface"] == interface.name and ifl_data["level"] == interface.level]
if not eos_data:
self.result.is_failure(f"No neighbor detected for interface {interface.name}")
return
if eos_data[0]["count"] != interface.count:
self.result.is_failure(
f"Interface {interface.name}:"
f"expected Level {interface.level}: count {interface.count}, "
f"got Level {eos_data[0]['level']}: count {eos_data[0]['count']}"
)
class VerifyISISInterfaceMode(AntaTest):
"""Verifies ISIS Interfaces are running in correct mode.
Expected Results
----------------
* Success: The test will pass if all listed interfaces are running in correct mode.
* Failure: The test will fail if any of the listed interfaces is not running in correct mode.
* Skipped: The test will be skipped if no ISIS neighbor is found.
Examples
--------
```yaml
anta.tests.routing:
isis:
- VerifyISISInterfaceMode:
interfaces:
- name: Loopback0
mode: passive
# vrf is set to default by default
- name: Ethernet2
mode: passive
level: 2
# vrf is set to default by default
- name: Ethernet1
mode: point-to-point
vrf: default
# level is set to 2 by default
```
"""
name = "VerifyISISInterfaceMode"
description = "Verifies interface mode for IS-IS"
categories: ClassVar[list[str]] = ["isis"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifyISISNeighborCount test."""
interfaces: list[InterfaceState]
"""list of interfaces with their information."""
class InterfaceState(BaseModel):
"""Input model for the VerifyISISNeighborCount test."""
name: Interface
"""Interface name to check."""
level: Literal[1, 2] = 2
"""ISIS level configured for interface. Default is 2."""
mode: Literal["point-to-point", "broadcast", "passive"]
"""Number of IS-IS neighbors."""
vrf: str = "default"
"""VRF where the interface should be configured"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyISISInterfaceMode."""
command_output = self.instance_commands[0].json_output
self.result.is_success()
if len(command_output["vrfs"]) == 0:
self.result.is_failure("IS-IS is not configured on device")
# Check for p2p interfaces
for interface in self.inputs.interfaces:
interface_data = _get_interface_data(
interface=interface.name,
vrf=interface.vrf,
command_output=command_output,
)
# Check for correct VRF
if interface_data is not None:
interface_type = get_value(dictionary=interface_data, key="interfaceType", default="unset")
# Check for interfaceType
if interface.mode == "point-to-point" and interface.mode != interface_type:
self.result.is_failure(f"Interface {interface.name} in VRF {interface.vrf} is not running in {interface.mode} reporting {interface_type}")
# Check for passive
elif interface.mode == "passive":
json_path = f"intfLevels.{interface.level}.passive"
if interface_data is None or get_value(dictionary=interface_data, key=json_path, default=False) is False:
self.result.is_failure(f"Interface {interface.name} in VRF {interface.vrf} is not running in passive mode")
else:
self.result.is_failure(f"Interface {interface.name} not found in VRF {interface.vrf}")

View file

@ -44,7 +44,11 @@ class VerifySSHStatus(AntaTest):
"""Main test function for VerifySSHStatus."""
command_output = self.instance_commands[0].text_output
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.")
return
status = line.split("is ")[1]
if status == "disabled":

View file

@ -188,6 +188,7 @@ class VerifySTPForwardingPorts(AntaTest):
if not (topologies := get_value(command.json_output, "topologies")):
not_configured.append(vlan_id)
else:
interfaces_not_forwarding = []
for value in topologies.values():
if vlan_id and int(vlan_id) in value["vlans"]:
interfaces_not_forwarding = [interface for interface, state in value["interfaces"].items() if state["state"] != "forwarding"]

View file

@ -5,7 +5,26 @@
from __future__ import annotations
from typing import Any
import cProfile
import os
import pstats
from functools import wraps
from time import perf_counter
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
from anta.logger import format_td
if TYPE_CHECKING:
import sys
from logging import Logger
from types import TracebackType
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
F = TypeVar("F", bound=Callable[..., Any])
def get_failed_logs(expected_output: dict[Any, Any], actual_output: dict[Any, Any]) -> str:
@ -28,14 +47,35 @@ def get_failed_logs(expected_output: dict[Any, Any], actual_output: dict[Any, An
for element, expected_data in expected_output.items():
actual_data = actual_output.get(element)
if actual_data == expected_data:
continue
if actual_data is None:
failed_logs.append(f"\nExpected `{expected_data}` as the {element}, but it was not found in the actual output.")
elif actual_data != expected_data:
continue
# actual_data != expected_data: and actual_data is not None
failed_logs.append(f"\nExpected `{expected_data}` as the {element}, but found `{actual_data}` instead.")
return "".join(failed_logs)
def custom_division(numerator: float, denominator: float) -> int | float:
"""Get the custom division of numbers.
Custom division that returns an integer if the result is an integer, otherwise a float.
Parameters
----------
numerator: The numerator.
denominator: The denominator.
Returns
-------
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]],
@ -228,3 +268,81 @@ def get_item(
if required is True:
raise ValueError(custom_error_msg or var_name)
return default
class Catchtime:
"""A class working as a context to capture time differences."""
start: float
raw_time: float
time: str
def __init__(self, logger: Logger | None = None, message: str | None = None) -> None:
self.logger = logger
self.message = message
def __enter__(self) -> Self:
"""__enter__ method."""
self.start = perf_counter()
if self.logger and self.message:
self.logger.info("%s ...", self.message)
return self
def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> None:
"""__exit__ method."""
self.raw_time = perf_counter() - self.start
self.time = format_td(self.raw_time, 3)
if self.logger and self.message:
self.logger.info("%s completed in: %s.", self.message, self.time)
def cprofile(sort_by: str = "cumtime") -> Callable[[F], F]:
"""Profile a function with cProfile.
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'.
Returns
-------
Callable: The decorated function with conditional profiling.
"""
def decorator(func: F) -> F:
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
"""Enable cProfile or not.
If `ANTA_CPROFILE` is set, cProfile is enabled and dumps the stats to the file.
Args:
----
*args: Arbitrary positional arguments.
**kwargs: Arbitrary keyword arguments.
Returns
-------
The result of the function call.
"""
cprofile_file = os.environ.get("ANTA_CPROFILE")
if cprofile_file is not None:
profiler = cProfile.Profile()
profiler.enable()
try:
result = await func(*args, **kwargs)
finally:
if cprofile_file is not None:
profiler.disable()
stats = pstats.Stats(profiler).sort_stats(sort_by)
stats.dump_stats(cprofile_file)
return result
return cast(F, wrapper)
return decorator

12
asynceapi/__init__.py Normal file
View file

@ -0,0 +1,12 @@
# Copyright (c) 2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
# Initially written by Jeremy Schulman at https://github.com/jeremyschulman/aio-eapi
"""Arista EOS eAPI asyncio client."""
from .config_session import SessionConfig
from .device import Device
from .errors import EapiCommandError
__all__ = ["Device", "SessionConfig", "EapiCommandError"]

View file

@ -0,0 +1,58 @@
# Copyright (c) 2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
# Initially written by Jeremy Schulman at https://github.com/jeremyschulman/aio-eapi
"""Utility function to check if a port is open."""
# -----------------------------------------------------------------------------
# System Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import socket
from typing import TYPE_CHECKING
# -----------------------------------------------------------------------------
# Public Imports
# -----------------------------------------------------------------------------
if TYPE_CHECKING:
from httpx import URL
# -----------------------------------------------------------------------------
# Exports
# -----------------------------------------------------------------------------
__all__ = ["port_check_url"]
# -----------------------------------------------------------------------------
#
# CODE BEGINS
#
# -----------------------------------------------------------------------------
async def port_check_url(url: URL, timeout: int = 5) -> bool:
"""
Open the port designated by the URL given the timeout in seconds.
If the port is available then return True; False otherwise.
Parameters
----------
url: The URL that provides the target system
timeout: Time to await for the port to open in seconds
"""
port = url.port or socket.getservbyname(url.scheme)
try:
wr: asyncio.StreamWriter
_, wr = await asyncio.wait_for(asyncio.open_connection(host=url.host, port=port), timeout=timeout)
# MUST close if opened!
wr.close()
except TimeoutError:
return False
return True

289
asynceapi/config_session.py Normal file
View file

@ -0,0 +1,289 @@
# Copyright (c) 2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
# Initially written by Jeremy Schulman at https://github.com/jeremyschulman/aio-eapi
"""asynceapi.SessionConfig definition."""
# -----------------------------------------------------------------------------
# System Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .device import Device
# -----------------------------------------------------------------------------
# Exports
# -----------------------------------------------------------------------------
__all__ = ["SessionConfig"]
# -----------------------------------------------------------------------------
#
# CODE BEGINS
#
# -----------------------------------------------------------------------------
class SessionConfig:
"""
Send configuration to a device using the EOS session mechanism.
This is the preferred way of managing configuration changes.
Notes
-----
This class definition is used by the parent Device class definition as
defined by `config_session`. A Caller can use the SessionConfig directly
as well, but it is not required.
"""
CLI_CFG_FACTORY_RESET = "rollback clean-config"
def __init__(self, device: Device, name: str) -> None:
"""
Create a new instance of SessionConfig.
The session config instance bound
to the given device instance, and using the session `name`.
Parameters
----------
device: The associated device instance
name: The name of the config session
"""
self._device = device
self._cli = device.cli
self._name = name
self._cli_config_session = f"configure session {self.name}"
# -------------------------------------------------------------------------
# properties for read-only attributes
# -------------------------------------------------------------------------
@property
def name(self) -> str:
"""Return read-only session name attribute."""
return self._name
@property
def device(self) -> Device:
"""Return read-only device instance attribute."""
return self._device
# -------------------------------------------------------------------------
# Public Methods
# -------------------------------------------------------------------------
async def status_all(self) -> dict[str, Any]:
"""
Get the status of all the session config on the device.
Run the following command on the device:
# show configuration sessions detail
Returns
-------
Dict object of native EOS eAPI response; see `status` method for
details.
Examples
--------
{
"maxSavedSessions": 1,
"maxOpenSessions": 5,
"sessions": {
"jeremy1": {
"instances": {},
"state": "pending",
"commitUser": "",
"description": ""
},
"ansible_167510439362": {
"instances": {},
"state": "completed",
"commitUser": "joe.bob",
"description": "",
"completedTime": 1675104396.4500246
}
}
}
"""
return await self._cli("show configuration sessions detail") # type: ignore[return-value] # json outformat returns dict[str, Any]
async def status(self) -> dict[str, Any] | None:
"""
Get the status of a session config on the device.
Run the following command on the device:
# show configuration sessions detail
And return only the status dictionary for this session. If you want
all sessions, then use the `status_all` method.
Returns
-------
Dict instance of the session status. If the session does not exist,
then this method will return None.
The native eAPI results from JSON output, see example:
Examples
--------
all results:
{
"maxSavedSessions": 1,
"maxOpenSessions": 5,
"sessions": {
"jeremy1": {
"instances": {},
"state": "pending",
"commitUser": "",
"description": ""
},
"ansible_167510439362": {
"instances": {},
"state": "completed",
"commitUser": "joe.bob",
"description": "",
"completedTime": 1675104396.4500246
}
}
}
if the session name was 'jeremy1', then this method would return
{
"instances": {},
"state": "pending",
"commitUser": "",
"description": ""
}
"""
res = await self.status_all()
return res["sessions"].get(self.name)
async def push(self, content: list[str] | str, *, replace: bool = False) -> None:
"""
Send the configuration content to the device.
If `replace` is true, then the command "rollback clean-config" is issued
before sending the configuration content.
Parameters
----------
content:
The text configuration CLI commands, as a list of strings, that
will be sent to the device. If the parameter is a string, and not
a list, then split the string across linebreaks. In either case
any empty lines will be discarded before they are send to the
device.
replace:
When True, the content will replace the existing configuration
on the device.
"""
# if given s string, we need to break it up into individual command
# lines.
if isinstance(content, str):
content = content.splitlines()
# prepare the initial set of command to enter the config session and
# rollback clean if the `replace` argument is True.
commands: list[str | dict[str, Any]] = [self._cli_config_session]
if replace:
commands.append(self.CLI_CFG_FACTORY_RESET)
# add the Caller's commands, filtering out any blank lines. any command
# lines (!) are still included.
commands.extend(filter(None, content))
await self._cli(commands=commands)
async def commit(self, timer: str | None = None) -> None:
"""
Commit the session config.
Run the following command on the device:
# configure session <name>
# commit
If the timer is specified, format is "hh:mm:ss", then a commit timer is
started. A second commit action must be made to confirm the config
session before the timer expires; otherwise the config-session is
automatically aborted.
"""
command = f"{self._cli_config_session} commit"
if timer:
command += f" timer {timer}"
await self._cli(command)
async def abort(self) -> None:
"""
Abort the configuration session.
Run the following command on the device:
# configure session <name> abort
"""
await self._cli(f"{self._cli_config_session} abort")
async def diff(self) -> str:
"""
Return the "diff" of the session config relative to the running config.
Run the following command on the device:
# show session-config named <name> diffs
Returns
-------
Return a string in diff-patch format.
References
----------
* https://www.gnu.org/software/diffutils/manual/diffutils.txt
"""
return await self._cli(f"show session-config named {self.name} diffs", ofmt="text") # type: ignore[return-value] # text outformat returns str
async def load_file(self, filename: str, *, replace: bool = False) -> None:
"""
Load the configuration from <filename> into the session configuration.
If the replace parameter is True then the file contents will replace the existing session config (load-replace).
Parameters
----------
filename:
The name of the configuration file. The caller is required to
specify the filesystem, for example, the
filename="flash:thisfile.cfg"
replace:
When True, the contents of the file will completely replace the
session config for a load-replace behavior.
Raises
------
If there are any issues with loading the configuration file then a
RuntimeError is raised with the error messages content.
"""
commands: list[str | dict[str, Any]] = [self._cli_config_session]
if replace:
commands.append(self.CLI_CFG_FACTORY_RESET)
commands.append(f"copy {filename} session-config")
res: list[dict[str, Any]] = await self._cli(commands=commands) # type: ignore[assignment] # JSON outformat of multiple commands returns list[dict[str, Any]]
checks_re = re.compile(r"error|abort|invalid", flags=re.I)
messages = res[-1]["messages"]
if any(map(checks_re.search, messages)):
raise RuntimeError("".join(messages))
async def write(self) -> None:
"""Save the running config to the startup config by issuing the command "write" to the device."""
await self._cli("write")

291
asynceapi/device.py Normal file
View file

@ -0,0 +1,291 @@
# Copyright (c) 2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
# Initially written by Jeremy Schulman at https://github.com/jeremyschulman/aio-eapi
"""asynceapi.Device definition."""
# -----------------------------------------------------------------------------
# System Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
from socket import getservbyname
from typing import TYPE_CHECKING, Any
# -----------------------------------------------------------------------------
# Public Imports
# -----------------------------------------------------------------------------
import httpx
# -----------------------------------------------------------------------------
# Private Imports
# -----------------------------------------------------------------------------
from .aio_portcheck import port_check_url
from .config_session import SessionConfig
from .errors import EapiCommandError
if TYPE_CHECKING:
from collections.abc import Sequence
# -----------------------------------------------------------------------------
# Exports
# -----------------------------------------------------------------------------
__all__ = ["Device"]
# -----------------------------------------------------------------------------
#
# CODE BEGINS
#
# -----------------------------------------------------------------------------
class Device(httpx.AsyncClient):
"""
Represent the async JSON-RPC client that communicates with an Arista EOS device.
This class inherits directly from the
httpx.AsyncClient, so any initialization options can be passed directly.
"""
auth = None
EAPI_OFMT_OPTIONS = ("json", "text")
EAPI_DEFAULT_OFMT = "json"
def __init__( # noqa: PLR0913 # pylint: disable=too-many-arguments
self,
host: str | None = None,
username: str | None = None,
password: str | None = None,
proto: str = "https",
port: str | int | None = None,
**kwargs: Any, # noqa: ANN401
) -> None:
"""
Initialize the Device class.
As a subclass to httpx.AsyncClient, the caller can provide any of those initializers.
Specific parameters for Device class are all optional and described below.
Parameters
----------
host: The EOS target device, either hostname (DNS) or ipaddress.
username: The login user-name; requires the password parameter.
password: The login password; requires the username parameter.
proto: The protocol, http or https, to communicate eAPI with the device.
port: If not provided, the proto value is used to look up the associated
port (http=80, https=443). If provided, overrides the port used to
communite with the device.
Other Parameters
----------------
base_url: str
If provided, the complete URL to the device eAPI endpoint.
auth:
If provided, used as the httpx authorization initializer value. If
not provided, then username+password is assumed by the Caller and
used to create a BasicAuth instance.
"""
self.port = port or getservbyname(proto)
self.host = host
kwargs.setdefault("base_url", httpx.URL(f"{proto}://{self.host}:{self.port}"))
kwargs.setdefault("verify", False)
if username and password:
self.auth = httpx.BasicAuth(username, password)
kwargs.setdefault("auth", self.auth)
super().__init__(**kwargs)
self.headers["Content-Type"] = "application/json-rpc"
async def check_connection(self) -> bool:
"""
Check the target device to ensure that the eAPI port is open and accepting connections.
It is recommended that a Caller checks the connection before involving cli commands,
but this step is not required.
Returns
-------
True when the device eAPI is accessible, False otherwise.
"""
return await port_check_url(self.base_url)
async def cli( # noqa: PLR0913 # pylint: disable=too-many-arguments
self,
command: str | dict[str, Any] | None = None,
commands: Sequence[str | dict[str, Any]] | None = None,
ofmt: str | None = None,
version: int | str | None = "latest",
*,
suppress_error: bool = False,
auto_complete: bool = False,
expand_aliases: bool = False,
req_id: int | str | None = None,
) -> list[dict[str, Any] | str] | dict[str, Any] | str | None:
"""
Execute one or more CLI commands.
Parameters
----------
command:
A single command to execute; results in a single output response
commands:
A list of commands to execute; results in a list of output responses
ofmt:
Either 'json' or 'text'; indicates the output format for the CLI commands.
version:
By default the eAPI will use "version 1" for all API object models.
This driver will, by default, always set version to "latest" so
that the behavior matches the CLI of the device. The caller can
override the "latest" behavior by explicitly setting the version.
suppress_error:
When not False, then if the execution of the command would-have
raised an EapiCommandError, rather than raising this exception this
routine will return the value None.
For example, if the following command had raised
EapiCommandError, now response would be set to None instead.
response = dev.cli(..., suppress_error=True)
auto_complete:
Enabled/disables the command auto-compelete feature of the EAPI. Per the
documentation:
Allows users to use shorthand commands in eAPI calls. With this
parameter included a user can send 'sh ver' via eAPI to get the
output of 'show version'.
expand_aliases:
Enables/disables the command use of User defined alias. Per the
documentation:
Allowed users to provide the expandAliases parameter to eAPI
calls. This allows users to use aliased commands via the API.
For example if an alias is configured as 'sv' for 'show version'
then an API call with sv and the expandAliases parameter will
return the output of show version.
req_id:
A unique identifier that will be echoed back by the switch. May be a string or number.
Returns
-------
One or List of output responses, per the description above.
"""
if not any((command, commands)):
msg = "Required 'command' or 'commands'"
raise RuntimeError(msg)
jsonrpc = self._jsonrpc_command(
commands=[command] if command else commands, ofmt=ofmt, version=version, auto_complete=auto_complete, expand_aliases=expand_aliases, req_id=req_id
)
try:
res = await self.jsonrpc_exec(jsonrpc)
return res[0] if command else res
except EapiCommandError:
if suppress_error:
return None
raise
def _jsonrpc_command( # noqa: PLR0913 # pylint: disable=too-many-arguments
self,
commands: Sequence[str | dict[str, Any]] | None = None,
ofmt: str | None = None,
version: int | str | None = "latest",
*,
auto_complete: bool = False,
expand_aliases: bool = False,
req_id: int | str | None = None,
) -> dict[str, Any]:
"""Create the JSON-RPC command dictionary object."""
cmd: dict[str, Any] = {
"jsonrpc": "2.0",
"method": "runCmds",
"params": {
"version": version,
"cmds": commands,
"format": ofmt or self.EAPI_DEFAULT_OFMT,
},
"id": req_id or id(self),
}
if auto_complete is not None:
cmd["params"].update({"autoComplete": auto_complete})
if expand_aliases is not None:
cmd["params"].update({"expandAliases": expand_aliases})
return cmd
async def jsonrpc_exec(self, jsonrpc: dict[str, Any]) -> list[dict[str, Any] | str]:
"""
Execute the JSON-RPC dictionary object.
Parameters
----------
jsonrpc:
The JSON-RPC as created by the `meth`:_jsonrpc_command().
Raises
------
EapiCommandError
In the event that a command resulted in an error response.
Returns
-------
The list of command results; either dict or text depending on the
JSON-RPC format parameter.
"""
res = await self.post("/command-api", json=jsonrpc)
res.raise_for_status()
body = res.json()
commands = jsonrpc["params"]["cmds"]
ofmt = jsonrpc["params"]["format"]
get_output = (lambda _r: _r["output"]) if ofmt == "text" else (lambda _r: _r)
# if there are no errors then return the list of command results.
if (err_data := body.get("error")) is None:
return [get_output(cmd_res) for cmd_res in body["result"]]
# ---------------------------------------------------------------------
# if we are here, then there were some command errors. Raise a
# EapiCommandError exception with args (commands that failed, passed,
# not-executed).
# ---------------------------------------------------------------------
# -------------------------- eAPI specification ----------------------
# On an error, no result object is present, only an error object, which
# is guaranteed to have the following attributes: code, messages, and
# data. Similar to the result object in the successful response, the
# data object is a list of objects corresponding to the results of all
# commands up to, and including, the failed command. If there was a an
# error before any commands were executed (e.g. bad credentials), data
# will be empty. The last object in the data array will always
# correspond to the failed command. The command failure details are
# always stored in the errors array.
cmd_data = err_data["data"]
len_data = len(cmd_data)
err_at = len_data - 1
err_msg = err_data["message"]
raise EapiCommandError(
passed=[get_output(cmd_data[cmd_i]) for cmd_i, cmd in enumerate(commands[:err_at])],
failed=commands[err_at]["cmd"],
errors=cmd_data[err_at]["errors"],
errmsg=err_msg,
not_exec=commands[err_at + 1 :],
)
def config_session(self, name: str) -> SessionConfig:
"""
return a SessionConfig instance bound to this device with the given session name.
Parameters
----------
name: The config-session name
"""
return SessionConfig(self, name)

42
asynceapi/errors.py Normal file
View file

@ -0,0 +1,42 @@
# Copyright (c) 2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
# Initially written by Jeremy Schulman at https://github.com/jeremyschulman/aio-eapi
"""asynceapi module exceptions."""
from __future__ import annotations
from typing import Any
import httpx
class EapiCommandError(RuntimeError):
"""
Exception class for EAPI command errors.
Attributes
----------
failed: the failed command
errmsg: a description of the failure reason
errors: the command failure details
passed: a list of command results of the commands that passed
not_exec: a list of commands that were not executed
"""
def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]) -> None: # noqa: PLR0913 # pylint: disable=too-many-arguments
"""Initialize for the EapiCommandError exception."""
self.failed = failed
self.errmsg = errmsg
self.errors = errors
self.passed = passed
self.not_exec = not_exec
super().__init__()
def __str__(self) -> str:
"""Return the error message associated with the exception."""
return self.errmsg
# alias for exception during sending-receiving
EapiTransportError = httpx.HTTPStatusError

View file

@ -19,14 +19,31 @@ ANTA is Python framework that automates tests for Arista devices.
- Automate NRFU (Network Ready For Use) test on a preproduction network
- Automate tests on a live network (periodically or on demand)
- ANTA can be used with:
- The [ANTA CLI](cli/overview.md)
- As a [Python library](advanced_usages/as-python-lib.md) in your own application
- The [ANTA CLI](cli/overview.md)
![anta nrfu](https://raw.githubusercontent.com/arista-netdevops-community/anta/main/docs/imgs/anta-nrfu.svg)
## Install ANTA library
The library will **NOT** install the necessary dependencies for the CLI.
```bash
# Install ANTA CLI
$ pip install anta
# Install ANTA as a library
pip install anta
```
## Install ANTA CLI
If you plan to use ANTA only as a CLI tool you can use `pipx` to install it.
[`pipx`](https://pipx.pypa.io/stable/) is a tool to install and run python applications in isolated environments. Refer to `pipx` instructions to install on your system.
`pipx` installs ANTA in an isolated python environment and makes it available globally.
**This is not recommended if you plan to contribute to ANTA**
```bash
# Install ANTA CLI with pipx
$ pipx install anta[cli]
# Run ANTA CLI
$ anta --help
@ -52,8 +69,11 @@ Commands:
nrfu Run ANTA tests on devices
```
> [!WARNING]
> The ANTA CLI options have changed after version 0.11 and have moved away from the top level `anta` and are now required at their respective commands (e.g. `anta nrfu`). This breaking change occurs after users feedback on making the CLI more intuitive. This change should not affect user experience when using environment variables.
You can also still choose to install it with directly with `pip`:
```bash
$ pip install anta[cli]
```
## Documentation
@ -65,4 +85,6 @@ Contributions are welcome. Please refer to the [contribution guide](contribution
## Credits
Thank you to [Jeremy Schulman](https://github.com/jeremyschulman) for [aio-eapi](https://github.com/jeremyschulman/aio-eapi/tree/main/aioeapi).
Thank you to [Angélique Phillipps](https://github.com/aphillipps), [Colin MacGiollaEáin](https://github.com/colinmacgiolla), [Khelil Sator](https://github.com/ksator), [Matthieu Tache](https://github.com/mtache), [Onur Gashi](https://github.com/onurgashi), [Paul Lavelle](https://github.com/paullavelle), [Guillaume Mulocher](https://github.com/gmuloc) and [Thomas Grimonet](https://github.com/titom73) for their contributions and guidances.

View file

@ -55,7 +55,9 @@ class VerifyTemperature(AntaTest):
[AntaTest](../api/models.md#anta.models.AntaTest) also provide more advanced capabilities like [AntaCommand](../api/models.md#anta.models.AntaCommand) templating using the [AntaTemplate](../api/models.md#anta.models.AntaTemplate) class or test inputs definition and validation using [AntaTest.Input](../api/models.md#anta.models.AntaTest.Input) [pydantic](https://docs.pydantic.dev/latest/) model. This will be discussed in the sections below.
## [AntaTest](../api/models.md#anta.models.AntaTest) structure
## AntaTest structure
Full AntaTest API documentation is available in the [API documentation section](../api/models.md#anta.models.AntaTest)
### Class Attributes
@ -98,7 +100,9 @@ class VerifyTemperature(AntaTest):
The base definition of [AntaTest.Input](../api/models.md#anta.models.AntaTest.Input) provides common test inputs for all [AntaTest](../api/models.md#anta.models.AntaTest) instances:
#### [Input](../api/models.md#anta.models.AntaTest.Input) model
#### Input model
Full `Input` model documentation is available in [API documentation section](../api/models.md#anta.models.AntaTest.Input)
::: anta.models.AntaTest.Input
options:
@ -114,7 +118,9 @@ The base definition of [AntaTest.Input](../api/models.md#anta.models.AntaTest.In
show_root_toc_entry: false
heading_level: 10
#### [ResultOverwrite](../api/models.md#anta.models.AntaTest.Input.ResultOverwrite) model
#### ResultOverwrite model
Full `ResultOverwrite` model documentation is available in [API documentation section](../api/models.md#anta.models.AntaTest.Input.ResultOverwrite)
::: anta.models.AntaTest.Input.ResultOverwrite
options:

9
docs/api/runner.md Normal file
View file

@ -0,0 +1,9 @@
<!--
~ Copyright (c) 2023-2024 Arista Networks, Inc.
~ Use of this source code is governed by the Apache License 2.0
~ that can be found in the LICENSE file.
-->
### ::: anta.runner
options:
filters: ["!^_[^_]", "!__str__"]

20
docs/api/tests.avt.md Normal file
View file

@ -0,0 +1,20 @@
---
anta_title: ANTA catalog for Adaptive Virtual Topology (AVT) tests
---
<!--
~ Copyright (c) 2023-2024 Arista Networks, Inc.
~ Use of this source code is governed by the Apache License 2.0
~ that can be found in the LICENSE file.
-->
::: anta.tests.avt
options:
show_root_heading: false
show_root_toc_entry: false
show_bases: false
merge_init_into_class: false
anta_hide_test_module_description: true
show_labels: true
filters:
- "!test"
- "!render"

View file

@ -13,6 +13,7 @@ This section describes all the available tests provided by the ANTA package.
Here are the tests that we currently provide:
- [AAA](tests.aaa.md)
- [Adaptive Virtual Topology](tests.avt.md)
- [BFD](tests.bfd.md)
- [Configuration](tests.configuration.md)
- [Connectivity](tests.connectivity.md)
@ -26,6 +27,7 @@ Here are the tests that we currently provide:
- [Multicast](tests.multicast.md)
- [Profiles](tests.profiles.md)
- [PTP](tests.ptp.md)
- [Router Path Selection](tests.path_selection.md)
- [Routing Generic](tests.routing.generic.md)
- [Routing BGP](tests.routing.bgp.md)
- [Routing OSPF](tests.routing.ospf.md)

View file

@ -0,0 +1,20 @@
---
anta_title: ANTA catalog for Router path-selection tests
---
<!--
~ Copyright (c) 2023-2024 Arista Networks, Inc.
~ Use of this source code is governed by the Apache License 2.0
~ that can be found in the LICENSE file.
-->
::: anta.tests.path_selection
options:
show_root_heading: false
show_root_toc_entry: false
show_bases: false
merge_init_into_class: false
anta_hide_test_module_description: true
show_labels: true
filters:
- "!test"
- "!render"

View file

@ -0,0 +1,20 @@
---
anta_title: ANTA catalog for IS-IS tests
---
<!--
~ Copyright (c) 2023-2024 Arista Networks, Inc.
~ Use of this source code is governed by the Apache License 2.0
~ that can be found in the LICENSE file.
-->
::: anta.tests.routing.isis
options:
show_root_heading: false
show_root_toc_entry: false
show_bases: false
merge_init_into_class: false
anta_hide_test_module_description: true
show_labels: true
filters:
- "!test"
- "!render"

View file

@ -14,17 +14,28 @@ In large setups, it might be beneficial to construct your inventory based on you
$ anta get from-ansible --help
Usage: anta get from-ansible [OPTIONS]
Build ANTA inventory from an ansible inventory YAML file
Build ANTA inventory from an ansible inventory YAML file.
NOTE: This command does not support inline vaulted variables. Make sure to
comment them out.
Options:
-o, --output FILE Path to save inventory file [env var:
ANTA_INVENTORY; required]
--overwrite Do not prompt when overriding current inventory
[env var: ANTA_GET_FROM_ANSIBLE_OVERWRITE]
-g, --ansible-group TEXT Ansible group to filter
--ansible-inventory FILENAME
Path to your ansible inventory file to read
-o, --output FILENAME Path to save inventory file
-d, --inventory-directory PATH Directory to save inventory file
--ansible-inventory FILE Path to your ansible inventory file to read
[required]
--help Show this message and exit.
```
!!! warning
`anta get from-ansible` does not support inline vaulted variables, comment them out to generate your inventory.
If the vaulted variable is necessary to build the inventory (e.g. `ansible_host`), it needs to be unvaulted for `from-ansible` command to work."
The output is an inventory where the name of the container is added as a tag for each host:
```yaml

View file

@ -173,7 +173,7 @@ The `--output` option allows you to choose the path where the final report will
```bash
anta nrfu --tags LEAF tpl-report --template ./custom_template.j2
```
[![anta nrfu json results](../imgs/anta-nrfu-tpl-report-output.png){ loading=lazy width="1600" }](../imgs/anta-nrfu-tpl-report-output.png)
[![anta nrfu tpl_resultss](../imgs/anta-nrfu-tpl-report-output.png){ loading=lazy width="1600" }](../imgs/anta-nrfu-tpl-report-output.png)
The template `./custom_template.j2` is a simple Jinja2 template:
@ -200,3 +200,9 @@ cat nrfu-tpl-report.txt
* VerifyMlagConfigSanity is [green]SUCCESS[/green] for DC1-LEAF1A
* VerifyMlagReloadDelay is [green]SUCCESS[/green] for DC1-LEAF1A
```
## Dry-run mode
It is possible to run `anta nrfu --dry-run` to execute ANTA up to the point where it should communicate with the network to execute the tests. When using `--dry-run`, all inventory devices are assumed to be online. This can be useful to check how many tests would be run using the catalog and inventory.
[![anta nrfu dry_run](../imgs/anta_nrfu___dry_run.svg){ loading=lazy width="1600" }](../imgs/anta_nrfu___dry_run.svg)

View file

@ -12,9 +12,6 @@ ANTA can also be used as a Python library, allowing you to build your own tools
To start using the ANTA CLI, open your terminal and type `anta`.
!!! warning
The ANTA CLI options have changed after version 0.11 and have moved away from the top level `anta` and are now required at their respective commands (e.g. `anta nrfu`). This breaking change occurs after users feedback on making the CLI more intuitive. This change should not affect user experience when using environment variables.
## Invoking ANTA CLI
```bash

View file

@ -21,12 +21,14 @@ $ cd anta
# Install ANTA in editable mode and its development tools
$ pip install -e .[dev]
# To also install the CLI
$ pip install -e .[dev,cli]
# Verify installation
$ pip list -e
Package Version Editable project location
------- ------- -------------------------
anta 0.14.0 /mnt/lab/projects/anta
anta 0.15.0 /mnt/lab/projects/anta
```
Then, [`tox`](https://tox.wiki/) is configured with few environments to run CI locally:
@ -91,17 +93,20 @@ All submodule should have its own pytest section under `tests/units/anta_tests/<
The Python modules in the `tests/units/anta_tests` folder define test parameters for AntaTest subclasses unit tests.
A generic test function is written for all unit tests in `tests.lib.anta` module.
The `pytest_generate_tests` function definition in `conftest.py` is called during test collection.
The `pytest_generate_tests` function will parametrize the generic test function based on the `DATA` data structure defined in `tests.units.anta_tests` modules.
See https://docs.pytest.org/en/7.3.x/how-to/parametrize.html#basic-pytest-generate-tests-example
The `DATA` structure is a list of dictionaries used to parametrize the test.
The list elements have the following keys:
- `name` (str): Test name as displayed by Pytest.
- `test` (AntaTest): An AntaTest subclass imported in the test module - e.g. VerifyUptime.
- `eos_data` (list[dict]): List of data mocking EOS returned data to be passed to the test.
- `inputs` (dict): Dictionary to instantiate the `test` inputs as defined in the class from `test`.
- `expected` (dict): Expected test result structure, a dictionary containing a key
The `DATA` structure is a list of dictionaries used to parametrize the test. The list elements have the following keys:
- `name` (str): Test name as displayed by Pytest.
- `test` (AntaTest): An AntaTest subclass imported in the test module - e.g. VerifyUptime.
- `eos_data` (list[dict]): List of data mocking EOS returned data to be passed to the test.
- `inputs` (dict): Dictionary to instantiate the `test` inputs as defined in the class from `test`.
- `expected` (dict): Expected test result structure, a dictionary containing a key
`result` containing one of the allowed status (`Literal['success', 'failure', 'unset', 'skipped', 'error']`) and optionally a key `messages` which is a list(str) and each message is expected to be a substring of one of the actual messages in the TestResult object.

View file

@ -1,5 +1,5 @@
---
toc_depth: 4
toc_depth: 2
---
<!--
~ Copyright (c) 2023-2024 Arista Networks, Inc.
@ -7,7 +7,7 @@ toc_depth: 4
~ that can be found in the LICENSE file.
-->
<style>
h4 {
.md-typeset h2 {
visibility: hidden;
font-size: 0em;
height: 0em;

View file

@ -13,7 +13,7 @@ This section shows how to use ANTA with basic configuration. All examples are ba
The easiest way to install ANTA package is to run Python (`>=3.9`) and its pip package to install:
```bash
pip install anta
pip install anta[cli]
```
For more details about how to install package, please see the [requirements and installation](./requirements-and-installation.md) section.
@ -121,6 +121,14 @@ anta.tests.configuration:
## Test your network
### Basic usage in a python script
```python
--8<-- "anta_runner.py"
```
### CLI
ANTA comes with a generic CLI entrypoint to run tests in your network. It requires an inventory file as well as a test catalog.
This entrypoint has multiple options to manage test coverage and reporting.
@ -135,7 +143,7 @@ This entrypoint has multiple options to manage test coverage and reporting.
To run the NRFU, you need to select an output format amongst ["json", "table", "text", "tpl-report"]. For a first usage, `table` is recommended. By default all test results for all devices are rendered but it can be changed to a report per test case or per host
### Default report using table
#### Default report using table
```bash
anta nrfu \
@ -176,7 +184,7 @@ anta nrfu \
└───────────┴──────────────────────────┴─────────────┴──────────────────┴──────────────────────────────────────────────────────────────────────┴───────────────┘
```
### Report in text mode
#### Report in text mode
```bash
$ anta nrfu \
@ -206,7 +214,7 @@ leaf01 :: VerifyMlagConfigSanity :: SKIPPED (MLAG is disabled)
[...]
```
### Report in JSON format
#### Report in JSON format
```bash
$ anta nrfu \

View file

@ -0,0 +1,127 @@
<svg class="rich-terminal" viewBox="0 0 1482 440.4" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-2602327173-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-2602327173-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-2602327173-r1 { fill: #c5c8c6 }
.terminal-2602327173-r2 { fill: #68a0b3 }
.terminal-2602327173-r3 { fill: #98a84b }
.terminal-2602327173-r4 { fill: #4e707b }
.terminal-2602327173-r5 { fill: #608ab1 }
.terminal-2602327173-r6 { fill: #d0b344 }
.terminal-2602327173-r7 { fill: #868887 }
.terminal-2602327173-r8 { fill: #00823d;font-weight: bold }
.terminal-2602327173-r9 { fill: #68a0b3;font-weight: bold }
.terminal-2602327173-r10 { fill: #c5c8c6;font-weight: bold }
</style>
<defs>
<clipPath id="terminal-2602327173-clip-terminal">
<rect x="0" y="0" width="1463.0" height="389.4" />
</clipPath>
<clipPath id="terminal-2602327173-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-2602327173-line-1">
<rect x="0" y="25.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-2602327173-line-2">
<rect x="0" y="50.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-2602327173-line-3">
<rect x="0" y="74.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-2602327173-line-4">
<rect x="0" y="99.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-2602327173-line-5">
<rect x="0" y="123.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-2602327173-line-6">
<rect x="0" y="147.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-2602327173-line-7">
<rect x="0" y="172.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-2602327173-line-8">
<rect x="0" y="196.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-2602327173-line-9">
<rect x="0" y="221.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-2602327173-line-10">
<rect x="0" y="245.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-2602327173-line-11">
<rect x="0" y="269.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-2602327173-line-12">
<rect x="0" y="294.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-2602327173-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-2602327173-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="438.4" rx="8"/><text class="terminal-2602327173-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">anta&#160;nrfu&#160;--dry-run</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-2602327173-clip-terminal)">
<g class="terminal-2602327173-matrix">
<text class="terminal-2602327173-r1" x="0" y="20" textLength="390.4" clip-path="url(#terminal-2602327173-line-0)">ant@anthill$&#160;anta&#160;nrfu&#160;--dry-run</text><text class="terminal-2602327173-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-2602327173-line-0)">
</text><text class="terminal-2602327173-r2" x="0" y="44.4" textLength="24.4" clip-path="url(#terminal-2602327173-line-1)">╭─</text><text class="terminal-2602327173-r2" x="24.4" y="44.4" textLength="256.2" clip-path="url(#terminal-2602327173-line-1)">─────────────────────</text><text class="terminal-2602327173-r3" x="292.8" y="44.4" textLength="97.6" clip-path="url(#terminal-2602327173-line-1)">Settings</text><text class="terminal-2602327173-r2" x="402.6" y="44.4" textLength="256.2" clip-path="url(#terminal-2602327173-line-1)">─────────────────────</text><text class="terminal-2602327173-r2" x="658.8" y="44.4" textLength="24.4" clip-path="url(#terminal-2602327173-line-1)">─╮</text><text class="terminal-2602327173-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-2602327173-line-1)">
</text><text class="terminal-2602327173-r2" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-2602327173-line-2)"></text><text class="terminal-2602327173-r2" x="24.4" y="68.8" textLength="634.4" clip-path="url(#terminal-2602327173-line-2)">-&#160;ANTA&#160;Inventory&#160;contains&#160;3&#160;devices&#160;(AsyncEOSDevice)</text><text class="terminal-2602327173-r2" x="671" y="68.8" textLength="12.2" clip-path="url(#terminal-2602327173-line-2)"></text><text class="terminal-2602327173-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-2602327173-line-2)">
</text><text class="terminal-2602327173-r2" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-2602327173-line-3)"></text><text class="terminal-2602327173-r2" x="24.4" y="93.2" textLength="390.4" clip-path="url(#terminal-2602327173-line-3)">-&#160;Tests&#160;catalog&#160;contains&#160;9&#160;tests</text><text class="terminal-2602327173-r2" x="671" y="93.2" textLength="12.2" clip-path="url(#terminal-2602327173-line-3)"></text><text class="terminal-2602327173-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-2602327173-line-3)">
</text><text class="terminal-2602327173-r2" x="0" y="117.6" textLength="683.2" clip-path="url(#terminal-2602327173-line-4)">╰──────────────────────────────────────────────────────╯</text><text class="terminal-2602327173-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-2602327173-line-4)">
</text><text class="terminal-2602327173-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-2602327173-line-5)">
</text><text class="terminal-2602327173-r4" x="0" y="166.4" textLength="231.8" clip-path="url(#terminal-2602327173-line-6)">[04/29/24&#160;12:12:25]</text><text class="terminal-2602327173-r5" x="244" y="166.4" textLength="97.6" clip-path="url(#terminal-2602327173-line-6)">INFO&#160;&#160;&#160;&#160;</text><text class="terminal-2602327173-r1" x="353.8" y="166.4" textLength="292.8" clip-path="url(#terminal-2602327173-line-6)">Preparing&#160;ANTA&#160;NRFU&#160;Run&#160;</text><text class="terminal-2602327173-r6" x="646.6" y="166.4" textLength="36.6" clip-path="url(#terminal-2602327173-line-6)">...</text><text class="terminal-2602327173-r7" x="1317.6" y="166.4" textLength="97.6" clip-path="url(#terminal-2602327173-line-6)">tools.py</text><text class="terminal-2602327173-r7" x="1415.2" y="166.4" textLength="12.2" clip-path="url(#terminal-2602327173-line-6)">:</text><text class="terminal-2602327173-r7" x="1427.4" y="166.4" textLength="36.6" clip-path="url(#terminal-2602327173-line-6)">288</text><text class="terminal-2602327173-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-2602327173-line-6)">
</text><text class="terminal-2602327173-r5" x="244" y="190.8" textLength="97.6" clip-path="url(#terminal-2602327173-line-7)">INFO&#160;&#160;&#160;&#160;</text><text class="terminal-2602327173-r1" x="353.8" y="190.8" textLength="244" clip-path="url(#terminal-2602327173-line-7)">Preparing&#160;the&#160;tests&#160;</text><text class="terminal-2602327173-r6" x="597.8" y="190.8" textLength="36.6" clip-path="url(#terminal-2602327173-line-7)">...</text><text class="terminal-2602327173-r7" x="1317.6" y="190.8" textLength="97.6" clip-path="url(#terminal-2602327173-line-7)">tools.py</text><text class="terminal-2602327173-r7" x="1415.2" y="190.8" textLength="12.2" clip-path="url(#terminal-2602327173-line-7)">:</text><text class="terminal-2602327173-r7" x="1427.4" y="190.8" textLength="36.6" clip-path="url(#terminal-2602327173-line-7)">288</text><text class="terminal-2602327173-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-2602327173-line-7)">
</text><text class="terminal-2602327173-r5" x="244" y="215.2" textLength="97.6" clip-path="url(#terminal-2602327173-line-8)">INFO&#160;&#160;&#160;&#160;</text><text class="terminal-2602327173-r1" x="353.8" y="215.2" textLength="414.8" clip-path="url(#terminal-2602327173-line-8)">Preparing&#160;the&#160;tests&#160;completed&#160;in:&#160;</text><text class="terminal-2602327173-r8" x="768.6" y="215.2" textLength="85.4" clip-path="url(#terminal-2602327173-line-8)">0:00:00</text><text class="terminal-2602327173-r1" x="854" y="215.2" textLength="12.2" clip-path="url(#terminal-2602327173-line-8)">.</text><text class="terminal-2602327173-r9" x="866.2" y="215.2" textLength="36.6" clip-path="url(#terminal-2602327173-line-8)">001</text><text class="terminal-2602327173-r1" x="902.8" y="215.2" textLength="402.6" clip-path="url(#terminal-2602327173-line-8)">.&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-2602327173-r7" x="1317.6" y="215.2" textLength="97.6" clip-path="url(#terminal-2602327173-line-8)">tools.py</text><text class="terminal-2602327173-r7" x="1415.2" y="215.2" textLength="12.2" clip-path="url(#terminal-2602327173-line-8)">:</text><text class="terminal-2602327173-r7" x="1427.4" y="215.2" textLength="36.6" clip-path="url(#terminal-2602327173-line-8)">296</text><text class="terminal-2602327173-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-2602327173-line-8)">
</text><text class="terminal-2602327173-r5" x="244" y="239.6" textLength="97.6" clip-path="url(#terminal-2602327173-line-9)">INFO&#160;&#160;&#160;&#160;</text><text class="terminal-2602327173-r1" x="353.8" y="239.6" textLength="939.4" clip-path="url(#terminal-2602327173-line-9)">---&#160;ANTA&#160;NRFU&#160;Run&#160;Information&#160;---&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-2602327173-r7" x="1305.4" y="239.6" textLength="109.8" clip-path="url(#terminal-2602327173-line-9)">runner.py</text><text class="terminal-2602327173-r7" x="1415.2" y="239.6" textLength="12.2" clip-path="url(#terminal-2602327173-line-9)">:</text><text class="terminal-2602327173-r7" x="1427.4" y="239.6" textLength="36.6" clip-path="url(#terminal-2602327173-line-9)">245</text><text class="terminal-2602327173-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-2602327173-line-9)">
</text><text class="terminal-2602327173-r1" x="353.8" y="264" textLength="231.8" clip-path="url(#terminal-2602327173-line-10)">Number&#160;of&#160;devices:&#160;</text><text class="terminal-2602327173-r9" x="585.6" y="264" textLength="12.2" clip-path="url(#terminal-2602327173-line-10)">3</text><text class="terminal-2602327173-r10" x="610" y="264" textLength="12.2" clip-path="url(#terminal-2602327173-line-10)">(</text><text class="terminal-2602327173-r9" x="622.2" y="264" textLength="12.2" clip-path="url(#terminal-2602327173-line-10)">3</text><text class="terminal-2602327173-r1" x="634.4" y="264" textLength="146.4" clip-path="url(#terminal-2602327173-line-10)">&#160;established</text><text class="terminal-2602327173-r10" x="780.8" y="264" textLength="12.2" clip-path="url(#terminal-2602327173-line-10)">)</text><text class="terminal-2602327173-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-2602327173-line-10)">
</text><text class="terminal-2602327173-r1" x="353.8" y="288.4" textLength="390.4" clip-path="url(#terminal-2602327173-line-11)">Total&#160;number&#160;of&#160;selected&#160;tests:&#160;</text><text class="terminal-2602327173-r9" x="744.2" y="288.4" textLength="24.4" clip-path="url(#terminal-2602327173-line-11)">27</text><text class="terminal-2602327173-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-2602327173-line-11)">
</text><text class="terminal-2602327173-r1" x="353.8" y="312.8" textLength="854" clip-path="url(#terminal-2602327173-line-12)">Maximum&#160;number&#160;of&#160;open&#160;file&#160;descriptors&#160;for&#160;the&#160;current&#160;ANTA&#160;process:&#160;</text><text class="terminal-2602327173-r9" x="1207.8" y="312.8" textLength="61" clip-path="url(#terminal-2602327173-line-12)">16384</text><text class="terminal-2602327173-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-2602327173-line-12)">
</text><text class="terminal-2602327173-r1" x="353.8" y="337.2" textLength="939.4" clip-path="url(#terminal-2602327173-line-13)">---------------------------------&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-2602327173-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-2602327173-line-13)">
</text><text class="terminal-2602327173-r5" x="244" y="361.6" textLength="97.6" clip-path="url(#terminal-2602327173-line-14)">INFO&#160;&#160;&#160;&#160;</text><text class="terminal-2602327173-r1" x="353.8" y="361.6" textLength="463.6" clip-path="url(#terminal-2602327173-line-14)">Preparing&#160;ANTA&#160;NRFU&#160;Run&#160;completed&#160;in:&#160;</text><text class="terminal-2602327173-r8" x="817.4" y="361.6" textLength="85.4" clip-path="url(#terminal-2602327173-line-14)">0:00:00</text><text class="terminal-2602327173-r1" x="902.8" y="361.6" textLength="12.2" clip-path="url(#terminal-2602327173-line-14)">.</text><text class="terminal-2602327173-r9" x="915" y="361.6" textLength="36.6" clip-path="url(#terminal-2602327173-line-14)">006</text><text class="terminal-2602327173-r1" x="951.6" y="361.6" textLength="353.8" clip-path="url(#terminal-2602327173-line-14)">.&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-2602327173-r7" x="1317.6" y="361.6" textLength="97.6" clip-path="url(#terminal-2602327173-line-14)">tools.py</text><text class="terminal-2602327173-r7" x="1415.2" y="361.6" textLength="12.2" clip-path="url(#terminal-2602327173-line-14)">:</text><text class="terminal-2602327173-r7" x="1427.4" y="361.6" textLength="36.6" clip-path="url(#terminal-2602327173-line-14)">296</text><text class="terminal-2602327173-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-2602327173-line-14)">
</text><text class="terminal-2602327173-r5" x="244" y="386" textLength="97.6" clip-path="url(#terminal-2602327173-line-15)">INFO&#160;&#160;&#160;&#160;</text><text class="terminal-2602327173-r1" x="353.8" y="386" textLength="939.4" clip-path="url(#terminal-2602327173-line-15)">Dry-run&#160;mode,&#160;exiting&#160;before&#160;running&#160;the&#160;tests.&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-2602327173-r7" x="1305.4" y="386" textLength="109.8" clip-path="url(#terminal-2602327173-line-15)">runner.py</text><text class="terminal-2602327173-r7" x="1415.2" y="386" textLength="12.2" clip-path="url(#terminal-2602327173-line-15)">:</text><text class="terminal-2602327173-r7" x="1427.4" y="386" textLength="36.6" clip-path="url(#terminal-2602327173-line-15)">257</text><text class="terminal-2602327173-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-2602327173-line-15)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -22,24 +22,56 @@ This installation will deploy tests collection, scripts and all their Python req
The ANTA package and the cli require some packages that are not part of the Python standard library. They are indicated in the [pyproject.toml](https://github.com/arista-netdevops-community/anta/blob/main/pyproject.toml) file, under dependencies.
### Install from Pypi server
### Install library from Pypi server
```bash
pip install anta
```
!!! Warning
* This command alone **will not** install the ANTA CLI requirements.
* When using ANTA mode in [AVD](https://avd.arista.com) `eos_validate` role, (currently in preview), ensure you install the documented supported ANTA version for your AVD version.</br>
The latest documented version can be found at: https://avd.arista.com/stable/roles/eos_validate_state/ANTA-Preview.html
### Install ANTA CLI as an application with `pipx`
[`pipx`](https://pipx.pypa.io/stable/) is a tool to install and run python applications in isolated environments. If you plan to use ANTA only as a CLI tool you can use `pipx` to install it. `pipx` installs ANTA in an isolated python environment and makes it available globally.
```
pipx install anta[cli]
```
!!! Info
Please take the time to read through the installation instructions of `pipx` before getting started.
### Install CLI from Pypi server
Alternatively, pip install with `cli` extra is enough to install the ANTA CLI.
```bash
pip install anta[cli]
```
### Install ANTA from github
```bash
pip install git+https://github.com/arista-netdevops-community/anta.git
pip install git+https://github.com/arista-netdevops-community/anta.git#egg=anta[cli]
# You can even specify the branch, tag or commit:
pip install git+https://github.com/arista-netdevops-community/anta.git@<cool-feature-branch>
pip install git+https://github.com/arista-netdevops-community/anta.git@<cool-tag>
pip install git+https://github.com/arista-netdevops-community/anta.git@<more-or-less-cool-hash>
```
pip install git+https://github.com/arista-netdevops-community/anta.git@<cool-feature-branch>#egg=anta[cli]
pip install git+https://github.com/arista-netdevops-community/anta.git@<cool-tag>
pip install git+https://github.com/arista-netdevops-community/anta.git@<cool-tag>#egg=anta[cli]
pip install git+https://github.com/arista-netdevops-community/anta.git@<more-or-less-cool-hash>
pip install git+https://github.com/arista-netdevops-community/anta.git@<more-or-less-cool-hash>#egg=anta[cli]
```
### Check installation
@ -61,12 +93,12 @@ which anta
```bash
# Check ANTA version
anta --version
anta, version v0.14.0
anta, version v0.15.0
```
## EOS Requirements
To get ANTA working, the targeted Arista EOS devices must have the following configuration (assuming you connect to the device using Management interface in MGMT VRF):
To get ANTA working, the targeted Arista EOS devices must have eAPI enabled. They need to use the following configuration (assuming you connect to the device using Management interface in MGMT VRF):
```eos
configure

View file

@ -13,6 +13,7 @@ python generate_svg.py anta ...
# ruff: noqa: T201
import io
import logging
import os
import pathlib
import sys
@ -22,10 +23,17 @@ from importlib.metadata import entry_points
from unittest.mock import patch
from rich.console import Console
from rich.logging import RichHandler
from anta.cli.console import console
from anta.cli.nrfu.utils import anta_progress_bar
root = logging.getLogger()
r = RichHandler(console=console)
root.addHandler(r)
OUTPUT_DIR = pathlib.Path(__file__).parent.parent / "imgs"
@ -43,7 +51,7 @@ def custom_progress_bar() -> None:
if __name__ == "__main__":
# Sane rich size
os.environ["COLUMNS"] = "165"
os.environ["COLUMNS"] = "120"
# stolen from https://github.com/ewels/rich-click/blob/main/src/rich_click/cli.py
args = sys.argv[1:]
@ -64,6 +72,8 @@ if __name__ == "__main__":
print("Usage: python generate_svg.py anta <options>")
sys.exit(1)
# possibly-used-before-assignment - prog / function_name -> not understanding sys.exit here...
# pylint: disable=E0606
sys.argv = [prog, *args[1:]]
module = import_module(module_path)
function = getattr(module, function_name)
@ -71,22 +81,24 @@ if __name__ == "__main__":
# Console to captur everything
new_console = Console(record=True)
# tweaks to record and redirect to a dummy file
pipe = io.StringIO()
console.record = True
console.file = pipe
with redirect_stdout(io.StringIO()) as f:
# tweaks to record and redirect to a dummy file
console.print(f"ant@anthill$ {' '.join(sys.argv)}")
# Redirect stdout of the program towards another StringIO to capture help
# that is not part or anta rich console
# redirect potential progress bar output to console by patching
with redirect_stdout(io.StringIO()) as f, patch("anta.cli.nrfu.commands.anta_progress_bar", custom_progress_bar), suppress(SystemExit):
with patch("anta.cli.nrfu.anta_progress_bar", custom_progress_bar), suppress(SystemExit):
function()
# print to our new console the output of anta console
new_console.print(console.export_text())
# print the content of the stdout to our new_console
new_console.print(f.getvalue())
if "--help" in args:
console.print(f.getvalue())
filename = f"{'_'.join(x.replace('/', '_').replace('-', '_').replace('.', '_') for x in args)}.svg"
filename = f"{OUTPUT_DIR}/{filename}"
print(f"File saved at {filename}")
new_console.save_svg(filename, title=" ".join(args))
console.save_svg(filename, title=" ".join(args))

View file

@ -42,6 +42,10 @@ Options:
ANTA_NRFU_IGNORE_ERROR]
--hide [success|failure|error|skipped]
Group result by test or device.
--dry-run Run anta nrfu command but stop before
starting to execute the tests. Considers all
devices as connected. [env var:
ANTA_NRFU_DRY_RUN]
--help Show this message and exit.
Commands:

View file

@ -2,11 +2,15 @@
--md-hue: 210;
}
#page {
counter-reset: heading;
}
:root {
/* Color schema based on Arista Color Schema */
/* Default color shades */
--md-default-fg-color: #000000;
--md-default-fg-color--light: #a1a0a0;
--md-default-fg-color--light: #444343;
--md-default-fg-color--lighter: #FFFFFF;
--md-default-fg-color--lightest: #FFFFFF;
--md-default-bg-color: #FFFFFF;
@ -35,12 +39,8 @@
--md-code-bg-color: #E6E6E6;
--md-code-border-color: #0000004f;
--block-code-bg-color: #e4e4e4;
/* --md-code-fg-color: ...; */
font-size: 1.1rem;
/* min-height: 100%;
position: relative;
width: 100%; */
font-feature-settings: "kern","liga";
font-family: var(--md-text-font-family,_),-apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;
-webkit-font-smoothing: antialiased;
@ -49,15 +49,16 @@
[data-md-color-scheme="slate"] {
/* Default color shades */
--md-default-fg-color--light: #949393;
/* Link color */
--md-typeset-a-color: #75aaf8;
--md-typeset-a-color-fg: #FFFFFF;
--md-typeset-a-color-bg: #27569B;
/* Code block color shades */
/* --md-code-bg-color: #E6E6E6; */
--md-code-border-color: #aec6db4f;
/* --block-code-bg-color: #e4e4e4; */
}
@media only screen and (min-width: 76.25em) {
@ -76,6 +77,7 @@
}
@media only screen {
.md-typeset a:hover {
background-color: var(--md-typeset-a-color-bg);
color: var(--md-typeset-a-color-fg);
@ -102,12 +104,56 @@
color: var(--md-default-fg-color--light);
}
.md-typeset h4 h5 h6 {
.md-typeset h2 {
line-height: 2em;
font-size: 1.5rem;
margin: 1em 0;
/* font-weight: 700; */
letter-spacing: -.01em;
line-height: 3em;
color: var(--md-default-fg-color--light);
text-transform: capitalize;
font-style: normal;
font-weight: bolder;
}
.md-typeset h3 {
line-height: 1em;
font-size: 1.3rem;
margin: 1em 0;
/* font-weight: 700; */
letter-spacing: -.01em;
color: var(--md-default-fg-color--light);
text-transform: capitalize;
font-style: normal;
font-weight: bold;
}
.md-typeset h4::before {
content: ">> ";
}
.md-typeset h4 {
font-size: 1.1rem;
margin: 1em 0;
font-weight: 700;
letter-spacing: -.01em;
line-height: 1em;
color: var(--md-default-fg-color--light);
font-style: italic;
text-transform: capitalize;
}
.md-typeset h5,
.md-typeset h6 {
font-size: 0.9rem;
margin: 1em 0;
/* font-weight: 700; */
letter-spacing: -.01em;
/* line-height: 2em; */
color: var(--md-default-fg-color--light);
font-style: italic;
text-transform: capitalize;
text-decoration: underline;
}
.md-typeset table:not([class]) th {
@ -178,8 +224,6 @@
.md-typeset table:not([class]) th {
min-width: 5rem;
padding: .6rem .8rem;
/* color: var(--md-primary-fg-color--light); */
bg: var(--md-footer-fg-color--lighter);
}
.md-footer-copyright {
@ -195,7 +239,6 @@
margin-left: auto;
margin-right: auto;
border-radius: 1%;
/* width: 50%; */
}
}

View file

@ -77,3 +77,14 @@ Example:
```bash
ANTA_DEBUG=true anta -l DEBUG --log-file anta.log nrfu --enable --username username --password arista --inventory inventory.yml -c nrfu.yml text
```
### Troubleshooting on EOS
ANTA is using a specific ID in eAPI requests towards EOS. This allows for easier eAPI requests debugging on the device using EOS configuration `trace CapiApp setting UwsgiRequestContext/4,CapiUwsgiServer/4` to set up CapiApp agent logs.
Then, you can view agent logs using:
```bash
bash tail -f /var/log/agents/CapiApp-*
2024-05-15 15:32:54.056166 1429 UwsgiRequestContext 4 request content b'{"jsonrpc": "2.0", "method": "runCmds", "params": {"version": "latest", "cmds": [{"cmd": "show ip route vrf default 10.255.0.3", "revision": 4}], "format": "json", "autoComplete": false, "expandAliases": false}, "id": "ANTA-VerifyRoutingTableEntry-132366530677328"}'
```

View file

@ -244,3 +244,32 @@ Once you run `anta nrfu table`, you will see following output:
│ spine01 │ VerifyInterfaceUtilization │ success │ │ Verifies interfaces utilization is below 75%. │ interfaces │
└───────────┴────────────────────────────┴─────────────┴────────────┴───────────────────────────────────────────────┴───────────────┘
```
### Example script to merge catalogs
The following script reads all the files in `intended/test_catalogs/` with names `<device_name>-catalog.yml` and merge them together inside one big catalog `anta-catalog.yml`.
```python
#!/usr/bin/env python
from anta.catalog import AntaCatalog
from pathlib import Path
from anta.models import AntaTest
CATALOG_SUFFIX = '-catalog.yml'
CATALOG_DIR = 'intended/test_catalogs/'
if __name__ == "__main__":
catalog = AntaCatalog()
for file in Path(CATALOG_DIR).glob('*'+CATALOG_SUFFIX):
c = AntaCatalog.parse(file)
device = str(file).removesuffix(CATALOG_SUFFIX).removeprefix(CATALOG_DIR)
print(f"Merging test catalog for device {device}")
# Apply filters to all tests for this device
for test in c.tests:
test.inputs.filters = AntaTest.Input.Filters(tags=[device])
catalog.merge(c)
with open(Path('anta-catalog.yml'), "w") as f:
f.write(catalog.dump().yaml())
```

67
examples/anta_runner.py Normal file
View file

@ -0,0 +1,67 @@
# 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.
"""Example script for ANTA.
usage:
python anta_runner.py
"""
from __future__ import annotations
import asyncio
import logging
import sys
from pathlib import Path
from anta.catalog import AntaCatalog
from anta.cli.nrfu.utils import anta_progress_bar
from anta.inventory import AntaInventory
from anta.logger import Log, setup_logging
from anta.models import AntaTest
from anta.result_manager import ResultManager
from anta.runner import main as anta_runner
# setup logging
setup_logging(Log.INFO, Path("/tmp/anta.log"))
LOGGER = logging.getLogger()
SCRIPT_LOG_PREFIX = "[bold magenta][ANTA RUNNER SCRIPT][/] " # For convenience purpose - there are nicer way to do this.
# NOTE: The inventory and catalog files are not delivered with this script
USERNAME = "admin"
PASSWORD = "admin"
CATALOG_PATH = Path("/tmp/anta_catalog.yml")
INVENTORY_PATH = Path("/tmp/anta_inventory.yml")
# Load catalog file
try:
catalog = AntaCatalog.parse(CATALOG_PATH)
except Exception:
LOGGER.exception("%s Catalog failed to load!", SCRIPT_LOG_PREFIX)
sys.exit(1)
LOGGER.info("%s Catalog loaded!", SCRIPT_LOG_PREFIX)
# Load inventory
try:
inventory = AntaInventory.parse(INVENTORY_PATH, username=USERNAME, password=PASSWORD)
except Exception:
LOGGER.exception("%s Inventory failed to load!", SCRIPT_LOG_PREFIX)
sys.exit(1)
LOGGER.info("%s Inventory loaded!", SCRIPT_LOG_PREFIX)
# Create result manager object
manager = ResultManager()
# Launch ANTA
LOGGER.info("%s Starting ANTA runner...", SCRIPT_LOG_PREFIX)
with anta_progress_bar() as AntaTest.progress:
# Set dry_run to True to avoid connecting to the devices
asyncio.run(anta_runner(manager, inventory, catalog, dry_run=False))
LOGGER.info("%s ANTA run completed!", SCRIPT_LOG_PREFIX)
# Manipulate the test result object
for test_result in manager.results:
LOGGER.info("%s %s:%s:%s", SCRIPT_LOG_PREFIX, test_result.name, test_result.test, test_result.result)

View file

@ -50,6 +50,18 @@ anta.tests.aaa:
- commands
- dot1x
anta.tests.avt:
- VerifyAVTPathHealth:
- VerifyAVTSpecificPath:
avt_paths:
- avt_name: CONTROL-PLANE-PROFILE
vrf: default
destination: 10.101.255.2
next_hop: 10.101.255.1
path_type: direct
- VerifyAVTRole:
role: edge
anta.tests.bfd:
- VerifyBFDSpecificPeers:
bfd_peers:
@ -167,6 +179,18 @@ anta.tests.interfaces:
- 10.10.10.10/31
- VerifyIpVirtualRouterMac:
mac_address: 00:1c:73:00:dc:01
- VerifyInterfacesSpeed:
interfaces:
- name: Ethernet2
auto: False
speed: 10
- name: Eth3
auto: True
speed: 100
lanes: 1
- name: Eth2
auto: False
speed: 2.5
anta.tests.lanz:
- VerifyLANZ:
@ -210,6 +234,15 @@ anta.tests.multicast:
- VerifyIGMPSnoopingGlobal:
enabled: True
anta.tests.path_selection:
- VerifyPathsHealth:
- VerifySpecificPath:
paths:
- peer: 10.255.0.1
path_group: internet
source_address: 100.64.3.2
destination_address: 100.64.1.2
anta.tests.profiles:
- VerifyUnifiedForwardingTableMode:
mode: 3
@ -515,3 +548,29 @@ anta.tests.routing:
- VerifyOSPFNeighborCount:
number: 3
- VerifyOSPFMaxLSA:
isis:
- VerifyISISNeighborState:
- VerifyISISNeighborCount:
interfaces:
- name: Ethernet1
level: 1
count: 2
- name: Ethernet2
level: 2
count: 1
- name: Ethernet3
count: 2
# level is set to 2 by default
- VerifyISISInterfaceMode:
interfaces:
- name: Loopback0
mode: passive
# vrf is set to default by default
- name: Ethernet2
mode: passive
level: 2
# vrf is set to default by default
- name: Ethernet1
mode: point-to-point
vrf: default
# level is set to 2 by default

View file

@ -145,10 +145,12 @@ markdown_extensions:
separator: "-"
# permalink: "#"
permalink: true
baselevel: 3
# baselevel: 3
- pymdownx.highlight
- pymdownx.snippets:
base_path: docs/snippets
base_path:
- docs/snippets
- examples
- pymdownx.superfences
- pymdownx.superfences
- pymdownx.tabbed:
@ -178,6 +180,7 @@ nav:
- Tests Documentation:
- Overview: api/tests.md
- AAA: api/tests.aaa.md
- Adaptive Virtual Topology: api/tests.avt.md
- BFD: api/tests.bfd.md
- Configuration: api/tests.configuration.md
- Connectivity: api/tests.connectivity.md
@ -191,10 +194,12 @@ nav:
- Multicast: api/tests.multicast.md
- Profiles: api/tests.profiles.md
- PTP: api/tests.ptp.md
- Router Path Selection: api/tests.path_selection.md
- Routing:
- Generic: api/tests.routing.generic.md
- BGP: api/tests.routing.bgp.md
- OSPF: api/tests.routing.ospf.md
- ISIS: api/tests.routing.isis.md
- Security: api/tests.security.md
- Services: api/tests.services.md
- SNMP: api/tests.snmp.md
@ -205,11 +210,11 @@ nav:
- VXLAN: api/tests.vxlan.md
- VLAN: api/tests.vlan.md
- API Documentation:
- Device: api/device.md
- Inventory:
- Inventory module: api/inventory.md
- Inventory models: api/inventory.models.input.md
- Test Catalog: api/catalog.md
- Device: api/device.md
- Test:
- Test models: api/models.md
- Input Types: api/types.md
@ -217,6 +222,7 @@ nav:
- Result Manager module: api/result_manager.md
- Result Manager models: api/result_manager_models.md
- Report Manager: api/report_manager.md
- Runner: api/runner.md
- Troubleshooting: troubleshooting.md
- Contributions: contribution.md
- FAQ: faq.md

View file

@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "anta"
version = "v0.14.0"
version = "v0.15.0"
readme = "docs/README.md"
authors = [{ name = "Khelil Sator", email = "ksator@arista.com" }]
maintainers = [
@ -17,19 +17,17 @@ maintainers = [
description = "Arista Network Test Automation (ANTA) Framework"
license = { file = "LICENSE" }
dependencies = [
"aiocache~=0.12.2",
"aio-eapi==0.6.3",
"click~=8.1.6",
"click-help-colors~=0.9",
"cvprac~=1.3.1",
"pydantic>=2.6.1,<2.8.0",
"pydantic-extra-types>=2.1.0",
"eval-type-backport~=0.1.3", # Support newer typing features in older Python versions (required until Python 3.9 support is removed)
"PyYAML~=6.0",
"requests~=2.31.0",
"rich>=13.5.2,<13.8.0",
"asyncssh>=2.13.2,<2.15.0",
"Jinja2~=3.1.2",
"aiocache>=0.12.2",
"asyncssh>=2.13.2",
"cvprac>=1.3.1",
"eval-type-backport>=0.1.3", # Support newer typing features in older Python versions (required until Python 3.9 support is removed)
"Jinja2>=3.1.2",
"pydantic>=2.7",
"pydantic-extra-types>=2.3.0",
"PyYAML>=6.0",
"requests>=2.31.0",
"rich>=13.5.2,<14",
"httpx>=0.27.0"
]
keywords = ["test", "anta", "Arista", "network", "automation", "networking", "devops", "netdevops"]
classifiers = [
@ -53,40 +51,44 @@ classifiers = [
requires-python = ">=3.9"
[project.optional-dependencies]
cli = [
"click~=8.1.6",
"click-help-colors>=0.9",
]
dev = [
"bumpver==2023.1129",
"bumpver>=2023.1129",
"codespell~=2.2.6",
"mypy~=1.9",
"mypy-extensions~=1.0",
"mypy~=1.10",
"pre-commit>=3.3.3",
"pylint-pydantic>=0.2.4",
"pylint>=2.17.5",
"ruff~=0.3.5",
"pytest>=7.4.0",
"pytest-asyncio>=0.21.1",
"pytest-cov>=4.1.0",
"pytest-dependency",
"pytest-html>=3.2.0",
"pytest-metadata>=3.0.0",
"pylint-pydantic>=0.2.4",
"pytest>=7.4.0",
"ruff>=0.3.7,<0.5.0",
"tox>=4.10.0,<5.0.0",
"types-PyYAML",
"types-paramiko",
"types-pyOpenSSL",
"types-requests",
"typing-extensions",
"yamllint>=1.32.0",
]
doc = [
"fontawesome_markdown",
"mkdocs>=1.3.1",
"griffe",
"mike==2.1.1",
"mkdocs-autorefs>=0.4.1",
"mkdocs-bootswatch>=1.1",
"mkdocs-git-revision-date-localized-plugin>=1.1.0",
"mkdocs-git-revision-date-plugin>=0.3.2",
"mkdocs-material>=8.3.9",
"mkdocs-material-extensions>=1.0.3",
"mkdocs-material>=8.3.9",
"mkdocs>=1.3.1",
"mkdocstrings[python]>=0.20.0",
"mike==2.0.0",
"griffe",
]
[project.urls]
@ -101,14 +103,14 @@ anta = "anta.cli:cli"
# Tools
################################
[tool.setuptools.packages.find]
include = ["anta*"]
include = ["anta*", "asynceapi*"]
namespaces = false
################################
# Version
################################
[tool.bumpver]
current_version = "0.14.0"
current_version = "0.15.0"
version_pattern = "MAJOR.MINOR.PATCH"
commit_message = "bump: Version {old_version} -> {new_version}"
commit = true
@ -131,6 +133,8 @@ plugins = [
]
# Comment below for better type checking
#follow_imports = "skip"
# Make it false if we implement stubs using stubgen from mypy for aio-eapi, aiocache and cvprac
# and configure mypy_path to generated stubs e.g.: mypy_path = "./out"
ignore_missing_imports = true
warn_redundant_casts = true
# Note: tox find some unused type ignore which are required for pre-commit
@ -159,15 +163,20 @@ addopts = "-ra -q -vv --cov --cov-report term:skip-covered --color yes"
log_level = "WARNING"
render_collapsed = true
testpaths = ["tests"]
filterwarnings = [
"error",
# cvprac is raising the next warning
"default:pkg_resources is deprecated:DeprecationWarning",
# Need to investigate the following - only occuring when running the full pytest suite
"ignore:Exception ignored in.*:pytest.PytestUnraisableExceptionWarning",
]
[tool.coverage.run]
branch = true
source = ["anta"]
parallel = true
omit= [
# omit aioeapi patch
"anta/aioeapi.py",
]
[tool.coverage.report]
# Regexes for lines to exclude from consideration
@ -225,7 +234,9 @@ python =
[testenv]
description = Run pytest with {basepython}
extras = dev
extras =
dev
cli
# posargs allows to run only a specific test using
# tox -e <env> -- path/to/my/test::test
commands =
@ -297,7 +308,6 @@ exclude = [
"site-packages",
"venv",
".github",
"aioeapi.py" # Remove this when https://github.com/jeremyschulman/aio-eapi/pull/13 is merged
]
line-length = 165
@ -374,6 +384,9 @@ runtime-evaluated-base-classes = ["pydantic.BaseModel", "anta.models.AntaTest.In
"anta/cli/exec/utils.py" = [
"SLF001", # TODO: some private members, lets try to fix
]
"anta/cli/__init__.py" = [
"T201", # Allow print statements
]
"anta/cli/*" = [
"PLR0913", # Allow more than 5 input arguments in CLI functions
"ANN401", # TODO: Check if we can update the Any type hints in the CLI
@ -390,16 +403,17 @@ runtime-evaluated-base-classes = ["pydantic.BaseModel", "anta.models.AntaTest.In
"ANN401", # Ok to use Any type hint in our custom get functions
"PLR0913", # Ok to have more than 5 arguments in our custom get functions
]
"anta/runner.py" = [
"C901", # TODO: main function is too complex, needs a refactor
"PERF203", # TODO: try - except within a loop, same sa above needs a refactor
]
"anta/device.py" = [
"PLR0913", # Ok to have more than 5 arguments in the AntaDevice classes
]
"anta/inventory/__init__.py" = [
"PLR0913", # Ok to have more than 5 arguments in the AntaInventory class
]
"examples/anta_runner.py" = [ # This is an example script and linked in snippets
"S108", # Probable insecure usage of temporary file or directory
"S105", # Possible hardcoded password
"INP001", # Implicit packages
]
################################
# Pylint
@ -433,4 +447,5 @@ extension-pkg-whitelist="pydantic"
ignore-paths = [
"^tests/units/anta_tests/.*/data.py$",
"^tests/units/anta_tests/routing/.*/data.py$",
"^docs/scripts/anta_runner.py",
]

View file

@ -35,7 +35,7 @@ def build_test_id(val: dict[str, Any]) -> str:
...
}
"""
return f"{val['test'].__module__}.{val['test'].__name__}-{val['name']}"
return f"{val['test'].module}.{val['test'].__name__}-{val['name']}"
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:

View file

@ -0,0 +1,49 @@
---
all:
children:
cv_servers:
hosts:
cv_atd1:
ansible_host: 10.73.1.238
ansible_user: tom
ansible_password: !unknown |
OOOOOOOK!GNUTERRYPRATCHETT!OOOOOOK!EEEEEEEK!
cv_collection: v3
ATD_LAB:
vars:
ansible_user: arista
ansible_ssh_pass: arista
children:
ATD_FABRIC:
children:
ATD_SPINES:
vars:
type: spine
hosts:
spine1:
ansible_host: 192.168.0.10
spine2:
ansible_host: 192.168.0.11
ATD_LEAFS:
vars:
type: l3leaf
children:
pod1:
hosts:
leaf1:
ansible_host: 192.168.0.12
leaf2:
ansible_host: 192.168.0.13
pod2:
hosts:
leaf3:
ansible_host: 192.168.0.14
leaf4:
ansible_host: 192.168.0.15
ATD_TENANTS_NETWORKS:
children:
ATD_LEAFS:
ATD_SERVERS:
children:
ATD_LEAFS:

View file

@ -0,0 +1,50 @@
---
all:
children:
cv_servers:
hosts:
cv_atd1:
ansible_host: 10.73.1.238
ansible_user: tom
ansible_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
OOOOOOOK!YOURAWESOMEVAULTEDPASSWOR!OOOOOOK!EEEEEEEK!
cv_collection: v3
ATD_LAB:
vars:
ansible_user: arista
ansible_ssh_pass: arista
children:
ATD_FABRIC:
children:
ATD_SPINES:
vars:
type: spine
hosts:
spine1:
ansible_host: 192.168.0.10
spine2:
ansible_host: 192.168.0.11
ATD_LEAFS:
vars:
type: l3leaf
children:
pod1:
hosts:
leaf1:
ansible_host: 192.168.0.12
leaf2:
ansible_host: 192.168.0.13
pod2:
hosts:
leaf3:
ansible_host: 192.168.0.14
leaf4:
ansible_host: 192.168.0.15
ATD_TENANTS_NETWORKS:
children:
ATD_LEAFS:
ATD_SERVERS:
children:
ATD_LEAFS:

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1,103 @@
---
anta_inventory:
hosts:
- host: localhost
name: super-spine1
- host: localhost
name: super-spine2
- host: localhost
name: pod1-spine1
- host: localhost
name: pod1-spine2
- host: localhost
name: pod1-spine3
- host: localhost
name: pod1-spine4
- host: localhost
name: pod1-leaf1a
- host: localhost
name: pod1-leaf1b
- host: localhost
name: pod1-leaf2a
- host: localhost
name: pod1-leaf2b
- host: localhost
name: pod1-leaf3a
- host: localhost
name: pod1-leaf3b
- host: localhost
name: pod1-leaf4a
- host: localhost
name: pod1-leaf4b
- host: localhost
name: pod1-leaf5a
- host: localhost
name: pod1-leaf5b
- host: localhost
name: pod1-leaf6a
- host: localhost
name: pod1-leaf6b
- host: localhost
name: pod1-leaf7a
- host: localhost
name: pod1-leaf7b
- host: localhost
name: pod1-leaf8a
- host: localhost
name: pod1-leaf8b
- host: localhost
name: pod1-leaf9a
- host: localhost
name: pod1-leaf9b
- host: localhost
name: pod1-leaf10a
- host: localhost
name: pod1-leaf10b
- host: localhost
name: pod2-spine1
- host: localhost
name: pod2-spine2
- host: localhost
name: pod2-spine3
- host: localhost
name: pod2-spine4
- host: localhost
name: pod2-leaf1a
- host: localhost
name: pod2-leaf1b
- host: localhost
name: pod2-leaf2a
- host: localhost
name: pod2-leaf2b
- host: localhost
name: pod2-leaf3a
- host: localhost
name: pod2-leaf3b
- host: localhost
name: pod2-leaf4a
- host: localhost
name: pod2-leaf4b
- host: localhost
name: pod2-leaf5a
- host: localhost
name: pod2-leaf5b
- host: localhost
name: pod2-leaf6a
- host: localhost
name: pod2-leaf6b
- host: localhost
name: pod2-leaf7a
- host: localhost
name: pod2-leaf7b
- host: localhost
name: pod2-leaf8a
- host: localhost
name: pod2-leaf8b
- host: localhost
name: pod2-leaf9a
- host: localhost
name: pod2-leaf9b
- host: localhost
name: pod2-leaf10a
- host: localhost
name: pod2-leaf10b

View file

@ -0,0 +1,14 @@
anta_inventory:
hosts:
- host: localhost
name: spine1
- host: localhost
name: spine2
- host: localhost
name: leaf1a
- host: localhost
name: leaf1b
- host: localhost
name: leaf2a
- host: localhost
name: leaf2b

View file

@ -13,7 +13,7 @@ from unittest.mock import patch
import pytest
from click.testing import CliRunner, Result
from anta import aioeapi
import asynceapi
from anta.cli.console import console
from anta.device import AntaDevice, AsyncEOSDevice
from anta.inventory import AntaInventory
@ -33,7 +33,7 @@ DEVICE_HW_MODEL = "pytest"
DEVICE_NAME = "pytest"
COMMAND_OUTPUT = "retrieved"
MOCK_CLI_JSON: dict[str, aioeapi.EapiCommandError | dict[str, Any]] = {
MOCK_CLI_JSON: dict[str, asynceapi.EapiCommandError | dict[str, Any]] = {
"show version": {
"modelName": "DCS-7280CR3-32P4-F",
"version": "4.31.1F",
@ -41,7 +41,7 @@ MOCK_CLI_JSON: dict[str, aioeapi.EapiCommandError | dict[str, Any]] = {
"enable": {},
"clear counters": {},
"clear hardware counter drop": {},
"undefined": aioeapi.EapiCommandError(
"undefined": asynceapi.EapiCommandError(
passed=[],
failed="show version",
errors=["Authorization denied for command 'show version'"],
@ -50,7 +50,7 @@ MOCK_CLI_JSON: dict[str, aioeapi.EapiCommandError | dict[str, Any]] = {
),
}
MOCK_CLI_TEXT: dict[str, aioeapi.EapiCommandError | str] = {
MOCK_CLI_TEXT: dict[str, asynceapi.EapiCommandError | str] = {
"show version": "Arista cEOSLab",
"bash timeout 10 ls -1t /mnt/flash/schedule/tech-support": "dummy_tech-support_2023-12-01.1115.log.gz\ndummy_tech-support_2023-12-01.1015.log.gz",
"bash timeout 10 ls -1t /mnt/flash/schedule/tech-support | head -1": "dummy_tech-support_2023-12-01.1115.log.gz",
@ -62,7 +62,7 @@ MOCK_CLI_TEXT: dict[str, aioeapi.EapiCommandError | str] = {
def device(request: pytest.FixtureRequest) -> Iterator[AntaDevice]:
"""Return an AntaDevice instance with mocked abstract method."""
def _collect(command: AntaCommand) -> None:
def _collect(command: AntaCommand, *args: Any, **kwargs: Any) -> None: # noqa: ARG001, ANN401 #pylint: disable=unused-argument
command.output = COMMAND_OUTPUT
kwargs = {"name": DEVICE_NAME, "hw_model": DEVICE_HW_MODEL}
@ -214,7 +214,7 @@ def click_runner(capsys: pytest.CaptureFixture[str]) -> Iterator[CliRunner]: #
for mock_cmd, output in mock_cli.items():
if command == mock_cmd:
logger.info("Mocking command %s", mock_cmd)
if isinstance(output, aioeapi.EapiCommandError):
if isinstance(output, asynceapi.EapiCommandError):
raise output
return output
message = f"Command '{command}' is not mocked"
@ -231,10 +231,10 @@ def click_runner(capsys: pytest.CaptureFixture[str]) -> Iterator[CliRunner]: #
logger.debug("Mock output %s", res)
return res
# Patch aioeapi methods used by AsyncEOSDevice. See tests/units/test_device.py
# Patch asynceapi methods used by AsyncEOSDevice. See tests/units/test_device.py
with (
patch("aioeapi.device.Device.check_connection", return_value=True),
patch("aioeapi.device.Device.cli", side_effect=cli),
patch("asynceapi.device.Device.check_connection", return_value=True),
patch("asynceapi.device.Device.cli", side_effect=cli),
patch("asyncssh.connect"),
patch(
"asyncssh.scp",

View file

@ -28,7 +28,7 @@ def generate_test_ids(data: list[dict[str, Any]]) -> list[str]:
...
}
"""
return [f"{val['test'].__module__}.{val['test'].__name__}-{val['name']}" for val in data]
return [f"{val['test'].module}.{val['test'].__name__}-{val['name']}" for val in data]
def default_anta_env() -> dict[str, str | None]:

View file

@ -30,6 +30,7 @@ DATA: list[dict[str, Any]] = [
"name": "success",
"test": VerifyBGPPeerCount,
"eos_data": [
# Need to order the output as the commands would be sorted after template rendering.
{
"vrfs": {
"default": {
@ -120,9 +121,10 @@ DATA: list[dict[str, Any]] = [
],
"inputs": {
"address_families": [
# evpn first to make sure that the correct mapping output to input is kept.
{"afi": "evpn", "num_peers": 2},
{"afi": "ipv4", "safi": "unicast", "vrf": "default", "num_peers": 2},
{"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT", "num_peers": 1},
{"afi": "evpn", "num_peers": 2},
{"afi": "link-state", "num_peers": 2},
{"afi": "path-selection", "num_peers": 2},
]
@ -652,9 +654,10 @@ DATA: list[dict[str, Any]] = [
],
"inputs": {
"address_families": [
# Path selection first to make sure input to output mapping is correct.
{"afi": "path-selection"},
{"afi": "ipv4", "safi": "unicast", "vrf": "default"},
{"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT"},
{"afi": "path-selection"},
{"afi": "link-state"},
]
},
@ -1081,6 +1084,8 @@ DATA: list[dict[str, Any]] = [
],
"inputs": {
"address_families": [
# Path selection first to make sure input to output mapping is correct.
{"afi": "path-selection", "peers": ["10.1.255.20", "10.1.255.22"]},
{
"afi": "ipv4",
"safi": "unicast",
@ -1093,7 +1098,6 @@ DATA: list[dict[str, Any]] = [
"vrf": "MGMT",
"peers": ["10.1.255.10", "10.1.255.12"],
},
{"afi": "path-selection", "peers": ["10.1.255.20", "10.1.255.22"]},
{"afi": "link-state", "peers": ["10.1.255.30", "10.1.255.32"]},
]
},

View file

@ -0,0 +1,570 @@
# 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.
"""Tests for anta.tests.routing.ospf.py."""
from __future__ import annotations
from typing import Any
from anta.tests.routing.isis import VerifyISISInterfaceMode, VerifyISISNeighborCount, VerifyISISNeighborState
from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
DATA: list[dict[str, Any]] = [
{
"name": "success only default vrf",
"test": VerifyISISNeighborState,
"eos_data": [
{
"vrfs": {
"default": {
"isisInstances": {
"CORE-ISIS": {
"neighbors": {
"0168.0000.0111": {
"adjacencies": [
{
"hostname": "s1-p01",
"circuitId": "83",
"interfaceName": "Ethernet1",
"state": "up",
"lastHelloTime": 1713688408,
"routerIdV4": "1.0.0.111",
}
]
},
"0168.0000.0112": {
"adjacencies": [
{
"hostname": "s1-p02",
"circuitId": "87",
"interfaceName": "Ethernet2",
"state": "up",
"lastHelloTime": 1713688405,
"routerIdV4": "1.0.0.112",
}
]
},
}
}
}
}
}
},
],
"inputs": None,
"expected": {"result": "success"},
},
{
"name": "success different vrfs",
"test": VerifyISISNeighborState,
"eos_data": [
{
"vrfs": {
"default": {
"isisInstances": {
"CORE-ISIS": {
"neighbors": {
"0168.0000.0111": {
"adjacencies": [
{
"hostname": "s1-p01",
"circuitId": "83",
"interfaceName": "Ethernet1",
"state": "up",
"lastHelloTime": 1713688408,
"routerIdV4": "1.0.0.111",
}
]
},
},
},
},
"customer": {
"isisInstances": {
"CORE-ISIS": {
"neighbors": {
"0168.0000.0112": {
"adjacencies": [
{
"hostname": "s1-p02",
"circuitId": "87",
"interfaceName": "Ethernet2",
"state": "up",
"lastHelloTime": 1713688405,
"routerIdV4": "1.0.0.112",
}
]
}
}
}
}
},
}
}
},
],
"inputs": None,
"expected": {"result": "success"},
},
{
"name": "failure",
"test": VerifyISISNeighborState,
"eos_data": [
{
"vrfs": {
"default": {
"isisInstances": {
"CORE-ISIS": {
"neighbors": {
"0168.0000.0111": {
"adjacencies": [
{
"hostname": "s1-p01",
"circuitId": "83",
"interfaceName": "Ethernet1",
"state": "down",
"lastHelloTime": 1713688408,
"routerIdV4": "1.0.0.111",
}
]
},
"0168.0000.0112": {
"adjacencies": [
{
"hostname": "s1-p02",
"circuitId": "87",
"interfaceName": "Ethernet2",
"state": "up",
"lastHelloTime": 1713688405,
"routerIdV4": "1.0.0.112",
}
]
},
}
}
}
}
}
},
],
"inputs": None,
"expected": {
"result": "failure",
"messages": ["Some neighbors are not in the correct state (UP): [{'vrf': 'default', 'instance': 'CORE-ISIS', 'neighbor': 's1-p01', 'state': 'down'}]."],
},
},
{
"name": "success only default vrf",
"test": VerifyISISNeighborCount,
"eos_data": [
{
"vrfs": {
"default": {
"isisInstances": {
"CORE-ISIS": {
"interfaces": {
"Loopback0": {
"enabled": True,
"intfLevels": {
"2": {
"ipv4Metric": 10,
"sharedSecretProfile": "",
"isisAdjacencies": [],
"passive": True,
"v4Protection": "disabled",
"v6Protection": "disabled",
}
},
"areaProxyBoundary": False,
},
"Ethernet1": {
"intfLevels": {
"2": {
"ipv4Metric": 10,
"numAdjacencies": 1,
"linkId": "84",
"sharedSecretProfile": "",
"isisAdjacencies": [],
"passive": False,
"v4Protection": "link",
"v6Protection": "disabled",
}
},
"interfaceSpeed": 1000,
"areaProxyBoundary": False,
},
"Ethernet2": {
"enabled": True,
"intfLevels": {
"2": {
"ipv4Metric": 10,
"numAdjacencies": 1,
"linkId": "88",
"sharedSecretProfile": "",
"isisAdjacencies": [],
"passive": False,
"v4Protection": "link",
"v6Protection": "disabled",
}
},
"interfaceSpeed": 1000,
"areaProxyBoundary": False,
},
}
}
}
}
}
},
],
"inputs": {
"interfaces": [
{"name": "Ethernet1", "level": 2, "count": 1},
{"name": "Ethernet2", "level": 2, "count": 1},
]
},
"expected": {"result": "success"},
},
{
"name": "success VerifyISISInterfaceMode only default vrf",
"test": VerifyISISInterfaceMode,
"eos_data": [
{
"vrfs": {
"default": {
"isisInstances": {
"CORE-ISIS": {
"interfaces": {
"Loopback0": {
"enabled": True,
"index": 2,
"snpa": "0:0:0:0:0:0",
"mtu": 65532,
"interfaceAddressFamily": "ipv4",
"interfaceType": "loopback",
"intfLevels": {
"2": {
"ipv4Metric": 10,
"sharedSecretProfile": "",
"isisAdjacencies": [],
"passive": True,
"v4Protection": "disabled",
"v6Protection": "disabled",
}
},
"areaProxyBoundary": False,
},
"Ethernet1": {
"enabled": True,
"index": 132,
"snpa": "P2P",
"interfaceType": "point-to-point",
"intfLevels": {
"2": {
"ipv4Metric": 10,
"numAdjacencies": 1,
"linkId": "84",
"sharedSecretProfile": "",
"isisAdjacencies": [],
"passive": False,
"v4Protection": "link",
"v6Protection": "disabled",
}
},
"interfaceSpeed": 1000,
"areaProxyBoundary": False,
},
"Ethernet2": {
"enabled": True,
"interfaceType": "broadcast",
"intfLevels": {
"2": {
"ipv4Metric": 10,
"numAdjacencies": 0,
"sharedSecretProfile": "",
"isisAdjacencies": [],
"passive": True,
"v4Protection": "disabled",
"v6Protection": "disabled",
}
},
"interfaceSpeed": 1000,
"areaProxyBoundary": False,
},
}
}
}
}
}
}
],
"inputs": {
"interfaces": [
{"name": "Loopback0", "mode": "passive"},
{"name": "Ethernet2", "mode": "passive"},
{"name": "Ethernet1", "mode": "point-to-point", "vrf": "default"},
]
},
"expected": {"result": "success"},
},
{
"name": "failure VerifyISISInterfaceMode default vrf with interface not running passive mode",
"test": VerifyISISInterfaceMode,
"eos_data": [
{
"vrfs": {
"default": {
"isisInstances": {
"CORE-ISIS": {
"interfaces": {
"Loopback0": {
"enabled": True,
"index": 2,
"snpa": "0:0:0:0:0:0",
"mtu": 65532,
"interfaceAddressFamily": "ipv4",
"interfaceType": "loopback",
"intfLevels": {
"2": {
"ipv4Metric": 10,
"sharedSecretProfile": "",
"isisAdjacencies": [],
"passive": True,
"v4Protection": "disabled",
"v6Protection": "disabled",
}
},
"areaProxyBoundary": False,
},
"Ethernet1": {
"enabled": True,
"index": 132,
"snpa": "P2P",
"interfaceType": "point-to-point",
"intfLevels": {
"2": {
"ipv4Metric": 10,
"numAdjacencies": 1,
"linkId": "84",
"sharedSecretProfile": "",
"isisAdjacencies": [],
"passive": False,
"v4Protection": "link",
"v6Protection": "disabled",
}
},
"interfaceSpeed": 1000,
"areaProxyBoundary": False,
},
"Ethernet2": {
"enabled": True,
"interfaceType": "point-to-point",
"intfLevels": {
"2": {
"ipv4Metric": 10,
"numAdjacencies": 0,
"sharedSecretProfile": "",
"isisAdjacencies": [],
"passive": False,
"v4Protection": "disabled",
"v6Protection": "disabled",
}
},
"interfaceSpeed": 1000,
"areaProxyBoundary": False,
},
}
}
}
}
}
}
],
"inputs": {
"interfaces": [
{"name": "Loopback0", "mode": "passive"},
{"name": "Ethernet2", "mode": "passive"},
{"name": "Ethernet1", "mode": "point-to-point", "vrf": "default"},
]
},
"expected": {
"result": "failure",
"messages": ["Interface Ethernet2 in VRF default is not running in passive mode"],
},
},
{
"name": "failure VerifyISISInterfaceMode default vrf with interface not running point-point mode",
"test": VerifyISISInterfaceMode,
"eos_data": [
{
"vrfs": {
"default": {
"isisInstances": {
"CORE-ISIS": {
"interfaces": {
"Loopback0": {
"enabled": True,
"index": 2,
"snpa": "0:0:0:0:0:0",
"mtu": 65532,
"interfaceAddressFamily": "ipv4",
"interfaceType": "loopback",
"intfLevels": {
"2": {
"ipv4Metric": 10,
"sharedSecretProfile": "",
"isisAdjacencies": [],
"passive": True,
"v4Protection": "disabled",
"v6Protection": "disabled",
}
},
"areaProxyBoundary": False,
},
"Ethernet1": {
"enabled": True,
"index": 132,
"snpa": "P2P",
"interfaceType": "broadcast",
"intfLevels": {
"2": {
"ipv4Metric": 10,
"numAdjacencies": 1,
"linkId": "84",
"sharedSecretProfile": "",
"isisAdjacencies": [],
"passive": False,
"v4Protection": "link",
"v6Protection": "disabled",
}
},
"interfaceSpeed": 1000,
"areaProxyBoundary": False,
},
"Ethernet2": {
"enabled": True,
"interfaceType": "broadcast",
"intfLevels": {
"2": {
"ipv4Metric": 10,
"numAdjacencies": 0,
"sharedSecretProfile": "",
"isisAdjacencies": [],
"passive": True,
"v4Protection": "disabled",
"v6Protection": "disabled",
}
},
"interfaceSpeed": 1000,
"areaProxyBoundary": False,
},
}
}
}
}
}
}
],
"inputs": {
"interfaces": [
{"name": "Loopback0", "mode": "passive"},
{"name": "Ethernet2", "mode": "passive"},
{"name": "Ethernet1", "mode": "point-to-point", "vrf": "default"},
]
},
"expected": {
"result": "failure",
"messages": ["Interface Ethernet1 in VRF default is not running in point-to-point reporting broadcast"],
},
},
{
"name": "failure VerifyISISInterfaceMode default vrf with interface not running correct VRF mode",
"test": VerifyISISInterfaceMode,
"eos_data": [
{
"vrfs": {
"fake_vrf": {
"isisInstances": {
"CORE-ISIS": {
"interfaces": {
"Loopback0": {
"enabled": True,
"index": 2,
"snpa": "0:0:0:0:0:0",
"mtu": 65532,
"interfaceAddressFamily": "ipv4",
"interfaceType": "loopback",
"intfLevels": {
"2": {
"ipv4Metric": 10,
"sharedSecretProfile": "",
"isisAdjacencies": [],
"passive": True,
"v4Protection": "disabled",
"v6Protection": "disabled",
}
},
"areaProxyBoundary": False,
},
"Ethernet1": {
"enabled": True,
"index": 132,
"snpa": "P2P",
"interfaceType": "point-to-point",
"intfLevels": {
"2": {
"ipv4Metric": 10,
"numAdjacencies": 1,
"linkId": "84",
"sharedSecretProfile": "",
"isisAdjacencies": [],
"passive": False,
"v4Protection": "link",
"v6Protection": "disabled",
}
},
"interfaceSpeed": 1000,
"areaProxyBoundary": False,
},
"Ethernet2": {
"enabled": True,
"interfaceType": "broadcast",
"intfLevels": {
"2": {
"ipv4Metric": 10,
"numAdjacencies": 0,
"sharedSecretProfile": "",
"isisAdjacencies": [],
"passive": True,
"v4Protection": "disabled",
"v6Protection": "disabled",
}
},
"interfaceSpeed": 1000,
"areaProxyBoundary": False,
},
}
}
}
}
}
}
],
"inputs": {
"interfaces": [
{"name": "Loopback0", "mode": "passive"},
{"name": "Ethernet2", "mode": "passive"},
{"name": "Ethernet1", "mode": "point-to-point", "vrf": "default"},
]
},
"expected": {
"result": "failure",
"messages": [
"Interface Loopback0 not found in VRF default",
"Interface Ethernet2 not found in VRF default",
"Interface Ethernet1 not found in VRF default",
],
},
},
]

View file

@ -271,6 +271,18 @@ DATA: list[dict[str, Any]] = [
"inputs": {"methods": ["tacacs+", "local"], "types": ["commands", "exec"]},
"expected": {"result": "success"},
},
{
"name": "success-skipping-exec",
"test": VerifyAuthzMethods,
"eos_data": [
{
"commandsAuthzMethods": {"privilege0-15": {"methods": ["group tacacs+", "local"]}},
"execAuthzMethods": {"exec": {"methods": ["group tacacs+", "local"]}},
},
],
"inputs": {"methods": ["tacacs+", "local"], "types": ["commands"]},
"expected": {"result": "success"},
},
{
"name": "failure-commands",
"test": VerifyAuthzMethods,

View file

@ -0,0 +1,581 @@
# 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.
"""Tests for anta.tests.avt.py."""
from __future__ import annotations
from typing import Any
from anta.tests.avt import VerifyAVTPathHealth, VerifyAVTRole, VerifyAVTSpecificPath
from tests.lib.anta import test # noqa: F401; pylint: disable=unused-import
DATA: list[dict[str, Any]] = [
{
"name": "success",
"test": VerifyAVTPathHealth,
"eos_data": [
{
"vrfs": {
"data": {
"avts": {
"DATA-AVT-POLICY-DEFAULT": {
"avtPaths": {
"direct:9": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:10": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:1": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:8": {
"flags": {"directPath": True, "valid": True, "active": True},
},
}
}
}
},
"guest": {
"avts": {
"GUEST-AVT-POLICY-DEFAULT": {
"avtPaths": {
"direct:10": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:8": {
"flags": {"directPath": True, "valid": True, "active": True},
},
}
}
}
},
"default": {
"avts": {
"CONTROL-PLANE-PROFILE": {
"avtPaths": {
"direct:9": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:10": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:1": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:8": {
"flags": {"directPath": True, "valid": True, "active": True},
},
}
},
"DEFAULT-AVT-POLICY-DEFAULT": {
"avtPaths": {
"direct:10": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:8": {
"flags": {"directPath": True, "valid": True, "active": True},
},
}
},
}
},
}
}
],
"inputs": {},
"expected": {"result": "success"},
},
{
"name": "failure-avt-not-configured",
"test": VerifyAVTPathHealth,
"eos_data": [{"vrfs": {}}],
"inputs": {},
"expected": {
"result": "failure",
"messages": ["Adaptive virtual topology paths are not configured."],
},
},
{
"name": "failure-not-active-path",
"test": VerifyAVTPathHealth,
"eos_data": [
{
"vrfs": {
"data": {
"avts": {
"DATA-AVT-POLICY-DEFAULT": {
"avtPaths": {
"direct:9": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:10": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:1": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:8": {
"flags": {"directPath": True, "valid": True, "active": True},
},
}
}
}
},
"guest": {
"avts": {
"GUEST-AVT-POLICY-DEFAULT": {
"avtPaths": {
"direct:10": {
"flags": {"directPath": True, "valid": True, "active": False},
},
"direct:8": {
"flags": {"directPath": True, "valid": True, "active": True},
},
}
}
}
},
"default": {
"avts": {
"CONTROL-PLANE-PROFILE": {
"avtPaths": {
"direct:9": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:10": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:1": {
"flags": {"directPath": True, "valid": True, "active": False},
},
"direct:8": {
"flags": {"directPath": True, "valid": True, "active": True},
},
}
},
"DEFAULT-AVT-POLICY-DEFAULT": {
"avtPaths": {
"direct:10": {
"flags": {"directPath": True, "valid": True, "active": False},
},
"direct:8": {
"flags": {"directPath": True, "valid": True, "active": True},
},
}
},
}
},
}
}
],
"inputs": {},
"expected": {
"result": "failure",
"messages": [
"AVT path direct:10 for profile GUEST-AVT-POLICY-DEFAULT in VRF guest is not active.",
"AVT path direct:1 for profile CONTROL-PLANE-PROFILE in VRF default is not active.",
"AVT path direct:10 for profile DEFAULT-AVT-POLICY-DEFAULT in VRF default is not active.",
],
},
},
{
"name": "failure-invalid-path",
"test": VerifyAVTPathHealth,
"eos_data": [
{
"vrfs": {
"data": {
"avts": {
"DATA-AVT-POLICY-DEFAULT": {
"avtPaths": {
"direct:9": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:10": {
"flags": {"directPath": True, "valid": False, "active": True},
},
"direct:1": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:8": {
"flags": {"directPath": True, "valid": True, "active": True},
},
}
}
}
},
"guest": {
"avts": {
"GUEST-AVT-POLICY-DEFAULT": {
"avtPaths": {
"direct:10": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:8": {
"flags": {"directPath": True, "valid": False, "active": True},
},
}
}
}
},
"default": {
"avts": {
"CONTROL-PLANE-PROFILE": {
"avtPaths": {
"direct:9": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:10": {
"flags": {"directPath": True, "valid": False, "active": True},
},
"direct:1": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:8": {
"flags": {"directPath": True, "valid": True, "active": True},
},
}
},
"DEFAULT-AVT-POLICY-DEFAULT": {
"avtPaths": {
"direct:10": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:8": {
"flags": {"directPath": True, "valid": False, "active": True},
},
}
},
}
},
}
}
],
"inputs": {},
"expected": {
"result": "failure",
"messages": [
"AVT path direct:10 for profile DATA-AVT-POLICY-DEFAULT in VRF data is invalid.",
"AVT path direct:8 for profile GUEST-AVT-POLICY-DEFAULT in VRF guest is invalid.",
"AVT path direct:10 for profile CONTROL-PLANE-PROFILE in VRF default is invalid.",
"AVT path direct:8 for profile DEFAULT-AVT-POLICY-DEFAULT in VRF default is invalid.",
],
},
},
{
"name": "failure-not-active-and-invalid",
"test": VerifyAVTPathHealth,
"eos_data": [
{
"vrfs": {
"data": {
"avts": {
"DATA-AVT-POLICY-DEFAULT": {
"avtPaths": {
"direct:9": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:10": {
"flags": {"directPath": True, "valid": False, "active": False},
},
"direct:1": {
"flags": {"directPath": True, "valid": True, "active": False},
},
"direct:8": {
"flags": {"directPath": True, "valid": True, "active": True},
},
}
}
}
},
"guest": {
"avts": {
"GUEST-AVT-POLICY-DEFAULT": {
"avtPaths": {
"direct:10": {
"flags": {"directPath": True, "valid": False, "active": True},
},
"direct:8": {
"flags": {"directPath": True, "valid": False, "active": False},
},
}
}
}
},
"default": {
"avts": {
"CONTROL-PLANE-PROFILE": {
"avtPaths": {
"direct:9": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:10": {
"flags": {"directPath": True, "valid": False, "active": False},
},
"direct:1": {
"flags": {"directPath": True, "valid": True, "active": True},
},
"direct:8": {
"flags": {"directPath": True, "valid": True, "active": True},
},
}
},
"DEFAULT-AVT-POLICY-DEFAULT": {
"avtPaths": {
"direct:10": {
"flags": {"directPath": True, "valid": True, "active": False},
},
"direct:8": {
"flags": {"directPath": True, "valid": False, "active": False},
},
}
},
}
},
}
}
],
"inputs": {},
"expected": {
"result": "failure",
"messages": [
"AVT path direct:10 for profile DATA-AVT-POLICY-DEFAULT in VRF data is invalid and not active.",
"AVT path direct:1 for profile DATA-AVT-POLICY-DEFAULT in VRF data is not active.",
"AVT path direct:10 for profile GUEST-AVT-POLICY-DEFAULT in VRF guest is invalid.",
"AVT path direct:8 for profile GUEST-AVT-POLICY-DEFAULT in VRF guest is invalid and not active.",
"AVT path direct:10 for profile CONTROL-PLANE-PROFILE in VRF default is invalid and not active.",
"AVT path direct:10 for profile DEFAULT-AVT-POLICY-DEFAULT in VRF default is not active.",
"AVT path direct:8 for profile DEFAULT-AVT-POLICY-DEFAULT in VRF default is invalid and not active.",
],
},
},
{
"name": "success",
"test": VerifyAVTSpecificPath,
"eos_data": [
{
"vrfs": {
"default": {
"avts": {
"DEFAULT-AVT-POLICY-CONTROL-PLANE": {
"avtPaths": {
"direct:10": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.1"},
"direct:9": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.1"},
"multihop:1": {"flags": {"directPath": False, "valid": True, "active": True}, "nexthopAddr": "10.101.255.1"},
"multihop:3": {"flags": {"directPath": False, "valid": True, "active": True}, "nexthopAddr": "10.101.255.1"},
}
}
}
}
}
},
{
"vrfs": {
"data": {
"avts": {
"DATA-AVT-POLICY-CONTROL-PLANE": {
"avtPaths": {
"direct:10": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.1"},
"direct:9": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.1"},
"direct:8": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.2"},
"multihop:1": {"flags": {"directPath": False, "valid": True, "active": True}, "nexthopAddr": "10.101.255.2"},
"multihop:3": {"flags": {"directPath": False, "valid": True, "active": True}, "nexthopAddr": "10.101.255.2"},
}
}
}
}
}
},
{
"vrfs": {
"data": {
"avts": {
"DATA-AVT-POLICY-CONTROL-PLANE": {
"avtPaths": {
"direct:10": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.1"},
"direct:9": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.1"},
"direct:8": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.2"},
"multihop:1": {"flags": {"directPath": False, "valid": True, "active": True}, "nexthopAddr": "10.101.255.2"},
"multihop:3": {"flags": {"directPath": False, "valid": True, "active": True}, "nexthopAddr": "10.101.255.2"},
}
}
}
}
}
},
],
"inputs": {
"avt_paths": [
{"avt_name": "DEFAULT-AVT-POLICY-CONTROL-PLANE", "destination": "10.101.255.2", "next_hop": "10.101.255.1", "path_type": "multihop"},
{"avt_name": "DATA-AVT-POLICY-CONTROL-PLANE", "vrf": "data", "destination": "10.101.255.1", "next_hop": "10.101.255.2", "path_type": "direct"},
{"avt_name": "DATA-AVT-POLICY-CONTROL-PLANE", "vrf": "data", "destination": "10.101.255.1", "next_hop": "10.101.255.2"},
]
},
"expected": {"result": "success"},
},
{
"name": "failure-no-peer",
"test": VerifyAVTSpecificPath,
"eos_data": [
{"vrfs": {}},
{
"vrfs": {
"data": {
"avts": {
"DATA-AVT-POLICY-CONTROL-PLANE": {
"avtPaths": {
"direct:10": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.1"},
"direct:9": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.1"},
"multihop:1": {"flags": {"directPath": False, "valid": True, "active": True}, "nexthopAddr": "10.101.255.2"},
"multihop:3": {"flags": {"directPath": False, "valid": True, "active": True}, "nexthopAddr": "10.101.255.2"},
}
}
}
}
}
},
],
"inputs": {
"avt_paths": [
{"avt_name": "MGMT-AVT-POLICY-DEFAULT", "vrf": "default", "destination": "10.101.255.2", "next_hop": "10.101.255.1", "path_type": "multihop"},
{"avt_name": "DATA-AVT-POLICY-CONTROL-PLANE", "vrf": "data", "destination": "10.101.255.1", "next_hop": "10.101.255.2", "path_type": "multihop"},
]
},
"expected": {
"result": "failure",
"messages": ["AVT configuration for peer '10.101.255.2' under topology 'MGMT-AVT-POLICY-DEFAULT' in VRF 'default' is not found."],
},
},
{
"name": "failure-no-path-with-correct-next-hop",
"test": VerifyAVTSpecificPath,
"eos_data": [
{
"vrfs": {
"default": {
"avts": {
"DEFAULT-AVT-POLICY-CONTROL-PLANE": {
"avtPaths": {
"direct:10": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.2"},
"direct:9": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.2"},
"multihop:1": {"flags": {"directPath": False, "valid": True, "active": True}, "nexthopAddr": "10.101.255.1"},
"multihop:3": {"flags": {"directPath": False, "valid": True, "active": True}, "nexthopAddr": "10.101.255.1"},
}
}
}
}
}
},
{
"vrfs": {
"data": {
"avts": {
"DATA-AVT-POLICY-CONTROL-PLANE": {
"avtPaths": {
"direct:10": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.1"},
"direct:9": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.1"},
"multihop:1": {"flags": {"directPath": False, "valid": True, "active": True}, "nexthopAddr": "10.101.255.2"},
"multihop:3": {"flags": {"directPath": False, "valid": True, "active": True}, "nexthopAddr": "10.101.255.2"},
}
}
}
}
}
},
],
"inputs": {
"avt_paths": [
{
"avt_name": "DEFAULT-AVT-POLICY-CONTROL-PLANE",
"vrf": "default",
"destination": "10.101.255.2",
"next_hop": "10.101.255.11",
"path_type": "multihop",
},
{"avt_name": "DATA-AVT-POLICY-CONTROL-PLANE", "vrf": "data", "destination": "10.101.255.1", "next_hop": "10.101.255.21", "path_type": "direct"},
]
},
"expected": {
"result": "failure",
"messages": [
"No 'multihop' path found with next-hop address '10.101.255.11' for AVT peer '10.101.255.2' under "
"topology 'DEFAULT-AVT-POLICY-CONTROL-PLANE' in VRF 'default'.",
"No 'direct' path found with next-hop address '10.101.255.21' for AVT peer '10.101.255.1' under "
"topology 'DATA-AVT-POLICY-CONTROL-PLANE' in VRF 'data'.",
],
},
},
{
"name": "failure-incorrect-path",
"test": VerifyAVTSpecificPath,
"eos_data": [
{
"vrfs": {
"default": {
"avts": {
"DEFAULT-AVT-POLICY-CONTROL-PLANE": {
"avtPaths": {
"direct:10": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.2"},
"direct:9": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.2"},
"multihop:1": {"flags": {"directPath": True, "valid": False, "active": False}, "nexthopAddr": "10.101.255.1"},
"multihop:3": {"flags": {"directPath": False, "valid": True, "active": False}, "nexthopAddr": "10.101.255.1"},
}
}
}
}
}
},
{
"vrfs": {
"data": {
"avts": {
"DATA-AVT-POLICY-CONTROL-PLANE": {
"avtPaths": {
"direct:10": {"flags": {"directPath": True, "valid": True, "active": True}, "nexthopAddr": "10.101.255.1"},
"direct:9": {"flags": {"directPath": True, "valid": False, "active": True}, "nexthopAddr": "10.101.255.1"},
"multihop:1": {"flags": {"directPath": False, "valid": True, "active": True}, "nexthopAddr": "10.101.255.2"},
"multihop:3": {"flags": {"directPath": False, "valid": True, "active": True}, "nexthopAddr": "10.101.255.2"},
}
}
}
}
}
},
],
"inputs": {
"avt_paths": [
{
"avt_name": "DEFAULT-AVT-POLICY-CONTROL-PLANE",
"vrf": "default",
"destination": "10.101.255.2",
"next_hop": "10.101.255.1",
"path_type": "multihop",
},
{"avt_name": "DATA-AVT-POLICY-CONTROL-PLANE", "vrf": "data", "destination": "10.101.255.1", "next_hop": "10.101.255.1", "path_type": "direct"},
]
},
"expected": {
"result": "failure",
"messages": [
"AVT path 'multihop:3' for topology 'DEFAULT-AVT-POLICY-CONTROL-PLANE' in VRF 'default' is inactive.",
"AVT path 'direct:9' for topology 'DATA-AVT-POLICY-CONTROL-PLANE' in VRF 'data' is invalid.",
],
},
},
{
"name": "success",
"test": VerifyAVTRole,
"eos_data": [{"role": "edge"}],
"inputs": {"role": "edge"},
"expected": {"result": "success"},
},
{
"name": "failure-incorrect-role",
"test": VerifyAVTRole,
"eos_data": [{"role": "transit"}],
"inputs": {"role": "edge"},
"expected": {"result": "failure", "messages": ["Expected AVT role as `edge`, but found `transit` instead."]},
},
]

View file

@ -7,7 +7,7 @@ from __future__ import annotations
from typing import Any
from anta.tests.configuration import VerifyRunningConfigDiffs, VerifyZeroTouch
from anta.tests.configuration import VerifyRunningConfigDiffs, VerifyRunningConfigLines, VerifyZeroTouch
from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
DATA: list[dict[str, Any]] = [
@ -32,5 +32,42 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {"result": "success"},
},
{"name": "failure", "test": VerifyRunningConfigDiffs, "eos_data": ["blah blah"], "inputs": None, "expected": {"result": "failure", "messages": ["blah blah"]}},
{
"name": "failure",
"test": VerifyRunningConfigDiffs,
"eos_data": ["blah blah"],
"inputs": None,
"expected": {"result": "failure", "messages": ["blah blah"]},
},
{
"name": "success",
"test": VerifyRunningConfigLines,
"eos_data": ["blah blah"],
"inputs": {"regex_patterns": ["blah"]},
"expected": {"result": "success"},
},
{
"name": "success",
"test": VerifyRunningConfigLines,
"eos_data": ["enable password something\nsome other line"],
"inputs": {"regex_patterns": ["^enable password .*$", "^.*other line$"]},
"expected": {"result": "success"},
},
{
"name": "failure",
"test": VerifyRunningConfigLines,
"eos_data": ["enable password something\nsome other line"],
"inputs": {"regex_patterns": ["bla", "bleh"]},
"expected": {"result": "failure", "messages": ["Following patterns were not found: 'bla','bleh'"]},
},
{
"name": "failure-invalid-regex",
"test": VerifyRunningConfigLines,
"eos_data": ["enable password something\nsome other line"],
"inputs": {"regex_patterns": ["["]},
"expected": {
"result": "error",
"messages": ["1 validation error for Input\nregex_patterns.0\n Value error, Invalid regex: unterminated character set at position 0"],
},
},
]

View file

@ -21,7 +21,7 @@ DATA: list[dict[str, Any]] = [
"modelName": "DCS-7280QRA-C36S",
"details": {
"deviations": [],
"components": [{"name": "Aboot", "version": "Aboot-veos-8.0.0-3255441"}],
"components": [{"name": "Aboot", "version": "Aboot-veos-8.0.0-3255441"}, {"name": "NotAboot", "version": "Aboot-veos-8.0.0-3255441"}],
},
},
],
@ -128,6 +128,26 @@ DATA: list[dict[str, Any]] = [
"messages": ["device is not impacted by FN044"],
},
},
{
"name": "failure-no-aboot-component",
"test": VerifyFieldNotice44Resolution,
"eos_data": [
{
"imageFormatVersion": "1.0",
"uptime": 1109144.35,
"modelName": "DCS-7280QRA-C36S",
"details": {
"deviations": [],
"components": [{"name": "NotAboot", "version": "Aboot-veos-4.0.1-3255441"}],
},
},
],
"inputs": None,
"expected": {
"result": "failure",
"messages": ["Aboot component not found"],
},
},
{
"name": "success-JPE",
"test": VerifyFieldNotice72Resolution,

View file

@ -1,7 +1,7 @@
# Copyright (c) 2023-2024 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Test inputs for anta.tests.hardware."""
"""Test inputs for anta.tests.interfaces."""
# pylint: disable=C0302
from __future__ import annotations
@ -14,6 +14,7 @@ from anta.tests.interfaces import (
VerifyInterfaceErrDisabled,
VerifyInterfaceErrors,
VerifyInterfaceIPv4,
VerifyInterfacesSpeed,
VerifyInterfacesStatus,
VerifyInterfaceUtilization,
VerifyIPProxyARP,
@ -1354,6 +1355,14 @@ DATA: list[dict[str, Any]] = [
"lineProtocolStatus": "up",
"mtu": 65535,
},
# Checking not loopbacks are skipped
"Ethernet666": {
"name": "Ethernet666",
"interfaceStatus": "connected",
"interfaceAddress": {"ipAddr": {"maskLen": 32, "address": "6.6.6.6"}},
"ipv4Routable240": False,
"lineProtocolStatus": "up",
},
},
},
],
@ -1733,7 +1742,7 @@ DATA: list[dict[str, Any]] = [
},
},
],
"inputs": {"mtu": 9214},
"inputs": {"mtu": 9214, "ignored_interfaces": ["Loopback", "Port-Channel", "Management", "Vxlan"], "specific_mtu": [{"Ethernet10": 9214}]},
"expected": {"result": "success"},
},
{
@ -2157,4 +2166,279 @@ DATA: list[dict[str, Any]] = [
"inputs": {"mac_address": "00:1c:73:00:dc:01"},
"expected": {"result": "failure", "messages": ["IP virtual router MAC address `00:1c:73:00:dc:01` is not configured."]},
},
{
"name": "success",
"test": VerifyInterfacesSpeed,
"eos_data": [
{
"interfaces": {
"Ethernet1": {
"bandwidth": 1000000000,
"autoNegotiate": "unknown",
"duplex": "duplexFull",
"lanes": 2,
},
"Ethernet1/1/2": {
"bandwidth": 1000000000,
"autoNegotiate": "unknown",
"duplex": "duplexFull",
"lanes": 2,
},
"Ethernet3": {
"bandwidth": 100000000000,
"autoNegotiate": "success",
"duplex": "duplexFull",
"lanes": 8,
},
"Ethernet4": {
"bandwidth": 2500000000,
"autoNegotiate": "unknown",
"duplex": "duplexFull",
"lanes": 8,
},
}
}
],
"inputs": {
"interfaces": [
{"name": "Ethernet1", "auto": False, "speed": 1},
{"name": "Ethernet1", "auto": False, "speed": 1, "lanes": 2},
{"name": "Ethernet1/1/2", "auto": False, "speed": 1},
{"name": "Ethernet3", "auto": True, "speed": 100},
{"name": "Ethernet3", "auto": True, "speed": 100, "lanes": 8},
{"name": "Ethernet3", "auto": True, "speed": 100},
{"name": "Ethernet4", "auto": False, "speed": 2.5},
]
},
"expected": {"result": "success"},
},
{
"name": "failure-incorrect-speed",
"test": VerifyInterfacesSpeed,
"eos_data": [
{
"interfaces": {
"Ethernet1": {
"bandwidth": 100000000000,
"autoNegotiate": "unknown",
"duplex": "duplexFull",
"lanes": 2,
},
"Ethernet1/1/1": {
"bandwidth": 100000000000,
"autoNegotiate": "unknown",
"duplex": "duplexFull",
"lanes": 2,
},
"Ethernet3": {
"bandwidth": 10000000000,
"autoNegotiate": "success",
"duplex": "duplexFull",
"lanes": 8,
},
"Ethernet4": {
"bandwidth": 25000000000,
"autoNegotiate": "unknown",
"duplex": "duplexFull",
"lanes": 8,
},
}
}
],
"inputs": {
"interfaces": [
{"name": "Ethernet1", "auto": False, "speed": 1},
{"name": "Ethernet1/1/1", "auto": False, "speed": 1},
{"name": "Ethernet3", "auto": True, "speed": 100},
{"name": "Ethernet4", "auto": False, "speed": 2.5},
]
},
"expected": {
"result": "failure",
"messages": [
"For interface Ethernet1:\nExpected `1Gbps` as the speed, but found `100Gbps` instead.",
"For interface Ethernet1/1/1:\nExpected `1Gbps` as the speed, but found `100Gbps` instead.",
"For interface Ethernet3:\nExpected `100Gbps` as the speed, but found `10Gbps` instead.",
"For interface Ethernet4:\nExpected `2.5Gbps` as the speed, but found `25Gbps` instead.",
],
},
},
{
"name": "failure-incorrect-mode",
"test": VerifyInterfacesSpeed,
"eos_data": [
{
"interfaces": {
"Ethernet1": {
"bandwidth": 1000000000,
"autoNegotiate": "unknown",
"duplex": "duplexHalf",
"lanes": 2,
},
"Ethernet1/2/2": {
"bandwidth": 1000000000,
"autoNegotiate": "unknown",
"duplex": "duplexHalf",
"lanes": 2,
},
"Ethernet3": {
"bandwidth": 100000000000,
"autoNegotiate": "success",
"duplex": "duplexHalf",
"lanes": 8,
},
"Ethernet4": {
"bandwidth": 2500000000,
"autoNegotiate": "unknown",
"duplex": "duplexHalf",
"lanes": 8,
},
}
}
],
"inputs": {
"interfaces": [
{"name": "Ethernet1", "auto": False, "speed": 1},
{"name": "Ethernet1/2/2", "auto": False, "speed": 1},
{"name": "Ethernet3", "auto": True, "speed": 100},
{"name": "Ethernet3", "auto": True, "speed": 100, "lanes": 8},
{"name": "Ethernet4", "auto": False, "speed": 2.5},
]
},
"expected": {
"result": "failure",
"messages": [
"For interface Ethernet1:\nExpected `duplexFull` as the duplex mode, but found `duplexHalf` instead.",
"For interface Ethernet1/2/2:\nExpected `duplexFull` as the duplex mode, but found `duplexHalf` instead.",
"For interface Ethernet3:\nExpected `duplexFull` as the duplex mode, but found `duplexHalf` instead.",
"For interface Ethernet3:\nExpected `duplexFull` as the duplex mode, but found `duplexHalf` instead.",
"For interface Ethernet4:\nExpected `duplexFull` as the duplex mode, but found `duplexHalf` instead.",
],
},
},
{
"name": "failure-incorrect-lane",
"test": VerifyInterfacesSpeed,
"eos_data": [
{
"interfaces": {
"Ethernet1": {
"bandwidth": 1000000000,
"autoNegotiate": "unknown",
"duplex": "duplexFull",
"lanes": 4,
},
"Ethernet2": {
"bandwidth": 10000000000,
"autoNegotiate": "unknown",
"duplex": "duplexFull",
"lanes": 4,
},
"Ethernet3": {
"bandwidth": 100000000000,
"autoNegotiate": "success",
"duplex": "duplexFull",
"lanes": 4,
},
"Ethernet4": {
"bandwidth": 2500000000,
"autoNegotiate": "unknown",
"duplex": "duplexFull",
"lanes": 6,
},
"Ethernet4/1/1": {
"bandwidth": 2500000000,
"autoNegotiate": "unknown",
"duplex": "duplexFull",
"lanes": 6,
},
}
}
],
"inputs": {
"interfaces": [
{"name": "Ethernet1", "auto": False, "speed": 1, "lanes": 2},
{"name": "Ethernet3", "auto": True, "speed": 100, "lanes": 8},
{"name": "Ethernet4", "auto": False, "speed": 2.5, "lanes": 4},
{"name": "Ethernet4/1/1", "auto": False, "speed": 2.5, "lanes": 4},
]
},
"expected": {
"result": "failure",
"messages": [
"For interface Ethernet1:\nExpected `2` as the lanes, but found `4` instead.",
"For interface Ethernet3:\nExpected `8` as the lanes, but found `4` instead.",
"For interface Ethernet4:\nExpected `4` as the lanes, but found `6` instead.",
"For interface Ethernet4/1/1:\nExpected `4` as the lanes, but found `6` instead.",
],
},
},
{
"name": "failure-all-type",
"test": VerifyInterfacesSpeed,
"eos_data": [
{
"interfaces": {
"Ethernet1": {
"bandwidth": 10000000000,
"autoNegotiate": "unknown",
"duplex": "duplexHalf",
"lanes": 4,
},
"Ethernet2/1/2": {
"bandwidth": 1000000000,
"autoNegotiate": "unknown",
"duplex": "duplexHalf",
"lanes": 2,
},
"Ethernet3": {
"bandwidth": 10000000000,
"autoNegotiate": "unknown",
"duplex": "duplexHalf",
"lanes": 6,
},
"Ethernet4": {
"bandwidth": 25000000000,
"autoNegotiate": "unknown",
"duplex": "duplexHalf",
"lanes": 4,
},
}
}
],
"inputs": {
"interfaces": [
{"name": "Ethernet1", "auto": False, "speed": 1},
{"name": "Ethernet1", "auto": False, "speed": 1, "lanes": 2},
{"name": "Ethernet2/1/2", "auto": False, "speed": 10},
{"name": "Ethernet3", "auto": True, "speed": 1},
{"name": "Ethernet3", "auto": True, "speed": 100, "lanes": 8},
{"name": "Ethernet3", "auto": True, "speed": 100},
{"name": "Ethernet4", "auto": False, "speed": 2.5},
]
},
"expected": {
"result": "failure",
"messages": [
"For interface Ethernet1:\nExpected `duplexFull` as the duplex mode, but found `duplexHalf` instead.\n"
"Expected `1Gbps` as the speed, but found `10Gbps` instead.",
"For interface Ethernet1:\nExpected `duplexFull` as the duplex mode, but found `duplexHalf` instead.\n"
"Expected `1Gbps` as the speed, but found `10Gbps` instead.\n"
"Expected `2` as the lanes, but found `4` instead.",
"For interface Ethernet2/1/2:\nExpected `duplexFull` as the duplex mode, but found `duplexHalf` instead.\n"
"Expected `10Gbps` as the speed, but found `1Gbps` instead.",
"For interface Ethernet3:\nExpected `success` as the auto negotiation, but found `unknown` instead.\n"
"Expected `duplexFull` as the duplex mode, but found `duplexHalf` instead.",
"For interface Ethernet3:\nExpected `success` as the auto negotiation, but found `unknown` instead.\n"
"Expected `duplexFull` as the duplex mode, but found `duplexHalf` instead.\n"
"Expected `100Gbps` as the speed, but found `10Gbps` instead.\n"
"Expected `8` as the lanes, but found `6` instead.",
"For interface Ethernet3:\nExpected `success` as the auto negotiation, but found `unknown` instead.\n"
"Expected `duplexFull` as the duplex mode, but found `duplexHalf` instead.\n"
"Expected `100Gbps` as the speed, but found `10Gbps` instead.",
"For interface Ethernet4:\nExpected `duplexFull` as the duplex mode, but found `duplexHalf` instead.\n"
"Expected `2.5Gbps` as the speed, but found `25Gbps` instead.",
],
},
},
]

View file

@ -206,7 +206,9 @@ DATA: list[dict[str, Any]] = [
"eos_data": [
"",
"2023-05-10T15:41:44.680813-05:00 NW-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_INFO: "
"Message from arista on command-api (10.22.1.107): ANTA VerifyLoggingTimestamp validation\n",
"Message from arista on command-api (10.22.1.107): ANTA VerifyLoggingTimestamp validation\n"
"2023-05-10T15:42:44.680813-05:00 NW-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_INFO: "
"Other log\n",
],
"inputs": None,
"expected": {"result": "success"},
@ -222,6 +224,16 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {"result": "failure", "messages": ["Logs are not generated with the appropriate timestamp format"]},
},
{
"name": "failure-no-matching-log",
"test": VerifyLoggingTimestamp,
"eos_data": [
"",
"May 10 13:54:22 NE-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_INFO: Message from arista on command-api (10.22.1.107): BLAH\n",
],
"inputs": None,
"expected": {"result": "failure", "messages": ["Logs are not generated with the appropriate timestamp format"]},
},
{
"name": "success",
"test": VerifyLoggingAccounting,

View file

@ -0,0 +1,327 @@
# 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.
"""Tests for anta.tests.path_selection.py."""
from __future__ import annotations
from typing import Any
from anta.tests.path_selection import VerifyPathsHealth, VerifySpecificPath
from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
DATA: list[dict[str, Any]] = [
{
"name": "success",
"test": VerifyPathsHealth,
"eos_data": [
{
"dpsPeers": {
"10.255.0.1": {
"dpsGroups": {
"internet": {
"dpsPaths": {
"path3": {"state": "routeResolved", "dpsSessions": {"0": {"active": True}}},
},
},
"mpls": {
"dpsPaths": {
"path4": {"state": "ipsecEstablished", "dpsSessions": {"0": {"active": True}}},
},
},
},
},
"10.255.0.2": {
"dpsGroups": {
"internet": {
"dpsPaths": {
"path1": {"state": "ipsecEstablished", "dpsSessions": {"0": {"active": True}}},
},
},
"mpls": {
"dpsPaths": {
"path2": {"state": "routeResolved", "dpsSessions": {"0": {"active": True}}},
},
},
},
},
}
},
],
"inputs": {},
"expected": {"result": "success"},
},
{
"name": "failure-no-peer",
"test": VerifyPathsHealth,
"eos_data": [
{"dpsPeers": {}},
],
"inputs": {},
"expected": {"result": "failure", "messages": ["No path configured for router path-selection."]},
},
{
"name": "failure-not-established",
"test": VerifyPathsHealth,
"eos_data": [
{
"dpsPeers": {
"10.255.0.1": {
"dpsGroups": {
"internet": {
"dpsPaths": {
"path3": {"state": "ipsecPending", "dpsSessions": {"0": {"active": False}}},
},
},
"mpls": {
"dpsPaths": {
"path4": {"state": "ipsecPending", "dpsSessions": {"0": {"active": False}}},
},
},
},
},
"10.255.0.2": {
"dpsGroups": {
"internet": {
"dpsPaths": {
"path1": {"state": "ipsecEstablished", "dpsSessions": {"0": {"active": True}}},
},
},
"mpls": {
"dpsPaths": {
"path2": {"state": "ipsecPending", "dpsSessions": {"0": {"active": False}}},
},
},
},
},
}
},
],
"inputs": {},
"expected": {
"result": "failure",
"messages": [
"Path state for peer 10.255.0.1 in path-group internet is `ipsecPending`.",
"Path state for peer 10.255.0.1 in path-group mpls is `ipsecPending`.",
"Path state for peer 10.255.0.2 in path-group mpls is `ipsecPending`.",
],
},
},
{
"name": "failure-inactive",
"test": VerifyPathsHealth,
"eos_data": [
{
"dpsPeers": {
"10.255.0.1": {
"dpsGroups": {
"internet": {
"dpsPaths": {
"path3": {"state": "routeResolved", "dpsSessions": {"0": {"active": False}}},
},
},
"mpls": {
"dpsPaths": {
"path4": {"state": "routeResolved", "dpsSessions": {"0": {"active": False}}},
},
},
},
},
"10.255.0.2": {
"dpsGroups": {
"internet": {
"dpsPaths": {
"path1": {"state": "routeResolved", "dpsSessions": {"0": {"active": True}}},
},
},
"mpls": {
"dpsPaths": {
"path2": {"state": "routeResolved", "dpsSessions": {"0": {"active": False}}},
},
},
},
},
}
},
],
"inputs": {},
"expected": {
"result": "failure",
"messages": [
"Telemetry state for peer 10.255.0.1 in path-group internet is `inactive`.",
"Telemetry state for peer 10.255.0.1 in path-group mpls is `inactive`.",
"Telemetry state for peer 10.255.0.2 in path-group mpls is `inactive`.",
],
},
},
{
"name": "success",
"test": VerifySpecificPath,
"eos_data": [
{
"dpsPeers": {
"10.255.0.1": {
"dpsGroups": {
"internet": {
"dpsPaths": {
"path3": {
"state": "ipsecEstablished",
"source": "172.18.13.2",
"destination": "172.18.15.2",
"dpsSessions": {"0": {"active": True}},
}
}
}
}
}
}
},
{
"dpsPeers": {
"10.255.0.2": {
"dpsGroups": {
"mpls": {
"dpsPaths": {
"path2": {
"state": "ipsecEstablished",
"source": "172.18.3.2",
"destination": "172.18.5.2",
"dpsSessions": {"0": {"active": True}},
}
}
}
}
}
}
},
],
"inputs": {
"paths": [
{"peer": "10.255.0.1", "path_group": "internet", "source_address": "172.18.3.2", "destination_address": "172.18.5.2"},
{"peer": "10.255.0.2", "path_group": "mpls", "source_address": "172.18.13.2", "destination_address": "172.18.15.2"},
]
},
"expected": {"result": "success"},
},
{
"name": "failure-no-peer",
"test": VerifySpecificPath,
"eos_data": [
{"dpsPeers": {}},
{"dpsPeers": {}},
],
"inputs": {
"paths": [
{"peer": "10.255.0.1", "path_group": "internet", "source_address": "172.18.3.2", "destination_address": "172.18.5.2"},
{"peer": "10.255.0.2", "path_group": "mpls", "source_address": "172.18.13.2", "destination_address": "172.18.15.2"},
]
},
"expected": {
"result": "failure",
"messages": [
"Path `peer: 10.255.0.1 source: 172.18.3.2 destination: 172.18.5.2` is not configured for path-group `internet`.",
"Path `peer: 10.255.0.2 source: 172.18.13.2 destination: 172.18.15.2` is not configured for path-group `mpls`.",
],
},
},
{
"name": "failure-not-established",
"test": VerifySpecificPath,
"eos_data": [
{
"dpsPeers": {
"10.255.0.1": {
"dpsGroups": {
"internet": {
"dpsPaths": {
"path3": {"state": "ipsecPending", "source": "172.18.3.2", "destination": "172.18.5.2", "dpsSessions": {"0": {"active": True}}}
}
}
}
}
}
},
{
"dpsPeers": {
"10.255.0.2": {
"dpsGroups": {
"mpls": {
"dpsPaths": {
"path4": {
"state": "ipsecPending",
"source": "172.18.13.2",
"destination": "172.18.15.2",
"dpsSessions": {"0": {"active": False}},
}
}
}
}
}
}
},
],
"inputs": {
"paths": [
{"peer": "10.255.0.1", "path_group": "internet", "source_address": "172.18.3.2", "destination_address": "172.18.5.2"},
{"peer": "10.255.0.2", "path_group": "mpls", "source_address": "172.18.13.2", "destination_address": "172.18.15.2"},
]
},
"expected": {
"result": "failure",
"messages": [
"Path state for `peer: 10.255.0.1 source: 172.18.3.2 destination: 172.18.5.2` in path-group internet is `ipsecPending`.",
"Path state for `peer: 10.255.0.2 source: 172.18.13.2 destination: 172.18.15.2` in path-group mpls is `ipsecPending`.",
],
},
},
{
"name": "failure-inactive",
"test": VerifySpecificPath,
"eos_data": [
{
"dpsPeers": {
"10.255.0.1": {
"dpsGroups": {
"internet": {
"dpsPaths": {
"path3": {"state": "routeResolved", "source": "172.18.3.2", "destination": "172.18.5.2", "dpsSessions": {"0": {"active": False}}}
}
}
}
}
}
},
{
"dpsPeers": {
"10.255.0.2": {
"dpsGroups": {
"mpls": {
"dpsPaths": {
"path4": {
"state": "routeResolved",
"source": "172.18.13.2",
"destination": "172.18.15.2",
"dpsSessions": {"0": {"active": False}},
}
}
}
}
}
}
},
],
"inputs": {
"paths": [
{"peer": "10.255.0.1", "path_group": "internet", "source_address": "172.18.3.2", "destination_address": "172.18.5.2"},
{"peer": "10.255.0.2", "path_group": "mpls", "source_address": "172.18.13.2", "destination_address": "172.18.15.2"},
]
},
"expected": {
"result": "failure",
"messages": [
"Telemetry state for path `peer: 10.255.0.1 source: 172.18.3.2 destination: 172.18.5.2` in path-group internet is `inactive`.",
"Telemetry state for path `peer: 10.255.0.2 source: 172.18.13.2 destination: 172.18.15.2` in path-group mpls is `inactive`.",
],
},
},
]

View file

@ -42,11 +42,11 @@ DATA: list[dict[str, Any]] = [
"expected": {"result": "failure", "messages": ["The device is not configured as a PTP Boundary Clock: 'ptpDisabled'"]},
},
{
"name": "error",
"name": "skipped",
"test": VerifyPtpModeStatus,
"eos_data": [{"ptpIntfSummaries": {}}],
"inputs": None,
"expected": {"result": "error", "messages": ["'ptpMode' variable is not present in the command output"]},
"expected": {"result": "skipped", "messages": ["PTP is not configured"]},
},
{
"name": "success",
@ -104,11 +104,11 @@ DATA: list[dict[str, Any]] = [
},
},
{
"name": "error",
"name": "skipped",
"test": VerifyPtpGMStatus,
"eos_data": [{"ptpIntfSummaries": {}}],
"inputs": {"gmid": "0xec:46:70:ff:fe:00:ff:a8"},
"expected": {"result": "error", "messages": ["'ptpClockSummary' variable is not present in the command output"]},
"expected": {"result": "skipped", "messages": ["PTP is not configured"]},
},
{
"name": "success",
@ -161,14 +161,14 @@ DATA: list[dict[str, Any]] = [
"expected": {"result": "failure", "messages": ["The device lock is more than 60s old: 157s"]},
},
{
"name": "error",
"name": "skipped",
"test": VerifyPtpLockStatus,
"eos_data": [{"ptpIntfSummaries": {}}],
"inputs": None,
"expected": {
"result": "error",
"result": "skipped",
"messages": [
"'ptpClockSummary' variable is not present in the command output",
"PTP is not configured",
],
},
},

View file

@ -34,7 +34,14 @@ DATA: list[dict[str, Any]] = [
"expected": {"result": "success"},
},
{
"name": "failure",
"name": "error-missing-ssh-status",
"test": VerifySSHStatus,
"eos_data": ["SSH per host connection limit is 20\nFIPS status: disabled\n\n"],
"inputs": None,
"expected": {"result": "error", "messages": ["Could not find SSH status in returned output."]},
},
{
"name": "failure-ssh-disabled",
"test": VerifySSHStatus,
"eos_data": ["SSHD status for Default VRF is enabled\nSSH connection limit is 50\nSSH per host connection limit is 20\nFIPS status: disabled\n\n"],
"inputs": None,
@ -573,6 +580,40 @@ DATA: list[dict[str, Any]] = [
],
},
},
{
"name": "error-wrong-input-rsa",
"test": VerifyAPISSLCertificate,
"eos_data": [],
"inputs": {
"certificates": [
{
"certificate_name": "ARISTA_ROOT_CA.crt",
"expiry_threshold": 30,
"common_name": "Arista Networks Internal IT Root Cert Authority",
"encryption_algorithm": "RSA",
"key_size": 256,
},
]
},
"expected": {"result": "error", "messages": ["Allowed sizes are (2048, 3072, 4096)."]},
},
{
"name": "error-wrong-input-ecdsa",
"test": VerifyAPISSLCertificate,
"eos_data": [],
"inputs": {
"certificates": [
{
"certificate_name": "ARISTA_SIGNING_CA.crt",
"expiry_threshold": 30,
"common_name": "AristaIT-ICA ECDSA Issuing Cert Authority",
"encryption_algorithm": "ECDSA",
"key_size": 2048,
},
]
},
"expected": {"result": "error", "messages": ["Allowed sizes are (256, 384, 512)."]},
},
{
"name": "success",
"test": VerifyBannerLogin,

View file

@ -127,10 +127,12 @@ DATA: list[dict[str, Any]] = [
"name": "success",
"test": VerifyErrdisableRecovery,
"eos_data": [
# Adding empty line on purpose to verify they are skipped
"""
Errdisable Reason Timer Status Timer Interval
------------------------------ ----------------- --------------
acl Enabled 300
bpduguard Enabled 300
arp-inspection Enabled 30
"""

View file

@ -79,6 +79,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"versions": ["v1.17.1", "v1.18.1"]},
"expected": {"result": "failure", "messages": ["device is running TerminAttr version v1.17.0 and is not in the allowed list: ['v1.17.1', 'v1.18.1']"]},
},
# TODO: add a test with a real extension?
{
"name": "success-no-extensions",
"test": VerifyEOSExtensions,
@ -89,6 +90,16 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {"result": "success"},
},
{
"name": "success-empty-extension",
"test": VerifyEOSExtensions,
"eos_data": [
{"extensions": {}, "extensionStoredDir": "flash:", "warnings": ["No extensions are available"]},
{"extensions": [""]},
],
"inputs": None,
"expected": {"result": "success"},
},
{
"name": "failure",
"test": VerifyEOSExtensions,

View file

@ -25,6 +25,7 @@ if TYPE_CHECKING:
pytest.param("show version", None, "1", None, "dummy", False, id="version"),
pytest.param("show version", None, None, 3, "dummy", False, id="revision"),
pytest.param("undefined", None, None, None, "dummy", True, id="command fails"),
pytest.param("undefined", None, None, None, "doesnotexist", True, id="Device does not exist"),
],
)
def test_run_cmd(

View file

@ -11,7 +11,7 @@ from unittest.mock import call, patch
import pytest
from anta.cli.exec.utils import (
clear_counters_utils,
clear_counters,
)
from anta.models import AntaCommand
@ -69,14 +69,14 @@ if TYPE_CHECKING:
),
],
)
async def test_clear_counters_utils(
async def test_clear_counters(
caplog: pytest.LogCaptureFixture,
test_inventory: AntaInventory,
inventory_state: dict[str, Any],
per_device_command_output: dict[str, Any],
tags: set[str] | None,
) -> None:
"""Test anta.cli.exec.utils.clear_counters_utils."""
"""Test anta.cli.exec.utils.clear_counters."""
async def mock_connect_inventory() -> None:
"""Mock connect_inventory coroutine."""
@ -85,20 +85,19 @@ async def test_clear_counters_utils(
device.established = inventory_state[name].get("established", device.is_online)
device.hw_model = inventory_state[name].get("hw_model", "dummy")
async def dummy_collect(self: AntaDevice, command: AntaCommand) -> None:
async def collect(self: AntaDevice, command: AntaCommand, *args: Any, **kwargs: Any) -> None: # noqa: ARG001, ANN401 #pylint: disable=unused-argument
"""Mock collect coroutine."""
command.output = per_device_command_output.get(self.name, "")
# Need to patch the child device class
with (
patch("anta.device.AsyncEOSDevice.collect", side_effect=dummy_collect, autospec=True) as mocked_collect,
patch("anta.device.AsyncEOSDevice.collect", side_effect=collect, autospec=True) as mocked_collect,
patch(
"anta.inventory.AntaInventory.connect_inventory",
side_effect=mock_connect_inventory,
) as mocked_connect_inventory,
):
mocked_collect.side_effect = dummy_collect
await clear_counters_utils(test_inventory, tags=tags)
await clear_counters(test_inventory, tags=tags)
mocked_connect_inventory.assert_awaited_once()
devices_established = test_inventory.get_inventory(established_only=True, tags=tags).devices
@ -117,6 +116,7 @@ async def test_clear_counters_utils(
output=per_device_command_output.get(device.name, ""),
errors=[],
),
collection_id=None,
),
)
if device.hw_model not in ["cEOSLab", "vEOS-lab"]:
@ -130,6 +130,7 @@ async def test_clear_counters_utils(
ofmt="json",
output=per_device_command_output.get(device.name, ""),
),
collection_id=None,
),
)
mocked_collect.assert_has_awaits(calls)

View file

@ -7,7 +7,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from anta.cli import anta
from anta.cli._main import anta
from anta.cli.utils import ExitCode
if TYPE_CHECKING:

View file

@ -13,7 +13,7 @@ from unittest.mock import ANY, patch
import pytest
from cvprac.cvp_client_errors import CvpApiError
from anta.cli import anta
from anta.cli._main import anta
from anta.cli.utils import ExitCode
if TYPE_CHECKING:

View file

@ -81,14 +81,15 @@ def test_create_inventory_from_cvp(tmp_path: Path, inventory: list[dict[str, Any
@pytest.mark.parametrize(
("inventory_filename", "ansible_group", "expected_raise", "expected_inv_length"),
("inventory_filename", "ansible_group", "expected_raise", "expected_log", "expected_inv_length"),
[
pytest.param("ansible_inventory.yml", None, nullcontext(), 7, id="no group"),
pytest.param("ansible_inventory.yml", "ATD_LEAFS", nullcontext(), 4, id="group found"),
pytest.param("ansible_inventory.yml", None, nullcontext(), None, 7, id="no group"),
pytest.param("ansible_inventory.yml", "ATD_LEAFS", nullcontext(), None, 4, id="group found"),
pytest.param(
"ansible_inventory.yml",
"DUMMY",
pytest.raises(ValueError, match="Group DUMMY not found in Ansible inventory"),
None,
0,
id="group not found",
),
@ -96,6 +97,7 @@ def test_create_inventory_from_cvp(tmp_path: Path, inventory: list[dict[str, Any
"empty_ansible_inventory.yml",
None,
pytest.raises(ValueError, match="Ansible inventory .* is empty"),
None,
0,
id="empty inventory",
),
@ -103,19 +105,39 @@ def test_create_inventory_from_cvp(tmp_path: Path, inventory: list[dict[str, Any
"wrong_ansible_inventory.yml",
None,
pytest.raises(ValueError, match="Could not parse"),
None,
0,
id="os error inventory",
),
pytest.param(
"ansible_inventory_with_vault.yml",
None,
pytest.raises(ValueError, match="Could not parse"),
"`anta get from-ansible` does not support inline vaulted variables",
0,
id="Vault variable in inventory",
),
pytest.param(
"ansible_inventory_unknown_yaml_tag.yml",
None,
pytest.raises(ValueError, match="Could not parse"),
None,
0,
id="Unknown YAML tag in inventory",
),
],
)
def test_create_inventory_from_ansible(
caplog: pytest.LogCaptureFixture,
tmp_path: Path,
inventory_filename: Path,
ansible_group: str | None,
expected_raise: AbstractContextManager[Exception],
expected_log: str | None,
expected_inv_length: int,
) -> None:
"""Test anta.get.utils.create_inventory_from_ansible."""
# pylint: disable=R0913
target_file = tmp_path / "inventory.yml"
inventory_file_path = DATA_DIR / inventory_filename
@ -130,3 +152,5 @@ def test_create_inventory_from_ansible(
assert len(inv) == expected_inv_length
if not isinstance(expected_raise, nullcontext):
assert not target_file.exists()
if expected_log:
assert expected_log in caplog.text

View file

@ -24,6 +24,14 @@ def test_anta_nrfu_help(click_runner: CliRunner) -> None:
assert "Usage: anta nrfu" in result.output
def test_anta_nrfu_wrong_subcommand(click_runner: CliRunner) -> None:
"""Test anta nrfu toast."""
result = click_runner.invoke(anta, ["nrfu", "oook"])
assert result.exit_code == ExitCode.USAGE_ERROR
assert "Usage: anta nrfu" in result.output
assert "No such command 'oook'." in result.output
def test_anta_nrfu(click_runner: CliRunner) -> None:
"""Test anta nrfu, catalog is given via env."""
result = click_runner.invoke(anta, ["nrfu"])
@ -32,6 +40,15 @@ def test_anta_nrfu(click_runner: CliRunner) -> None:
assert "Tests catalog contains 1 tests" in result.output
def test_anta_nrfu_dry_run(click_runner: CliRunner) -> None:
"""Test anta nrfu --dry-run, catalog is given via env."""
result = click_runner.invoke(anta, ["nrfu", "--dry-run"])
assert result.exit_code == ExitCode.OK
assert "ANTA Inventory contains 3 devices" in result.output
assert "Tests catalog contains 1 tests" in result.output
assert "Dry-run" in result.output
def test_anta_password_required(click_runner: CliRunner) -> None:
"""Test that password is provided."""
env = default_anta_env()

View file

@ -54,6 +54,20 @@ def test_anta_nrfu_table(click_runner: CliRunner) -> None:
assert "dummy │ VerifyEOSVersion │ success" in result.output
def test_anta_nrfu_table_group_by_device(click_runner: CliRunner) -> None:
"""Test anta nrfu, catalog is given via env."""
result = click_runner.invoke(anta, ["nrfu", "table", "--group-by", "device"])
assert result.exit_code == ExitCode.OK
assert "Summary per device" in result.output
def test_anta_nrfu_table_group_by_test(click_runner: CliRunner) -> None:
"""Test anta nrfu, catalog is given via env."""
result = click_runner.invoke(anta, ["nrfu", "table", "--group-by", "test"])
assert result.exit_code == ExitCode.OK
assert "Summary per test" in result.output
def test_anta_nrfu_text(click_runner: CliRunner) -> None:
"""Test anta nrfu, catalog is given via env."""
result = click_runner.invoke(anta, ["nrfu", "text"])
@ -66,7 +80,7 @@ def test_anta_nrfu_json(click_runner: CliRunner) -> None:
result = click_runner.invoke(anta, ["nrfu", "json"])
assert result.exit_code == ExitCode.OK
assert "JSON results" in result.output
match = re.search(r"\[\n {[\s\S]+ }\n\]", result.output)
match = re.search(r"\[\n {2}{[\s\S]+ {2}}\n\]", result.output)
assert match is not None
result_list = json.loads(match.group())
for res in result_list:

View file

@ -1,64 +1,55 @@
# 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.
"""Tests for anta.cli.__init__."""
"""Tests for anta.cli._main."""
from __future__ import annotations
from typing import TYPE_CHECKING
import sys
from importlib import reload
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
import pytest
from anta.cli import anta, cli
from anta.cli.utils import ExitCode
import anta.cli
if TYPE_CHECKING:
from click.testing import CliRunner
from types import ModuleType
builtins_import = __import__
def test_anta(click_runner: CliRunner) -> None:
"""Test anta main entrypoint."""
result = click_runner.invoke(anta)
assert result.exit_code == ExitCode.OK
assert "Usage" in result.output
# Tried to achieve this with mock
# http://materials-scientist.com/blog/2021/02/11/mocking-failing-module-import-python/
def import_mock(name: str, *args: Any) -> ModuleType: # noqa: ANN401
"""Mock."""
if name == "click":
msg = "No module named 'click'"
raise ModuleNotFoundError(msg)
return builtins_import(name, *args)
def test_anta_help(click_runner: CliRunner) -> None:
"""Test anta --help."""
result = click_runner.invoke(anta, ["--help"])
assert result.exit_code == ExitCode.OK
assert "Usage" in result.output
def test_cli_error_missing(capsys: pytest.CaptureFixture[Any]) -> None:
"""Test ANTA errors out when anta[cli] was not installed."""
with patch.dict(sys.modules) as sys_modules, patch("builtins.__import__", import_mock):
del sys_modules["anta.cli._main"]
reload(anta.cli)
with pytest.raises(SystemExit) as e_info:
anta.cli.cli()
def test_anta_exec_help(click_runner: CliRunner) -> None:
"""Test anta exec --help."""
result = click_runner.invoke(anta, ["exec", "--help"])
assert result.exit_code == ExitCode.OK
assert "Usage: anta exec" in result.output
def test_anta_debug_help(click_runner: CliRunner) -> None:
"""Test anta debug --help."""
result = click_runner.invoke(anta, ["debug", "--help"])
assert result.exit_code == ExitCode.OK
assert "Usage: anta debug" in result.output
def test_anta_get_help(click_runner: CliRunner) -> None:
"""Test anta get --help."""
result = click_runner.invoke(anta, ["get", "--help"])
assert result.exit_code == ExitCode.OK
assert "Usage: anta get" in result.output
def test_uncaught_failure_anta(caplog: pytest.LogCaptureFixture) -> None:
"""Test uncaught failure when running ANTA cli."""
with (
pytest.raises(SystemExit) as e_info,
patch("anta.cli.anta", side_effect=ZeroDivisionError()),
):
cli()
assert "CRITICAL" in caplog.text
assert "Uncaught Exception when running ANTA CLI" in caplog.text
captured = capsys.readouterr()
assert "The ANTA command line client could not run because the required dependencies were not installed." in captured.out
assert "Make sure you've installed everything with: pip install 'anta[cli]'" in captured.out
assert e_info.value.code == 1
# setting ANTA_DEBUG
with pytest.raises(SystemExit) as e_info, patch("anta.cli.__DEBUG__", new=True):
anta.cli.cli()
captured = capsys.readouterr()
assert "The ANTA command line client could not run because the required dependencies were not installed." in captured.out
assert "Make sure you've installed everything with: pip install 'anta[cli]'" in captured.out
assert "The caught exception was:" in captured.out
assert e_info.value.code == 1

View file

@ -0,0 +1,64 @@
# 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.
"""Tests for anta.cli._main."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from anta.cli._main import anta, cli
from anta.cli.utils import ExitCode
if TYPE_CHECKING:
from click.testing import CliRunner
def test_anta(click_runner: CliRunner) -> None:
"""Test anta main entrypoint."""
result = click_runner.invoke(anta)
assert result.exit_code == ExitCode.OK
assert "Usage" in result.output
def test_anta_help(click_runner: CliRunner) -> None:
"""Test anta --help."""
result = click_runner.invoke(anta, ["--help"])
assert result.exit_code == ExitCode.OK
assert "Usage" in result.output
def test_anta_exec_help(click_runner: CliRunner) -> None:
"""Test anta exec --help."""
result = click_runner.invoke(anta, ["exec", "--help"])
assert result.exit_code == ExitCode.OK
assert "Usage: anta exec" in result.output
def test_anta_debug_help(click_runner: CliRunner) -> None:
"""Test anta debug --help."""
result = click_runner.invoke(anta, ["debug", "--help"])
assert result.exit_code == ExitCode.OK
assert "Usage: anta debug" in result.output
def test_anta_get_help(click_runner: CliRunner) -> None:
"""Test anta get --help."""
result = click_runner.invoke(anta, ["get", "--help"])
assert result.exit_code == ExitCode.OK
assert "Usage: anta get" in result.output
def test_uncaught_failure_anta(caplog: pytest.LogCaptureFixture) -> None:
"""Test uncaught failure when running ANTA cli."""
with (
pytest.raises(SystemExit) as e_info,
patch("anta.cli._main.anta", side_effect=ZeroDivisionError()),
):
cli()
assert "CRITICAL" in caplog.text
assert "Uncaught Exception when running ANTA CLI" in caplog.text
assert e_info.value.code == 1

View file

@ -5,13 +5,14 @@
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Callable
import pytest
from rich.table import Table
from anta import RICH_COLOR_PALETTE
from anta.reporter import ReportTable
from anta.reporter import ReportJinja, ReportTable
if TYPE_CHECKING:
from anta.custom_types import TestStatus
@ -185,3 +186,14 @@ class TestReportTable:
assert isinstance(res, Table)
assert res.title == (title or "Summary per device")
assert res.row_count == expected_length
class TestReportJinja:
"""Tests for ReportJinja class."""
# pylint: disable=too-few-public-methods
def test_fail__init__file_not_found(self) -> None:
"""Test __init__ failure if file is not found."""
with pytest.raises(FileNotFoundError, match="template file is not found: /gnu/terry/pratchett"):
ReportJinja(Path("/gnu/terry/pratchett"))

View file

@ -12,7 +12,7 @@ import pytest
from pydantic import ValidationError
from yaml import safe_load
from anta.catalog import AntaCatalog, AntaTestDefinition
from anta.catalog import AntaCatalog, AntaCatalogFile, AntaTestDefinition
from anta.models import AntaTest
from anta.tests.interfaces import VerifyL3MTU
from anta.tests.mlag import VerifyMlagStatus
@ -76,6 +76,11 @@ INIT_CATALOG_DATA: list[dict[str, Any]] = [
"filename": "test_empty_catalog.yml",
"tests": [],
},
{
"name": "test_empty_dict_catalog",
"filename": "test_empty_dict_catalog.yml",
"tests": [],
},
]
CATALOG_PARSE_FAIL_DATA: list[dict[str, Any]] = [
{
@ -160,7 +165,6 @@ CATALOG_FROM_LIST_FAIL_DATA: list[dict[str, Any]] = [
"error": "FakeTestWithInput test inputs are not valid: 1 validation error for Input\n\tstring\n\t Input should be a valid string",
},
]
TESTS_SETTER_FAIL_DATA: list[dict[str, Any]] = [
{
"name": "not_a_list",
@ -181,7 +185,7 @@ class TestAntaCatalog:
@pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA))
def test_parse(self, catalog_data: dict[str, Any]) -> None:
"""Instantiate AntaCatalog from a file."""
catalog: AntaCatalog = AntaCatalog.parse(str(DATA_DIR / catalog_data["filename"]))
catalog: AntaCatalog = AntaCatalog.parse(DATA_DIR / catalog_data["filename"])
assert len(catalog.tests) == len(catalog_data["tests"])
for test_id, (test, inputs_data) in enumerate(catalog_data["tests"]):
@ -221,7 +225,7 @@ class TestAntaCatalog:
def test_parse_fail(self, catalog_data: dict[str, Any]) -> None:
"""Errors when instantiating AntaCatalog from a file."""
with pytest.raises((ValidationError, TypeError)) as exec_info:
AntaCatalog.parse(str(DATA_DIR / catalog_data["filename"]))
AntaCatalog.parse(DATA_DIR / catalog_data["filename"])
if isinstance(exec_info.value, ValidationError):
assert catalog_data["error"] in exec_info.value.errors()[0]["msg"]
else:
@ -230,7 +234,7 @@ class TestAntaCatalog:
def test_parse_fail_parsing(self, caplog: pytest.LogCaptureFixture) -> None:
"""Errors when instantiating AntaCatalog from a file."""
with pytest.raises(FileNotFoundError) as exec_info:
AntaCatalog.parse(str(DATA_DIR / "catalog_does_not_exist.yml"))
AntaCatalog.parse(DATA_DIR / "catalog_does_not_exist.yml")
assert "No such file or directory" in str(exec_info)
assert len(caplog.record_tuples) >= 1
_, _, message = caplog.record_tuples[0]
@ -284,16 +288,79 @@ class TestAntaCatalog:
catalog.tests = catalog_data["tests"]
assert catalog_data["error"] in str(exec_info)
def test_build_indexes_all(self) -> None:
"""Test AntaCatalog.build_indexes()."""
catalog: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog_with_tags.yml")
catalog.build_indexes()
assert len(catalog.tests_without_tags) == 5
assert "leaf" in catalog.tag_to_tests
assert len(catalog.tag_to_tests["leaf"]) == 3
all_unique_tests = catalog.tests_without_tags
for tests in catalog.tag_to_tests.values():
all_unique_tests.update(tests)
assert len(all_unique_tests) == 11
assert catalog.indexes_built is True
def test_build_indexes_filtered(self) -> None:
"""Test AntaCatalog.build_indexes()."""
catalog: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog_with_tags.yml")
catalog.build_indexes({"VerifyUptime", "VerifyCoredump", "VerifyL3MTU"})
assert "leaf" in catalog.tag_to_tests
assert len(catalog.tag_to_tests["leaf"]) == 1
assert len(catalog.tests_without_tags) == 1
all_unique_tests = catalog.tests_without_tags
for tests in catalog.tag_to_tests.values():
all_unique_tests.update(tests)
assert len(all_unique_tests) == 4
assert catalog.indexes_built is True
def test_get_tests_by_tags(self) -> None:
"""Test AntaCatalog.get_tests_by_tags()."""
catalog: AntaCatalog = AntaCatalog.parse(str(DATA_DIR / "test_catalog_with_tags.yml"))
tests: list[AntaTestDefinition] = catalog.get_tests_by_tags(tags={"leaf"})
catalog: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog_with_tags.yml")
catalog.build_indexes()
tests: set[AntaTestDefinition] = catalog.get_tests_by_tags(tags={"leaf"})
assert len(tests) == 3
tests = catalog.get_tests_by_tags(tags={"leaf"}, strict=True)
assert len(tests) == 2
tests = catalog.get_tests_by_tags(tags={"leaf", "spine"}, strict=True)
assert len(tests) == 1
def test_get_tests_by_names(self) -> None:
"""Test AntaCatalog.get_tests_by_tags()."""
catalog: AntaCatalog = AntaCatalog.parse(str(DATA_DIR / "test_catalog_with_tags.yml"))
tests: list[AntaTestDefinition] = catalog.get_tests_by_names(names={"VerifyUptime", "VerifyCoredump"})
assert len(tests) == 3
def test_merge(self) -> None:
"""Test AntaCatalog.merge()."""
catalog1: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog.yml")
assert len(catalog1.tests) == 1
catalog2: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog.yml")
assert len(catalog2.tests) == 1
catalog3: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog_medium.yml")
assert len(catalog3.tests) == 228
assert len(catalog1.merge(catalog2).tests) == 2
assert len(catalog1.tests) == 1
assert len(catalog2.tests) == 1
assert len(catalog2.merge(catalog3).tests) == 229
assert len(catalog2.tests) == 1
assert len(catalog3.tests) == 228
def test_dump(self) -> None:
"""Test AntaCatalog.dump()."""
catalog: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog.yml")
assert len(catalog.tests) == 1
file: AntaCatalogFile = catalog.dump()
assert sum(len(tests) for tests in file.root.values()) == 1
catalog = AntaCatalog.parse(DATA_DIR / "test_catalog_medium.yml")
assert len(catalog.tests) == 228
file = catalog.dump()
assert sum(len(tests) for tests in file.root.values()) == 228
class TestAntaCatalogFile: # pylint: disable=too-few-public-methods
"""Test for anta.catalog.AntaCatalogFile."""
def test_yaml(self) -> None:
"""Test AntaCatalogFile.yaml()."""
file = DATA_DIR / "test_catalog_medium.yml"
catalog = AntaCatalog.parse(file)
assert len(catalog.tests) == 228
catalog_yaml_str = catalog.dump().yaml()
with file.open(encoding="UTF-8") as f:
assert catalog_yaml_str == f.read()

View file

@ -0,0 +1,264 @@
# 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.
"""Tests for `anta.custom_types`.
The intention is only to test here what is not used already in other places.
TODO: Expand later.
"""
from __future__ import annotations
import re
import pytest
from anta.custom_types import (
REGEX_BGP_IPV4_MPLS_VPN,
REGEX_BGP_IPV4_UNICAST,
REGEXP_BGP_IPV4_MPLS_LABELS,
REGEXP_BGP_L2VPN_AFI,
REGEXP_EOS_BLACKLIST_CMDS,
REGEXP_INTERFACE_ID,
REGEXP_PATH_MARKERS,
REGEXP_TYPE_EOS_INTERFACE,
REGEXP_TYPE_HOSTNAME,
REGEXP_TYPE_VXLAN_SRC_INTERFACE,
aaa_group_prefix,
bgp_multiprotocol_capabilities_abbreviations,
interface_autocomplete,
interface_case_sensitivity,
)
# ------------------------------------------------------------------------------
# TEST custom_types.py regular expressions
# ------------------------------------------------------------------------------
def test_regexp_path_markers() -> None:
"""Test REGEXP_PATH_MARKERS."""
# Test strings that should match the pattern
assert re.search(REGEXP_PATH_MARKERS, "show/bgp/interfaces") is not None
assert re.search(REGEXP_PATH_MARKERS, "show\\bgp") is not None
assert re.search(REGEXP_PATH_MARKERS, "show bgp") is not None
# Test strings that should not match the pattern
assert re.search(REGEXP_PATH_MARKERS, "aaaa") is None
assert re.search(REGEXP_PATH_MARKERS, "11111") is None
assert re.search(REGEXP_PATH_MARKERS, ".[]?<>") is None
def test_regexp_bgp_l2vpn_afi() -> None:
"""Test REGEXP_BGP_L2VPN_AFI."""
# Test strings that should match the pattern
assert re.search(REGEXP_BGP_L2VPN_AFI, "l2vpn-evpn") is not None
assert re.search(REGEXP_BGP_L2VPN_AFI, "l2 vpn evpn") is not None
assert re.search(REGEXP_BGP_L2VPN_AFI, "l2-vpn evpn") is not None
assert re.search(REGEXP_BGP_L2VPN_AFI, "l2vpn evpn") is not None
assert re.search(REGEXP_BGP_L2VPN_AFI, "l2vpnevpn") is not None
assert re.search(REGEXP_BGP_L2VPN_AFI, "l2 vpnevpn") is not None
# Test strings that should not match the pattern
assert re.search(REGEXP_BGP_L2VPN_AFI, "al2vpn evpn") is None
assert re.search(REGEXP_BGP_L2VPN_AFI, "l2vpn-evpna") is None
def test_regexp_bgp_ipv4_mpls_labels() -> None:
"""Test REGEXP_BGP_IPV4_MPLS_LABELS."""
assert re.search(REGEXP_BGP_IPV4_MPLS_LABELS, "ipv4-mpls-label") is not None
assert re.search(REGEXP_BGP_IPV4_MPLS_LABELS, "ipv4 mpls labels") is not None
assert re.search(REGEXP_BGP_IPV4_MPLS_LABELS, "ipv4Mplslabel") is None
def test_regex_bgp_ipv4_mpls_vpn() -> None:
"""Test REGEX_BGP_IPV4_MPLS_VPN."""
assert re.search(REGEX_BGP_IPV4_MPLS_VPN, "ipv4-mpls-vpn") is not None
assert re.search(REGEX_BGP_IPV4_MPLS_VPN, "ipv4_mplsvpn") is None
def test_regex_bgp_ipv4_unicast() -> None:
"""Test REGEX_BGP_IPV4_UNICAST."""
assert re.search(REGEX_BGP_IPV4_UNICAST, "ipv4-uni-cast") is not None
assert re.search(REGEX_BGP_IPV4_UNICAST, "ipv4+unicast") is None
def test_regexp_type_interface_id() -> None:
"""Test REGEXP_INTERFACE_ID."""
intf_id_re = re.compile(f"{REGEXP_INTERFACE_ID}")
# Test strings that should match the pattern
assert intf_id_re.search("123") is not None
assert intf_id_re.search("123/456") is not None
assert intf_id_re.search("123.456") is not None
assert intf_id_re.search("123/456.789") is not None
def test_regexp_type_eos_interface() -> None:
"""Test REGEXP_TYPE_EOS_INTERFACE."""
# Test strings that should match the pattern
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Ethernet0") is not None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Vlan100") is not None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Port-Channel1/0") is not None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Loopback0.1") is not None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Management0/0/0") is not None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Tunnel1") is not None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Vxlan1") is not None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Fabric1") is not None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Dps1") is not None
# Test strings that should not match the pattern
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Ethernet") is None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Vlan") is None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Port-Channel") is None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Loopback.") is None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Management/") is None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Tunnel") is None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Vxlan") is None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Fabric") is None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Dps") is None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Ethernet1/a") is None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Port-Channel-100") is None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Loopback.10") is None
assert re.match(REGEXP_TYPE_EOS_INTERFACE, "Management/10") is None
def test_regexp_type_vxlan_src_interface() -> None:
"""Test REGEXP_TYPE_VXLAN_SRC_INTERFACE."""
# Test strings that should match the pattern
assert re.match(REGEXP_TYPE_VXLAN_SRC_INTERFACE, "Loopback0") is not None
assert re.match(REGEXP_TYPE_VXLAN_SRC_INTERFACE, "Loopback1") is not None
assert re.match(REGEXP_TYPE_VXLAN_SRC_INTERFACE, "Loopback99") is not None
assert re.match(REGEXP_TYPE_VXLAN_SRC_INTERFACE, "Loopback100") is not None
assert re.match(REGEXP_TYPE_VXLAN_SRC_INTERFACE, "Loopback8190") is not None
assert re.match(REGEXP_TYPE_VXLAN_SRC_INTERFACE, "Loopback8199") is not None
# Test strings that should not match the pattern
assert re.match(REGEXP_TYPE_VXLAN_SRC_INTERFACE, "Loopback") is None
assert re.match(REGEXP_TYPE_VXLAN_SRC_INTERFACE, "Loopback9001") is None
assert re.match(REGEXP_TYPE_VXLAN_SRC_INTERFACE, "Loopback9000") is None
def test_regexp_type_hostname() -> None:
"""Test REGEXP_TYPE_HOSTNAME."""
# Test strings that should match the pattern
assert re.match(REGEXP_TYPE_HOSTNAME, "hostname") is not None
assert re.match(REGEXP_TYPE_HOSTNAME, "hostname.com") is not None
assert re.match(REGEXP_TYPE_HOSTNAME, "host-name.com") is not None
assert re.match(REGEXP_TYPE_HOSTNAME, "host.name.com") is not None
assert re.match(REGEXP_TYPE_HOSTNAME, "host-name1.com") is not None
# Test strings that should not match the pattern
assert re.match(REGEXP_TYPE_HOSTNAME, "-hostname.com") is None
assert re.match(REGEXP_TYPE_HOSTNAME, ".hostname.com") is None
assert re.match(REGEXP_TYPE_HOSTNAME, "hostname-.com") is None
assert re.match(REGEXP_TYPE_HOSTNAME, "hostname..com") is None
@pytest.mark.parametrize(
("test_string", "expected"),
[
("reload", True), # matches "^reload.*"
("reload now", True), # matches "^reload.*"
("configure terminal", True), # matches "^conf\w*\s*(terminal|session)*"
("conf t", True), # matches "^conf\w*\s*(terminal|session)*"
("write memory", True), # matches "^wr\w*\s*\w+"
("wr mem", True), # matches "^wr\w*\s*\w+"
("show running-config", False), # does not match any regex
("no shutdown", False), # does not match any regex
("", False), # empty string does not match any regex
],
)
def test_regexp_eos_blacklist_cmds(test_string: str, expected: bool) -> None:
"""Test REGEXP_EOS_BLACKLIST_CMDS."""
def matches_any_regex(string: str, regex_list: list[str]) -> bool:
"""
Check if a string matches at least one regular expression in a list.
:param string: The string to check.
:param regex_list: A list of regular expressions.
:return: True if the string matches at least one regular expression, False otherwise.
"""
return any(re.match(regex, string) for regex in regex_list)
assert matches_any_regex(test_string, REGEXP_EOS_BLACKLIST_CMDS) == expected
# ------------------------------------------------------------------------------
# TEST custom_types.py functions
# ------------------------------------------------------------------------------
def test_interface_autocomplete_success() -> None:
"""Test interface_autocomplete with valid inputs."""
assert interface_autocomplete("et1") == "Ethernet1"
assert interface_autocomplete("et1/1") == "Ethernet1/1"
assert interface_autocomplete("et1.1") == "Ethernet1.1"
assert interface_autocomplete("et1/1.1") == "Ethernet1/1.1"
assert interface_autocomplete("eth2") == "Ethernet2"
assert interface_autocomplete("po3") == "Port-Channel3"
assert interface_autocomplete("lo4") == "Loopback4"
def test_interface_autocomplete_no_alias() -> None:
"""Test interface_autocomplete with inputs that don't have aliases."""
assert interface_autocomplete("GigabitEthernet1") == "GigabitEthernet1"
assert interface_autocomplete("Vlan10") == "Vlan10"
assert interface_autocomplete("Tunnel100") == "Tunnel100"
def test_interface_autocomplete_failure() -> None:
"""Trigger ValueError for interface_autocomplete."""
with pytest.raises(ValueError, match="Could not parse interface ID in interface"):
interface_autocomplete("ThisIsNotAnInterface")
@pytest.mark.parametrize(
("str_input", "expected_output"),
[
pytest.param("L2VPNEVPN", "l2VpnEvpn", id="l2VpnEvpn"),
pytest.param("ipv4-mplsLabels", "ipv4MplsLabels", id="ipv4MplsLabels"),
pytest.param("ipv4-mpls-vpn", "ipv4MplsVpn", id="ipv4MplsVpn"),
pytest.param("ipv4-unicast", "ipv4Unicast", id="ipv4Unicast"),
pytest.param("BLAH", "BLAH", id="unmatched"),
],
)
def test_bgp_multiprotocol_capabilities_abbreviationsh(str_input: str, expected_output: str) -> None:
"""Test bgp_multiprotocol_capabilities_abbreviations."""
assert bgp_multiprotocol_capabilities_abbreviations(str_input) == expected_output
def test_aaa_group_prefix_known_method() -> None:
"""Test aaa_group_prefix with a known method."""
assert aaa_group_prefix("local") == "local"
assert aaa_group_prefix("none") == "none"
assert aaa_group_prefix("logging") == "logging"
def test_aaa_group_prefix_unknown_method() -> None:
"""Test aaa_group_prefix with an unknown method."""
assert aaa_group_prefix("demo") == "group demo"
assert aaa_group_prefix("group1") == "group group1"
def test_interface_case_sensitivity_lowercase() -> None:
"""Test interface_case_sensitivity with lowercase inputs."""
assert interface_case_sensitivity("ethernet") == "Ethernet"
assert interface_case_sensitivity("vlan") == "Vlan"
assert interface_case_sensitivity("loopback") == "Loopback"
def test_interface_case_sensitivity_mixed_case() -> None:
"""Test interface_case_sensitivity with mixed case inputs."""
assert interface_case_sensitivity("Ethernet") == "Ethernet"
assert interface_case_sensitivity("Vlan") == "Vlan"
assert interface_case_sensitivity("Loopback") == "Loopback"
def test_interface_case_sensitivity_uppercase() -> None:
"""Test interface_case_sensitivity with uppercase inputs."""
assert interface_case_sensitivity("ETHERNET") == "ETHERNET"
assert interface_case_sensitivity("VLAN") == "VLAN"
assert interface_case_sensitivity("LOOPBACK") == "LOOPBACK"

View file

@ -15,7 +15,7 @@ import pytest
from asyncssh import SSHClientConnection, SSHClientConnectionOptions
from rich import print as rprint
from anta import aioeapi
import asynceapi
from anta.device import AntaDevice, AsyncEOSDevice
from anta.models import AntaCommand
from tests.lib.fixture import COMMAND_OUTPUT
@ -128,7 +128,7 @@ EQUALITY_DATA: list[dict[str, Any]] = [
"expected": False,
},
]
AIOEAPI_COLLECT_DATA: list[dict[str, Any]] = [
ASYNCEAPI_COLLECT_DATA: list[dict[str, Any]] = [
{
"name": "command",
"device": {},
@ -350,12 +350,12 @@ AIOEAPI_COLLECT_DATA: list[dict[str, Any]] = [
},
},
{
"name": "aioeapi.EapiCommandError",
"name": "asynceapi.EapiCommandError",
"device": {},
"command": {
"command": "show version",
"patch_kwargs": {
"side_effect": aioeapi.EapiCommandError(
"side_effect": asynceapi.EapiCommandError(
passed=[],
failed="show version",
errors=["Authorization denied for command 'show version'"],
@ -385,7 +385,7 @@ AIOEAPI_COLLECT_DATA: list[dict[str, Any]] = [
"expected": {"output": None, "errors": ["ConnectError: Cannot open port"]},
},
]
AIOEAPI_COPY_DATA: list[dict[str, Any]] = [
ASYNCEAPI_COPY_DATA: list[dict[str, Any]] = [
{
"name": "from",
"device": {},
@ -509,12 +509,12 @@ REFRESH_DATA: list[dict[str, Any]] = [
"expected": {"is_online": True, "established": False, "hw_model": None},
},
{
"name": "aioeapi.EapiCommandError",
"name": "asynceapi.EapiCommandError",
"device": {},
"patch_kwargs": (
{"return_value": True},
{
"side_effect": aioeapi.EapiCommandError(
"side_effect": asynceapi.EapiCommandError(
passed=[],
failed="show version",
errors=["Authorization denied for command 'show version'"],
@ -644,7 +644,7 @@ class TestAntaDevice:
assert current_cached_data == COMMAND_OUTPUT
assert device.cache.hit_miss_ratio["hits"] == 1
else: # command is not allowed to use cache
device._collect.assert_called_once_with(command=command) # type: ignore[attr-defined] # pylint: disable=protected-access
device._collect.assert_called_once_with(command=command, collection_id=None) # type: ignore[attr-defined] # pylint: disable=protected-access
assert command.output == COMMAND_OUTPUT
if expected_data["cache_hit"] is True:
assert current_cached_data == cached_output
@ -652,7 +652,7 @@ class TestAntaDevice:
assert current_cached_data is None
else: # device is disabled
assert device.cache is None
device._collect.assert_called_once_with(command=command) # type: ignore[attr-defined] # pylint: disable=protected-access
device._collect.assert_called_once_with(command=command, collection_id=None) # type: ignore[attr-defined] # pylint: disable=protected-access
@pytest.mark.parametrize(("device", "expected"), CACHE_STATS_DATA, indirect=["device"])
def test_cache_statistics(self, device: AntaDevice, expected: dict[str, Any] | None) -> None:
@ -705,9 +705,9 @@ class TestAsyncEOSDevice:
"""Test AsyncEOSDevice.refresh()."""
with patch.object(async_device._session, "check_connection", **patch_kwargs[0]), patch.object(async_device._session, "cli", **patch_kwargs[1]):
await async_device.refresh()
async_device._session.check_connection.assert_called_once()
async_device._session.check_connection.assert_called_once() # type: ignore[attr-defined] # asynceapi.Device.check_connection is patched
if expected["is_online"]:
async_device._session.cli.assert_called_once()
async_device._session.cli.assert_called_once() # type: ignore[attr-defined] # asynceapi.Device.cli is patched
assert async_device.is_online == expected["is_online"]
assert async_device.established == expected["established"]
assert async_device.hw_model == expected["hw_model"]
@ -715,8 +715,8 @@ class TestAsyncEOSDevice:
@pytest.mark.asyncio()
@pytest.mark.parametrize(
("async_device", "command", "expected"),
((d["device"], d["command"], d["expected"]) for d in AIOEAPI_COLLECT_DATA),
ids=generate_test_ids_list(AIOEAPI_COLLECT_DATA),
((d["device"], d["command"], d["expected"]) for d in ASYNCEAPI_COLLECT_DATA),
ids=generate_test_ids_list(ASYNCEAPI_COLLECT_DATA),
indirect=["async_device"],
)
async def test__collect(self, async_device: AsyncEOSDevice, command: dict[str, Any], expected: dict[str, Any]) -> None:
@ -724,7 +724,8 @@ class TestAsyncEOSDevice:
"""Test AsyncEOSDevice._collect()."""
cmd = AntaCommand(command=command["command"], revision=command["revision"]) if "revision" in command else AntaCommand(command=command["command"])
with patch.object(async_device._session, "cli", **command["patch_kwargs"]):
await async_device.collect(cmd)
collection_id = "pytest"
await async_device.collect(cmd, collection_id=collection_id)
commands: list[dict[str, Any]] = []
if async_device.enable and async_device._enable_password is not None:
commands.append(
@ -740,15 +741,15 @@ class TestAsyncEOSDevice:
commands.append({"cmd": cmd.command, "revision": cmd.revision})
else:
commands.append({"cmd": cmd.command})
async_device._session.cli.assert_called_once_with(commands=commands, ofmt=cmd.ofmt, version=cmd.version)
async_device._session.cli.assert_called_once_with(commands=commands, ofmt=cmd.ofmt, version=cmd.version, req_id=f"ANTA-{collection_id}-{id(cmd)}") # type: ignore[attr-defined] # asynceapi.Device.cli is patched # pylint: disable=line-too-long
assert cmd.output == expected["output"]
assert cmd.errors == expected["errors"]
@pytest.mark.asyncio()
@pytest.mark.parametrize(
("async_device", "copy"),
((d["device"], d["copy"]) for d in AIOEAPI_COPY_DATA),
ids=generate_test_ids_list(AIOEAPI_COPY_DATA),
((d["device"], d["copy"]) for d in ASYNCEAPI_COPY_DATA),
ids=generate_test_ids_list(ASYNCEAPI_COPY_DATA),
indirect=["async_device"],
)
async def test_copy(self, async_device: AsyncEOSDevice, copy: dict[str, Any]) -> None:

Some files were not shown because too many files have changed in this diff Show more