Adding upstream version 0.15.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
6721599912
commit
a1777afd4b
103 changed files with 79620 additions and 742 deletions
2
.github/workflows/pr-triage.yml
vendored
2
.github/workflows/pr-triage.yml
vendored
|
@ -22,7 +22,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
# Please look up the latest version from
|
# Please look up the latest version from
|
||||||
# https://github.com/amannn/action-semantic-pull-request/releases
|
# 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:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
---
|
---
|
||||||
# See https://pre-commit.com for more information
|
# See https://pre-commit.com for more information
|
||||||
# See https://pre-commit.com/hooks.html for more hooks
|
# See https://pre-commit.com/hooks.html for more hooks
|
||||||
files: ^(anta|docs|scripts|tests)/
|
files: ^(anta|docs|scripts|tests|asynceapi)/
|
||||||
|
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.4.0
|
rev: v4.4.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
|
exclude: docs/.*.svg
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
|
exclude: tests/data/.*$
|
||||||
- id: check-merge-conflict
|
- id: check-merge-conflict
|
||||||
|
|
||||||
- repo: https://github.com/Lucas-C/pre-commit-hooks
|
- repo: https://github.com/Lucas-C/pre-commit-hooks
|
||||||
|
@ -41,7 +43,7 @@ repos:
|
||||||
- '<!--| ~| -->'
|
- '<!--| ~| -->'
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.3.5
|
rev: v0.4.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
name: Run Ruff linter
|
name: Run Ruff linter
|
||||||
|
@ -72,25 +74,16 @@ repos:
|
||||||
types: [text]
|
types: [text]
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
rev: v1.9.0
|
rev: v1.10.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
name: Check typing with mypy
|
name: Check typing with mypy
|
||||||
args:
|
args:
|
||||||
- --config-file=pyproject.toml
|
- --config-file=pyproject.toml
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- "aio-eapi==0.3.0"
|
- anta[cli]
|
||||||
- "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"
|
|
||||||
- types-PyYAML
|
- types-PyYAML
|
||||||
- types-paramiko
|
|
||||||
- types-requests
|
- types-requests
|
||||||
|
- types-pyOpenSSL
|
||||||
|
- pytest
|
||||||
files: ^(anta|tests)/
|
files: ^(anta|tests)/
|
||||||
|
|
28
Dockerfile
28
Dockerfile
|
@ -3,23 +3,30 @@ ARG IMG_OPTION=alpine
|
||||||
|
|
||||||
### BUILDER
|
### BUILDER
|
||||||
|
|
||||||
FROM python:${PYTHON_VER}-${IMG_OPTION} as BUILDER
|
FROM python:${PYTHON_VER}-${IMG_OPTION} AS BUILDER
|
||||||
|
|
||||||
RUN pip install --upgrade pip
|
RUN pip install --upgrade pip
|
||||||
|
|
||||||
WORKDIR /local
|
WORKDIR /local
|
||||||
COPY . /local
|
COPY . /local
|
||||||
|
|
||||||
ENV PYTHONPATH=/local
|
RUN python -m venv /opt/venv
|
||||||
ENV PATH=$PATH:/root/.local/bin
|
|
||||||
|
|
||||||
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
|
### 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
|
# Opencontainer labels
|
||||||
# Labels version and revision will be updating
|
# 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.revision"="dev" \
|
||||||
"org.opencontainers.image.version"="dev"
|
"org.opencontainers.image.version"="dev"
|
||||||
|
|
||||||
COPY --from=BUILDER /root/.local/ /root/.local
|
# Copy artifacts from builder
|
||||||
ENV PATH=$PATH:/root/.local/bin
|
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
28
NOTICE
Normal 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.
|
|
@ -17,7 +17,7 @@ __credits__ = [
|
||||||
"Guillaume Mulocher",
|
"Guillaume Mulocher",
|
||||||
"Thomas Grimonet",
|
"Thomas Grimonet",
|
||||||
]
|
]
|
||||||
__copyright__ = "Copyright 2022, Arista EMEA AS"
|
__copyright__ = "Copyright 2022-2024, Arista Networks, Inc."
|
||||||
|
|
||||||
# ANTA Debug Mode environment variable
|
# ANTA Debug Mode environment variable
|
||||||
__DEBUG__ = bool(os.environ.get("ANTA_DEBUG", "").lower() == "true")
|
__DEBUG__ = bool(os.environ.get("ANTA_DEBUG", "").lower() == "true")
|
||||||
|
|
106
anta/aioeapi.py
106
anta/aioeapi.py
|
@ -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
|
|
145
anta/catalog.py
145
anta/catalog.py
|
@ -7,11 +7,14 @@ from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
|
from collections import defaultdict
|
||||||
from inspect import isclass
|
from inspect import isclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Optional, Union
|
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.types import ImportString
|
||||||
from pydantic_core import PydanticCustomError
|
from pydantic_core import PydanticCustomError
|
||||||
from yaml import YAMLError, safe_load
|
from yaml import YAMLError, safe_load
|
||||||
|
@ -43,6 +46,22 @@ class AntaTestDefinition(BaseModel):
|
||||||
test: type[AntaTest]
|
test: type[AntaTest]
|
||||||
inputs: AntaTest.Input
|
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:
|
def __init__(self, **data: type[AntaTest] | AntaTest.Input | dict[str, Any] | None) -> None:
|
||||||
"""Inject test in the context to allow to instantiate Input in the BeforeValidator.
|
"""Inject test in the context to allow to instantiate Input in the BeforeValidator.
|
||||||
|
|
||||||
|
@ -157,12 +176,12 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
|
||||||
if isinstance(tests, dict):
|
if isinstance(tests, dict):
|
||||||
# This is an inner Python module
|
# This is an inner Python module
|
||||||
modules.update(AntaCatalogFile.flatten_modules(data=tests, package=module.__name__))
|
modules.update(AntaCatalogFile.flatten_modules(data=tests, package=module.__name__))
|
||||||
else:
|
elif isinstance(tests, list):
|
||||||
if not isinstance(tests, list):
|
|
||||||
msg = f"Syntax error when parsing: {tests}\nIt must be a list of ANTA tests. Check the test catalog."
|
|
||||||
raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError
|
|
||||||
# This is a list of AntaTestDefinition
|
# This is a list of AntaTestDefinition
|
||||||
modules[module] = tests
|
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
|
return modules
|
||||||
|
|
||||||
# ANN401 - Any ok for this validator as we are validating the received data
|
# 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.
|
with provided value to validate test inputs.
|
||||||
"""
|
"""
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
|
if not data:
|
||||||
|
return data
|
||||||
typed_data: dict[ModuleType, list[Any]] = AntaCatalogFile.flatten_modules(data)
|
typed_data: dict[ModuleType, list[Any]] = AntaCatalogFile.flatten_modules(data)
|
||||||
for module, tests in typed_data.items():
|
for module, tests in typed_data.items():
|
||||||
test_definitions: list[AntaTestDefinition] = []
|
test_definitions: list[AntaTestDefinition] = []
|
||||||
for test_definition in tests:
|
for test_definition in tests:
|
||||||
|
if isinstance(test_definition, AntaTestDefinition):
|
||||||
|
test_definitions.append(test_definition)
|
||||||
|
continue
|
||||||
if not isinstance(test_definition, dict):
|
if not isinstance(test_definition, dict):
|
||||||
msg = f"Syntax error when parsing: {test_definition}\nIt must be a dictionary. Check the test catalog."
|
msg = f"Syntax error when parsing: {test_definition}\nIt must be a dictionary. Check the test catalog."
|
||||||
raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError
|
raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError
|
||||||
|
@ -200,7 +224,21 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
test_definitions.append(AntaTestDefinition(test=test, inputs=test_inputs))
|
test_definitions.append(AntaTestDefinition(test=test, inputs=test_inputs))
|
||||||
typed_data[module] = test_definitions
|
typed_data[module] = test_definitions
|
||||||
return typed_data
|
return typed_data
|
||||||
|
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:
|
class AntaCatalog:
|
||||||
|
@ -232,6 +270,12 @@ class AntaCatalog:
|
||||||
else:
|
else:
|
||||||
self._filename = Path(filename)
|
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
|
@property
|
||||||
def filename(self) -> Path | None:
|
def filename(self) -> Path | None:
|
||||||
"""Path of the file used to create this AntaCatalog instance."""
|
"""Path of the file used to create this AntaCatalog instance."""
|
||||||
|
@ -297,7 +341,7 @@ class AntaCatalog:
|
||||||
raise TypeError(msg)
|
raise TypeError(msg)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
catalog_data = AntaCatalogFile(**data) # type: ignore[arg-type]
|
catalog_data = AntaCatalogFile(data) # type: ignore[arg-type]
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
anta_log_exception(
|
anta_log_exception(
|
||||||
e,
|
e,
|
||||||
|
@ -328,40 +372,85 @@ class AntaCatalog:
|
||||||
raise
|
raise
|
||||||
return AntaCatalog(tests)
|
return AntaCatalog(tests)
|
||||||
|
|
||||||
def get_tests_by_tags(self, tags: set[str], *, strict: bool = False) -> list[AntaTestDefinition]:
|
def merge(self, catalog: AntaCatalog) -> AntaCatalog:
|
||||||
"""Return all the tests that have matching tags in their input filters.
|
"""Merge two AntaCatalog instances.
|
||||||
|
|
||||||
If strict=True, return only tests that match all the tags provided as input.
|
|
||||||
If strict=False, return all the tests that match at least one tag provided as input.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
----
|
----
|
||||||
tags: Tags of the tests to get.
|
catalog: AntaCatalog instance to merge to this instance.
|
||||||
strict: Specify if the returned tests must match all the tags provided.
|
|
||||||
|
|
||||||
Returns
|
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:
|
for test in self.tests:
|
||||||
if test.inputs.filters and (f := test.inputs.filters.tags):
|
# Cannot use AntaTest.module property as the class is not instantiated
|
||||||
if strict:
|
root.setdefault(test.test.__module__, []).append(test)
|
||||||
if all(t in tags for t in f):
|
return AntaCatalogFile(root=root)
|
||||||
result.append(test)
|
|
||||||
elif any(t in tags for t in f):
|
|
||||||
result.append(test)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def get_tests_by_names(self, names: set[str]) -> list[AntaTestDefinition]:
|
def build_indexes(self, filtered_tests: set[str] | None = None) -> None:
|
||||||
"""Return all the tests that have matching a list of tests names.
|
"""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:
|
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
|
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)
|
||||||
|
|
|
@ -5,70 +5,37 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import pathlib
|
|
||||||
import sys
|
import sys
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
import click
|
from anta import __DEBUG__
|
||||||
|
|
||||||
from anta import GITHUB_SUGGESTION, __version__
|
# Note: need to separate this file from _main to be able to fail on the import.
|
||||||
from anta.cli.check import check as check_command
|
try:
|
||||||
from anta.cli.debug import debug as debug_command
|
from ._main import anta, cli
|
||||||
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__)
|
except ImportError as exc:
|
||||||
|
|
||||||
|
def build_cli(exception: Exception) -> Callable[[], None]:
|
||||||
|
"""Build CLI function using the caught exception."""
|
||||||
|
|
||||||
@click.group(cls=AliasedGroup)
|
def wrap() -> None:
|
||||||
@click.pass_context
|
"""Error message if any CLI dependency is missing."""
|
||||||
@click.version_option(__version__)
|
print(
|
||||||
@click.option(
|
"The ANTA command line client could not run because the required "
|
||||||
"--log-file",
|
"dependencies were not installed.\nMake sure you've installed "
|
||||||
help="Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout.",
|
"everything with: pip install 'anta[cli]'"
|
||||||
show_envvar=True,
|
)
|
||||||
type=click.Path(file_okay=True, dir_okay=False, writable=True, path_type=pathlib.Path),
|
if __DEBUG__:
|
||||||
)
|
print(f"The caught exception was: {exception}")
|
||||||
@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)
|
|
||||||
|
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
anta.add_command(nrfu_command)
|
return wrap
|
||||||
anta.add_command(check_command)
|
|
||||||
anta.add_command(exec_command)
|
|
||||||
anta.add_command(get_command)
|
|
||||||
anta.add_command(debug_command)
|
|
||||||
|
|
||||||
|
cli = build_cli(exc)
|
||||||
|
|
||||||
def cli() -> None:
|
__all__ = ["cli", "anta"]
|
||||||
"""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)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
cli()
|
cli()
|
||||||
|
|
70
anta/cli/_main.py
Normal file
70
anta/cli/_main.py
Normal 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)
|
|
@ -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
|
# TODO: @gmuloc - tags come from context https://github.com/arista-netdevops-community/anta/issues/584
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
# ruff: noqa: ARG001
|
# ruff: noqa: ARG001
|
||||||
try:
|
if (d := inventory.get(device)) is None:
|
||||||
d = inventory[device]
|
logger.error("Device '%s' does not exist in Inventory", device)
|
||||||
except KeyError as e:
|
|
||||||
message = f"Device {device} does not exist in Inventory"
|
|
||||||
logger.error(e, message)
|
|
||||||
ctx.exit(ExitCode.USAGE_ERROR)
|
ctx.exit(ExitCode.USAGE_ERROR)
|
||||||
return f(*args, device=d, **kwargs)
|
return f(*args, device=d, **kwargs)
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ import click
|
||||||
from yaml import safe_load
|
from yaml import safe_load
|
||||||
|
|
||||||
from anta.cli.console import console
|
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
|
from anta.cli.utils import inventory_options
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
@ -29,7 +29,7 @@ logger = logging.getLogger(__name__)
|
||||||
@inventory_options
|
@inventory_options
|
||||||
def clear_counters(inventory: AntaInventory, tags: set[str] | None) -> None:
|
def clear_counters(inventory: AntaInventory, tags: set[str] | None) -> None:
|
||||||
"""Clear counter statistics on EOS devices."""
|
"""Clear counter statistics on EOS devices."""
|
||||||
asyncio.run(clear_counters_utils(inventory, tags=tags))
|
asyncio.run(utils.clear_counters(inventory, tags=tags))
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
|
@ -62,7 +62,7 @@ def snapshot(inventory: AntaInventory, tags: set[str] | None, commands_list: Pat
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.error("Error reading %s", commands_list)
|
logger.error("Error reading %s", commands_list)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
asyncio.run(collect_commands(inventory, eos_commands, output, tags=tags))
|
asyncio.run(utils.collect_commands(inventory, eos_commands, output, tags=tags))
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
|
@ -98,4 +98,4 @@ def collect_tech_support(
|
||||||
configure: bool,
|
configure: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Collect scheduled tech-support from EOS devices."""
|
"""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))
|
||||||
|
|
|
@ -14,12 +14,13 @@ import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
from aioeapi import EapiCommandError
|
|
||||||
from click.exceptions import UsageError
|
from click.exceptions import UsageError
|
||||||
from httpx import ConnectError, HTTPError
|
from httpx import ConnectError, HTTPError
|
||||||
|
|
||||||
|
from anta.custom_types import REGEXP_PATH_MARKERS
|
||||||
from anta.device import AntaDevice, AsyncEOSDevice
|
from anta.device import AntaDevice, AsyncEOSDevice
|
||||||
from anta.models import AntaCommand
|
from anta.models import AntaCommand
|
||||||
|
from asynceapi import EapiCommandError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from anta.inventory import AntaInventory
|
from anta.inventory import AntaInventory
|
||||||
|
@ -29,7 +30,7 @@ INVALID_CHAR = "`~!@#$/"
|
||||||
logger = logging.getLogger(__name__)
|
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."""
|
"""Clear counters."""
|
||||||
|
|
||||||
async def clear(dev: AntaDevice) -> None:
|
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:
|
async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "text"]) -> None:
|
||||||
outdir = Path() / root_dir / dev.name / outformat
|
outdir = Path() / root_dir / dev.name / outformat
|
||||||
outdir.mkdir(parents=True, exist_ok=True)
|
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)
|
c = AntaCommand(command=command, ofmt=outformat)
|
||||||
await dev.collect(c)
|
await dev.collect(c)
|
||||||
if not c.collected:
|
if not c.collected:
|
||||||
|
@ -72,6 +73,9 @@ async def collect_commands(
|
||||||
elif c.ofmt == "text":
|
elif c.ofmt == "text":
|
||||||
outfile = outdir / f"{safe_command}.log"
|
outfile = outdir / f"{safe_command}.log"
|
||||||
content = c.text_output
|
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:
|
with outfile.open(mode="w", encoding="UTF-8") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
logger.info("Collected command '%s' from device %s (%s)", command, dev.name, dev.hw_model)
|
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))
|
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."""
|
"""Collect scheduled show-tech on devices."""
|
||||||
|
|
||||||
async def collect(device: AntaDevice) -> None:
|
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}"
|
cmd += f" | head -{latest}"
|
||||||
command = AntaCommand(command=cmd, ofmt="text")
|
command = AntaCommand(command=cmd, ofmt="text")
|
||||||
await device.collect(command=command)
|
await device.collect(command=command)
|
||||||
if command.collected and command.text_output:
|
if not (command.collected and command.text_output):
|
||||||
filenames = [Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}") for f in command.text_output.splitlines()]
|
|
||||||
else:
|
|
||||||
logger.error("Unable to get tech-support filenames on %s: verify that %s is not empty", device.name, EOS_SCHEDULED_TECH_SUPPORT)
|
logger.error("Unable to get tech-support filenames on %s: verify that %s is not empty", device.name, EOS_SCHEDULED_TECH_SUPPORT)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
filenames = [Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}") for f in command.text_output.splitlines()]
|
||||||
|
|
||||||
# Create directories
|
# Create directories
|
||||||
outdir = Path() / root_dir / f"{device.name.lower()}"
|
outdir = Path() / root_dir / f"{device.name.lower()}"
|
||||||
outdir.mkdir(parents=True, exist_ok=True)
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
@ -119,31 +123,32 @@ async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, *, con
|
||||||
|
|
||||||
if command.collected and not command.text_output:
|
if command.collected and not command.text_output:
|
||||||
logger.debug("'aaa authorization exec default local' is not configured on device %s", device.name)
|
logger.debug("'aaa authorization exec default local' is not configured on device %s", device.name)
|
||||||
if configure:
|
if not configure:
|
||||||
commands = []
|
|
||||||
# TODO: @mtache - add `config` field to `AntaCommand` object to handle this use case.
|
|
||||||
# Otherwise mypy complains about enable as it is only implemented for AsyncEOSDevice
|
|
||||||
# TODO: Should enable be also included in AntaDevice?
|
|
||||||
if not isinstance(device, AsyncEOSDevice):
|
|
||||||
msg = "anta exec collect-tech-support is only supported with AsyncEOSDevice for now."
|
|
||||||
raise UsageError(msg)
|
|
||||||
if device.enable and device._enable_password is not None: # pylint: disable=protected-access
|
|
||||||
commands.append({"cmd": "enable", "input": device._enable_password}) # pylint: disable=protected-access
|
|
||||||
elif device.enable:
|
|
||||||
commands.append({"cmd": "enable"})
|
|
||||||
commands.extend(
|
|
||||||
[
|
|
||||||
{"cmd": "configure terminal"},
|
|
||||||
{"cmd": "aaa authorization exec default local"},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
logger.warning("Configuring 'aaa authorization exec default local' on device %s", device.name)
|
|
||||||
command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text")
|
|
||||||
await device._session.cli(commands=commands) # pylint: disable=protected-access
|
|
||||||
logger.info("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)
|
logger.error("Unable to collect tech-support on %s: configuration 'aaa authorization exec default local' is not present", device.name)
|
||||||
return
|
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
|
||||||
|
# TODO: Should enable be also included in AntaDevice?
|
||||||
|
if not isinstance(device, AsyncEOSDevice):
|
||||||
|
msg = "anta exec collect-tech-support is only supported with AsyncEOSDevice for now."
|
||||||
|
raise UsageError(msg)
|
||||||
|
if device.enable and device._enable_password is not None: # pylint: disable=protected-access
|
||||||
|
commands.append({"cmd": "enable", "input": device._enable_password}) # pylint: disable=protected-access
|
||||||
|
elif device.enable:
|
||||||
|
commands.append({"cmd": "enable"})
|
||||||
|
commands.extend(
|
||||||
|
[
|
||||||
|
{"cmd": "configure terminal"},
|
||||||
|
{"cmd": "aaa authorization exec default local"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger.warning("Configuring 'aaa authorization exec default local' on device %s", device.name)
|
||||||
|
command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text")
|
||||||
|
await device._session.cli(commands=commands) # pylint: disable=protected-access
|
||||||
|
logger.info("Configured 'aaa authorization exec default local' on device %s", device.name)
|
||||||
|
|
||||||
logger.debug("'aaa authorization exec default local' is already configured on device %s", device.name)
|
logger.debug("'aaa authorization exec default local' is already configured on device %s", device.name)
|
||||||
|
|
||||||
await device.copy(sources=filenames, destination=outdir, direction="from")
|
await device.copy(sources=filenames, destination=outdir, direction="from")
|
||||||
|
|
|
@ -76,7 +76,11 @@ def from_cvp(ctx: click.Context, output: Path, host: str, username: str, passwor
|
||||||
required=True,
|
required=True,
|
||||||
)
|
)
|
||||||
def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_inventory: Path) -> None:
|
def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_inventory: Path) -> None:
|
||||||
"""Build ANTA inventory from an ansible inventory YAML file."""
|
"""Build ANTA inventory from an ansible inventory YAML file.
|
||||||
|
|
||||||
|
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)
|
logger.info("Building inventory from ansible file '%s'", ansible_inventory)
|
||||||
try:
|
try:
|
||||||
create_inventory_from_ansible(
|
create_inventory_from_ansible(
|
||||||
|
|
|
@ -154,6 +154,15 @@ def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group:
|
||||||
try:
|
try:
|
||||||
with inventory.open(encoding="utf-8") as inv:
|
with inventory.open(encoding="utf-8") as inv:
|
||||||
ansible_inventory = yaml.safe_load(inv)
|
ansible_inventory = yaml.safe_load(inv)
|
||||||
|
except 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:
|
except OSError as exc:
|
||||||
msg = f"Could not parse {inventory}."
|
msg = f"Could not parse {inventory}."
|
||||||
raise ValueError(msg) from exc
|
raise ValueError(msg) from exc
|
||||||
|
|
|
@ -99,6 +99,14 @@ HIDE_STATUS.remove("unset")
|
||||||
help="Group result by test or device.",
|
help="Group result by test or device.",
|
||||||
required=False,
|
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
|
# pylint: disable=too-many-arguments
|
||||||
def nrfu(
|
def nrfu(
|
||||||
ctx: click.Context,
|
ctx: click.Context,
|
||||||
|
@ -111,6 +119,7 @@ def nrfu(
|
||||||
*,
|
*,
|
||||||
ignore_status: bool,
|
ignore_status: bool,
|
||||||
ignore_error: bool,
|
ignore_error: bool,
|
||||||
|
dry_run: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run ANTA tests on selected inventory devices."""
|
"""Run ANTA tests on selected inventory devices."""
|
||||||
# If help is invoke somewhere, skip the command
|
# If help is invoke somewhere, skip the command
|
||||||
|
@ -124,7 +133,19 @@ def nrfu(
|
||||||
ctx.obj["hide"] = set(hide) if hide else None
|
ctx.obj["hide"] = set(hide) if hide else None
|
||||||
print_settings(inventory, catalog)
|
print_settings(inventory, catalog)
|
||||||
with anta_progress_bar() as AntaTest.progress:
|
with anta_progress_bar() as AntaTest.progress:
|
||||||
asyncio.run(main(ctx.obj["result_manager"], inventory, catalog, tags=tags, 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
|
# Invoke `anta nrfu table` if no command is passed
|
||||||
if ctx.invoked_subcommand is None:
|
if ctx.invoked_subcommand is None:
|
||||||
ctx.invoke(commands.table)
|
ctx.invoke(commands.table)
|
||||||
|
|
|
@ -38,7 +38,7 @@ def print_settings(
|
||||||
catalog: AntaCatalog,
|
catalog: AntaCatalog,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Print ANTA settings before running tests."""
|
"""Print ANTA settings before running tests."""
|
||||||
message = f"Running ANTA tests:\n- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests"
|
message = f"- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests"
|
||||||
console.print(Panel.fit(message, style="cyan", title="[green]Settings"))
|
console.print(Panel.fit(message, style="cyan", title="[green]Settings"))
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,6 @@ from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Callable
|
from typing import TYPE_CHECKING, Any, Callable
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from pydantic import ValidationError
|
|
||||||
from yaml import YAMLError
|
from yaml import YAMLError
|
||||||
|
|
||||||
from anta.catalog import AntaCatalog
|
from anta.catalog import AntaCatalog
|
||||||
|
@ -254,7 +253,7 @@ def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
insecure=insecure,
|
insecure=insecure,
|
||||||
disable_cache=disable_cache,
|
disable_cache=disable_cache,
|
||||||
)
|
)
|
||||||
except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError):
|
except (TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError):
|
||||||
ctx.exit(ExitCode.USAGE_ERROR)
|
ctx.exit(ExitCode.USAGE_ERROR)
|
||||||
return f(*args, inventory=i, tags=tags, **kwargs)
|
return f(*args, inventory=i, tags=tags, **kwargs)
|
||||||
|
|
||||||
|
@ -292,7 +291,7 @@ def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
return f(*args, catalog=None, **kwargs)
|
return f(*args, catalog=None, **kwargs)
|
||||||
try:
|
try:
|
||||||
c = AntaCatalog.parse(catalog)
|
c = AntaCatalog.parse(catalog)
|
||||||
except (ValidationError, TypeError, ValueError, YAMLError, OSError):
|
except (TypeError, ValueError, YAMLError, OSError):
|
||||||
ctx.exit(ExitCode.USAGE_ERROR)
|
ctx.exit(ExitCode.USAGE_ERROR)
|
||||||
return f(*args, catalog=c, **kwargs)
|
return f(*args, catalog=c, **kwargs)
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,31 @@ from typing import Annotated, Literal
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic.functional_validators import AfterValidator, BeforeValidator
|
from pydantic.functional_validators import AfterValidator, BeforeValidator
|
||||||
|
|
||||||
|
# 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:
|
def aaa_group_prefix(v: str) -> str:
|
||||||
"""Prefix the AAA method with 'group' if it is known."""
|
"""Prefix the AAA method with 'group' if it is known."""
|
||||||
|
@ -24,7 +49,7 @@ def interface_autocomplete(v: str) -> str:
|
||||||
- `po` will be changed to `Port-Channel`
|
- `po` will be changed to `Port-Channel`
|
||||||
- `lo` will be changed to `Loopback`
|
- `lo` will be changed to `Loopback`
|
||||||
"""
|
"""
|
||||||
intf_id_re = re.compile(r"[0-9]+(\/[0-9]+)*(\.[0-9]+)?")
|
intf_id_re = re.compile(REGEXP_INTERFACE_ID)
|
||||||
m = intf_id_re.search(v)
|
m = intf_id_re.search(v)
|
||||||
if m is None:
|
if m is None:
|
||||||
msg = f"Could not parse interface ID in interface '{v}'"
|
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"}
|
alias_map = {"et": "Ethernet", "eth": "Ethernet", "po": "Port-Channel", "lo": "Loopback"}
|
||||||
|
|
||||||
for alias, full_name in alias_map.items():
|
return next((f"{full_name}{intf_id}" for alias, full_name in alias_map.items() if v.lower().startswith(alias)), v)
|
||||||
if v.lower().startswith(alias):
|
|
||||||
return f"{full_name}{intf_id}"
|
|
||||||
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
def interface_case_sensitivity(v: str) -> str:
|
def interface_case_sensitivity(v: str) -> str:
|
||||||
|
@ -50,7 +71,7 @@ def interface_case_sensitivity(v: str) -> str:
|
||||||
- loopback -> Loopback
|
- 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 f"{v[0].upper()}{v[1:]}"
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@ -67,10 +88,10 @@ def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
patterns = {
|
patterns = {
|
||||||
r"\b(l2[\s\-]?vpn[\s\-]?evpn)\b": "l2VpnEvpn",
|
REGEXP_BGP_L2VPN_AFI: "l2VpnEvpn",
|
||||||
r"\bipv4[\s_-]?mpls[\s_-]?label(s)?\b": "ipv4MplsLabels",
|
REGEXP_BGP_IPV4_MPLS_LABELS: "ipv4MplsLabels",
|
||||||
r"\bipv4[\s_-]?mpls[\s_-]?vpn\b": "ipv4MplsVpn",
|
REGEX_BGP_IPV4_MPLS_VPN: "ipv4MplsVpn",
|
||||||
r"\bipv4[\s_-]?uni[\s_-]?cast\b": "ipv4Unicast",
|
REGEX_BGP_IPV4_UNICAST: "ipv4Unicast",
|
||||||
}
|
}
|
||||||
|
|
||||||
for pattern, replacement in patterns.items():
|
for pattern, replacement in patterns.items():
|
||||||
|
@ -81,6 +102,16 @@ def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str:
|
||||||
return value
|
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
|
# ANTA framework
|
||||||
TestStatus = Literal["unset", "success", "failure", "error", "skipped"]
|
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)]
|
Vni = Annotated[int, Field(ge=1, le=16777215)]
|
||||||
Interface = Annotated[
|
Interface = Annotated[
|
||||||
str,
|
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_autocomplete),
|
||||||
BeforeValidator(interface_case_sensitivity),
|
BeforeValidator(interface_case_sensitivity),
|
||||||
]
|
]
|
||||||
VxlanSrcIntf = Annotated[
|
VxlanSrcIntf = Annotated[
|
||||||
str,
|
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_autocomplete),
|
||||||
BeforeValidator(interface_case_sensitivity),
|
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"]
|
Safi = Literal["unicast", "multicast", "labeled-unicast", "sr-te"]
|
||||||
EncryptionAlgorithm = Literal["RSA", "ECDSA"]
|
EncryptionAlgorithm = Literal["RSA", "ECDSA"]
|
||||||
RsaKeySize = Literal[2048, 3072, 4096]
|
RsaKeySize = Literal[2048, 3072, 4096]
|
||||||
EcdsaKeySize = Literal[256, 384, 521]
|
EcdsaKeySize = Literal[256, 384, 512]
|
||||||
MultiProtocolCaps = Annotated[str, BeforeValidator(bgp_multiprotocol_capabilities_abbreviations)]
|
MultiProtocolCaps = Annotated[str, BeforeValidator(bgp_multiprotocol_capabilities_abbreviations)]
|
||||||
BfdInterval = Annotated[int, Field(ge=50, le=60000)]
|
BfdInterval = Annotated[int, Field(ge=50, le=60000)]
|
||||||
BfdMultiplier = Annotated[int, Field(ge=3, le=50)]
|
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)]
|
Percent = Annotated[float, Field(ge=0.0, le=100.0)]
|
||||||
PositiveInteger = Annotated[int, Field(ge=0)]
|
PositiveInteger = Annotated[int, Field(ge=0)]
|
||||||
Revision = Annotated[int, Field(ge=1, le=99)]
|
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)]
|
Port = Annotated[int, Field(ge=1, le=65535)]
|
||||||
|
RegexString = Annotated[str, AfterValidator(validate_regex)]
|
||||||
|
|
|
@ -18,7 +18,8 @@ from aiocache.plugins import HitMissRatioPlugin
|
||||||
from asyncssh import SSHClientConnection, SSHClientConnectionOptions
|
from asyncssh import SSHClientConnection, SSHClientConnectionOptions
|
||||||
from httpx import ConnectError, HTTPError, TimeoutException
|
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.logger import anta_log_exception, exc_to_str
|
||||||
from anta.models import AntaCommand
|
from anta.models import AntaCommand
|
||||||
|
|
||||||
|
@ -116,7 +117,7 @@ class AntaDevice(ABC):
|
||||||
yield "disable_cache", self.cache is None
|
yield "disable_cache", self.cache is None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def _collect(self, command: AntaCommand) -> None:
|
async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None:
|
||||||
"""Collect device command output.
|
"""Collect device command output.
|
||||||
|
|
||||||
This abstract coroutine can be used to implement any command collection method
|
This abstract coroutine can be used to implement any command collection method
|
||||||
|
@ -131,11 +132,11 @@ class AntaDevice(ABC):
|
||||||
|
|
||||||
Args:
|
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.
|
"""Collect the output for a specified command.
|
||||||
|
|
||||||
When caching is activated on both the device and the command,
|
When caching is activated on both the device and the command,
|
||||||
|
@ -148,8 +149,8 @@ class AntaDevice(ABC):
|
||||||
|
|
||||||
Args:
|
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
|
# Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough
|
||||||
# https://github.com/pylint-dev/pylint/issues/7258
|
# https://github.com/pylint-dev/pylint/issues/7258
|
||||||
|
@ -161,20 +162,20 @@ class AntaDevice(ABC):
|
||||||
logger.debug("Cache hit for %s on %s", command.command, self.name)
|
logger.debug("Cache hit for %s on %s", command.command, self.name)
|
||||||
command.output = cached_output
|
command.output = cached_output
|
||||||
else:
|
else:
|
||||||
await self._collect(command=command)
|
await self._collect(command=command, collection_id=collection_id)
|
||||||
await self.cache.set(command.uid, command.output) # pylint: disable=no-member
|
await self.cache.set(command.uid, command.output) # pylint: disable=no-member
|
||||||
else:
|
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.
|
"""Collect multiple commands.
|
||||||
|
|
||||||
Args:
|
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
|
@abstractmethod
|
||||||
async def refresh(self) -> None:
|
async def refresh(self) -> None:
|
||||||
|
@ -270,7 +271,7 @@ class AsyncEOSDevice(AntaDevice):
|
||||||
raise ValueError(message)
|
raise ValueError(message)
|
||||||
self.enable = enable
|
self.enable = enable
|
||||||
self._enable_password = enable_password
|
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] = {}
|
ssh_params: dict[str, Any] = {}
|
||||||
if insecure:
|
if insecure:
|
||||||
ssh_params["known_hosts"] = None
|
ssh_params["known_hosts"] = None
|
||||||
|
@ -305,7 +306,7 @@ class AsyncEOSDevice(AntaDevice):
|
||||||
"""
|
"""
|
||||||
return (self._session.host, self._session.port)
|
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.
|
"""Collect device command output from EOS using aio-eapi.
|
||||||
|
|
||||||
Supports outformat `json` and `text` as output structure.
|
Supports outformat `json` and `text` as output structure.
|
||||||
|
@ -314,9 +315,10 @@ class AsyncEOSDevice(AntaDevice):
|
||||||
|
|
||||||
Args:
|
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:
|
if self.enable and self._enable_password is not None:
|
||||||
commands.append(
|
commands.append(
|
||||||
{
|
{
|
||||||
|
@ -329,14 +331,15 @@ class AsyncEOSDevice(AntaDevice):
|
||||||
commands.append({"cmd": "enable"})
|
commands.append({"cmd": "enable"})
|
||||||
commands += [{"cmd": command.command, "revision": command.revision}] if command.revision else [{"cmd": command.command}]
|
commands += [{"cmd": command.command, "revision": command.revision}] if command.revision else [{"cmd": command.command}]
|
||||||
try:
|
try:
|
||||||
response: list[dict[str, Any]] = await self._session.cli(
|
response: list[dict[str, Any] | str] = await self._session.cli(
|
||||||
commands=commands,
|
commands=commands,
|
||||||
ofmt=command.ofmt,
|
ofmt=command.ofmt,
|
||||||
version=command.version,
|
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
|
# Do not keep response of 'enable' command
|
||||||
command.output = response[-1]
|
command.output = response[-1]
|
||||||
except aioeapi.EapiCommandError as e:
|
except asynceapi.EapiCommandError as e:
|
||||||
# This block catches exceptions related to EOS issuing an error.
|
# This block catches exceptions related to EOS issuing an error.
|
||||||
command.errors = e.errors
|
command.errors = e.errors
|
||||||
if command.requires_privileges:
|
if command.requires_privileges:
|
||||||
|
|
|
@ -7,6 +7,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
|
from datetime import timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, Literal
|
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")
|
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:
|
def exc_to_str(exception: BaseException) -> str:
|
||||||
"""Return a human readable string from an BaseException object."""
|
"""Return a human readable string from an BaseException object."""
|
||||||
return f"{type(exception).__name__}{f': {exception}' if str(exception) else ''}"
|
return f"{type(exception).__name__}{f': {exception}' if str(exception) else ''}"
|
||||||
|
|
164
anta/models.py
164
anta/models.py
|
@ -8,10 +8,7 @@ from __future__ import annotations
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from copy import deepcopy
|
|
||||||
from datetime import timedelta
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from string import Formatter
|
from string import Formatter
|
||||||
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, TypeVar
|
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 pydantic import BaseModel, ConfigDict, ValidationError, create_model
|
||||||
|
|
||||||
from anta import GITHUB_SUGGESTION
|
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.logger import anta_log_exception, exc_to_str
|
||||||
from anta.result_manager.models import TestResult
|
from anta.result_manager.models import TestResult
|
||||||
|
|
||||||
|
@ -35,9 +32,6 @@ F = TypeVar("F", bound=Callable[..., Any])
|
||||||
# This would imply overhead to define classes
|
# This would imply overhead to define classes
|
||||||
# https://stackoverflow.com/questions/74103528/type-hinting-an-instance-of-a-nested-class
|
# https://stackoverflow.com/questions/74103528/type-hinting-an-instance-of-a-nested-class
|
||||||
|
|
||||||
# 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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -46,19 +40,8 @@ class AntaParamsBaseModel(BaseModel):
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
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:
|
||||||
class AntaTemplate(BaseModel):
|
|
||||||
"""Class to define a command template as Python f-string.
|
"""Class to define a command template as Python f-string.
|
||||||
|
|
||||||
Can render a command from parameters.
|
Can render a command from parameters.
|
||||||
|
@ -70,14 +53,42 @@ class AntaTemplate(BaseModel):
|
||||||
revision: Revision of the command. Valid values are 1 to 99. Revision has precedence over version.
|
revision: Revision of the command. Valid values are 1 to 99. Revision has precedence over version.
|
||||||
ofmt: eAPI output - json or text.
|
ofmt: eAPI output - json or text.
|
||||||
use_cache: Enable or disable caching for this AntaTemplate if the AntaDevice supports it.
|
use_cache: Enable or disable caching for this AntaTemplate if the AntaDevice supports it.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template: str
|
# pylint: disable=too-few-public-methods
|
||||||
version: Literal[1, "latest"] = "latest"
|
|
||||||
revision: Revision | None = None
|
def __init__( # noqa: PLR0913
|
||||||
ofmt: Literal["json", "text"] = "json"
|
self,
|
||||||
use_cache: bool = True
|
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:
|
def render(self, **params: str | int | bool) -> AntaCommand:
|
||||||
"""Render an AntaCommand from an AntaTemplate instance.
|
"""Render an AntaCommand from an AntaTemplate instance.
|
||||||
|
@ -90,34 +101,28 @@ class AntaTemplate(BaseModel):
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
command: The rendered AntaCommand.
|
The rendered AntaCommand.
|
||||||
This AntaCommand instance have a template attribute that references this
|
This AntaCommand instance have a template attribute that references this
|
||||||
AntaTemplate instance.
|
AntaTemplate instance.
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
return AntaCommand(
|
command = self.template.format(**params)
|
||||||
command=self.template.format(**params),
|
except (KeyError, SyntaxError) as e:
|
||||||
ofmt=self.ofmt,
|
|
||||||
version=self.version,
|
|
||||||
revision=self.revision,
|
|
||||||
template=self,
|
|
||||||
params=ParamsSchema(**params),
|
|
||||||
use_cache=self.use_cache,
|
|
||||||
)
|
|
||||||
except KeyError as e:
|
|
||||||
raise AntaTemplateRenderError(self, e.args[0]) from e
|
raise AntaTemplateRenderError(self, e.args[0]) from e
|
||||||
|
return AntaCommand(
|
||||||
|
command=command,
|
||||||
|
ofmt=self.ofmt,
|
||||||
|
version=self.version,
|
||||||
|
revision=self.revision,
|
||||||
|
template=self,
|
||||||
|
params=self.params_schema(**params),
|
||||||
|
use_cache=self.use_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AntaCommand(BaseModel):
|
class AntaCommand(BaseModel):
|
||||||
|
@ -148,6 +153,8 @@ class AntaCommand(BaseModel):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
command: str
|
command: str
|
||||||
version: Literal[1, "latest"] = "latest"
|
version: Literal[1, "latest"] = "latest"
|
||||||
revision: Revision | None = None
|
revision: Revision | None = None
|
||||||
|
@ -273,14 +280,13 @@ class AntaTest(ABC):
|
||||||
vrf: str = "default"
|
vrf: str = "default"
|
||||||
|
|
||||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
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
|
@AntaTest.anta_test
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
failures = []
|
failures = []
|
||||||
for command in self.instance_commands:
|
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]:
|
if "2 received" not in command.json_output["messages"][0]:
|
||||||
failures.append((str(src), str(dst)))
|
failures.append((str(src), str(dst)))
|
||||||
if not failures:
|
if not failures:
|
||||||
|
@ -288,13 +294,14 @@ class AntaTest(ABC):
|
||||||
else:
|
else:
|
||||||
self.result.is_failure(f"Connectivity test failed for the following source-destination pairs: {failures}")
|
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
|
device: AntaDevice instance on which this test is run
|
||||||
inputs: AntaTest.Input instance carrying the test inputs
|
inputs: AntaTest.Input instance carrying the test inputs
|
||||||
instance_commands: List of AntaCommand instances of this test
|
instance_commands: List of AntaCommand instances of this test
|
||||||
result: TestResult instance representing the result of this test
|
result: TestResult instance representing the result of this test
|
||||||
logger: Python logger for this test instance
|
logger: Python logger for this test instance
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Mandatory class attributes
|
# Mandatory class attributes
|
||||||
|
@ -322,9 +329,10 @@ class AntaTest(ABC):
|
||||||
description: "Test with overwritten description"
|
description: "Test with overwritten description"
|
||||||
custom_field: "Test run by John Doe"
|
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")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
@ -360,7 +368,6 @@ class AntaTest(ABC):
|
||||||
Attributes
|
Attributes
|
||||||
----------
|
----------
|
||||||
tags: Tag of devices on which to run the test.
|
tags: Tag of devices on which to run the test.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
@ -380,9 +387,8 @@ class AntaTest(ABC):
|
||||||
inputs: dictionary of attributes used to instantiate the AntaTest.Input instance
|
inputs: dictionary of attributes used to instantiate the AntaTest.Input instance
|
||||||
eos_data: Populate outputs of the test commands instead of collecting from devices.
|
eos_data: Populate outputs of the test commands instead of collecting from devices.
|
||||||
This list must have the same length and order than the `instance_commands` instance attribute.
|
This list must have the same length and order than the `instance_commands` instance attribute.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self.logger: logging.Logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}")
|
self.logger: logging.Logger = logging.getLogger(f"{self.module}.{self.__class__.__name__}")
|
||||||
self.device: AntaDevice = device
|
self.device: AntaDevice = device
|
||||||
self.inputs: AntaTest.Input
|
self.inputs: AntaTest.Input
|
||||||
self.instance_commands: list[AntaCommand] = []
|
self.instance_commands: list[AntaCommand] = []
|
||||||
|
@ -411,7 +417,7 @@ class AntaTest(ABC):
|
||||||
elif isinstance(inputs, dict):
|
elif isinstance(inputs, dict):
|
||||||
self.inputs = self.Input(**inputs)
|
self.inputs = self.Input(**inputs)
|
||||||
except ValidationError as e:
|
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.logger.error(message)
|
||||||
self.result.is_error(message=message)
|
self.result.is_error(message=message)
|
||||||
return
|
return
|
||||||
|
@ -434,7 +440,7 @@ class AntaTest(ABC):
|
||||||
if self.__class__.commands:
|
if self.__class__.commands:
|
||||||
for cmd in self.__class__.commands:
|
for cmd in self.__class__.commands:
|
||||||
if isinstance(cmd, AntaCommand):
|
if isinstance(cmd, AntaCommand):
|
||||||
self.instance_commands.append(deepcopy(cmd))
|
self.instance_commands.append(cmd.model_copy())
|
||||||
elif isinstance(cmd, AntaTemplate):
|
elif isinstance(cmd, AntaTemplate):
|
||||||
try:
|
try:
|
||||||
self.instance_commands.extend(self.render(cmd))
|
self.instance_commands.extend(self.render(cmd))
|
||||||
|
@ -448,7 +454,7 @@ class AntaTest(ABC):
|
||||||
# render() is user-defined code.
|
# render() is user-defined code.
|
||||||
# We need to catch everything if we want the AntaTest object
|
# We need to catch everything if we want the AntaTest object
|
||||||
# to live until the reporting
|
# 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)
|
anta_log_exception(e, message, self.logger)
|
||||||
self.result.is_error(message=f"{message}: {exc_to_str(e)}")
|
self.result.is_error(message=f"{message}: {exc_to_str(e)}")
|
||||||
return
|
return
|
||||||
|
@ -476,14 +482,19 @@ class AntaTest(ABC):
|
||||||
msg = f"Class {cls.__module__}.{cls.__name__} is missing required class attribute {attr}"
|
msg = f"Class {cls.__module__}.{cls.__name__} is missing required class attribute {attr}"
|
||||||
raise NotImplementedError(msg)
|
raise NotImplementedError(msg)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def module(self) -> str:
|
||||||
|
"""Return the Python module in which this AntaTest class is defined."""
|
||||||
|
return self.__module__
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def collected(self) -> bool:
|
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)
|
return all(command.collected for command in self.instance_commands)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def failed_commands(self) -> list[AntaCommand]:
|
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]
|
return [command for command in self.instance_commands if command.error]
|
||||||
|
|
||||||
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
def render(self, template: AntaTemplate) -> list[AntaCommand]:
|
||||||
|
@ -493,7 +504,7 @@ class AntaTest(ABC):
|
||||||
no AntaTemplate for this test.
|
no AntaTemplate for this test.
|
||||||
"""
|
"""
|
||||||
_ = template
|
_ = 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)
|
raise NotImplementedError(msg)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -501,12 +512,12 @@ class AntaTest(ABC):
|
||||||
"""Check if CLI commands contain a blocked keyword."""
|
"""Check if CLI commands contain a blocked keyword."""
|
||||||
state = False
|
state = False
|
||||||
for command in self.instance_commands:
|
for command in self.instance_commands:
|
||||||
for pattern in BLACKLIST_REGEX:
|
for pattern in REGEXP_EOS_BLACKLIST_CMDS:
|
||||||
if re.match(pattern, command.command):
|
if re.match(pattern, command.command):
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"Command <%s> is blocked for security reason matching %s",
|
"Command <%s> is blocked for security reason matching %s",
|
||||||
command.command,
|
command.command,
|
||||||
BLACKLIST_REGEX,
|
REGEXP_EOS_BLACKLIST_CMDS,
|
||||||
)
|
)
|
||||||
self.result.is_error(f"<{command.command}> is blocked for security reason")
|
self.result.is_error(f"<{command.command}> is blocked for security reason")
|
||||||
state = True
|
state = True
|
||||||
|
@ -516,7 +527,7 @@ class AntaTest(ABC):
|
||||||
"""Collect outputs of all commands of this test class from the device of this test instance."""
|
"""Collect outputs of all commands of this test class from the device of this test instance."""
|
||||||
try:
|
try:
|
||||||
if self.blocked is False:
|
if self.blocked is False:
|
||||||
await self.device.collect_commands(self.instance_commands)
|
await self.device.collect_commands(self.instance_commands, collection_id=self.name)
|
||||||
except Exception as e: # pylint: disable=broad-exception-caught
|
except Exception as e: # pylint: disable=broad-exception-caught
|
||||||
# device._collect() is user-defined code.
|
# device._collect() is user-defined code.
|
||||||
# We need to catch everything if we want the AntaTest object
|
# 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
|
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":
|
if self.result.result != "unset":
|
||||||
return self.result
|
return self.result
|
||||||
|
|
||||||
|
@ -575,6 +580,7 @@ class AntaTest(ABC):
|
||||||
if not self.collected:
|
if not self.collected:
|
||||||
await self.collect()
|
await self.collect()
|
||||||
if self.result.result != "unset":
|
if self.result.result != "unset":
|
||||||
|
AntaTest.update_progress()
|
||||||
return self.result
|
return self.result
|
||||||
|
|
||||||
if cmds := self.failed_commands:
|
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}"
|
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.logger.warning(msg)
|
||||||
self.result.is_skipped("\n".join(unsupported_commands))
|
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]))
|
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
|
return self.result
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -597,10 +604,7 @@ class AntaTest(ABC):
|
||||||
anta_log_exception(e, message, self.logger)
|
anta_log_exception(e, message, self.logger)
|
||||||
self.result.is_error(message=exc_to_str(e))
|
self.result.is_error(message=exc_to_str(e))
|
||||||
|
|
||||||
test_duration = time.time() - start_time
|
# TODO: find a correct way to time test execution
|
||||||
msg = f"Executing test {self.name} on device {self.device.name} took {format_td(test_duration)}"
|
|
||||||
self.logger.debug(msg)
|
|
||||||
|
|
||||||
AntaTest.update_progress()
|
AntaTest.update_progress()
|
||||||
return self.result
|
return self.result
|
||||||
|
|
||||||
|
|
|
@ -215,12 +215,12 @@ class ReportJinja:
|
||||||
|
|
||||||
def __init__(self, template_path: pathlib.Path) -> None:
|
def __init__(self, template_path: pathlib.Path) -> None:
|
||||||
"""Create a ReportJinja instance."""
|
"""Create a ReportJinja instance."""
|
||||||
if template_path.is_file():
|
if not template_path.is_file():
|
||||||
self.tempalte_path = template_path
|
|
||||||
else:
|
|
||||||
msg = f"template file is not found: {template_path}"
|
msg = f"template file is not found: {template_path}"
|
||||||
raise FileNotFoundError(msg)
|
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:
|
def render(self, data: list[dict[str, Any]], *, trim_blocks: bool = True, lstrip_blocks: bool = True) -> str:
|
||||||
"""Build a report based on a Jinja2 template.
|
"""Build a report based on a Jinja2 template.
|
||||||
|
|
||||||
|
@ -250,7 +250,7 @@ class ReportJinja:
|
||||||
Rendered template
|
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)
|
template = Template(file_.read(), trim_blocks=trim_blocks, lstrip_blocks=lstrip_blocks)
|
||||||
|
|
||||||
return template.render({"data": data})
|
return template.render({"data": data})
|
||||||
|
|
|
@ -48,19 +48,25 @@ class ResultManager:
|
||||||
manager.results
|
manager.results
|
||||||
[
|
[
|
||||||
TestResult(
|
TestResult(
|
||||||
host=IPv4Address('192.168.0.10'),
|
name="pf1",
|
||||||
test='VerifyNTP',
|
test="VerifyZeroTouch",
|
||||||
result='failure',
|
categories=["configuration"],
|
||||||
message="device is not running NTP correctly"
|
description="Verifies ZeroTouch is disabled",
|
||||||
|
result="success",
|
||||||
|
messages=[],
|
||||||
|
custom_field=None,
|
||||||
),
|
),
|
||||||
TestResult(
|
TestResult(
|
||||||
host=IPv4Address('192.168.0.10'),
|
name="pf1",
|
||||||
test='VerifyEOSVersion',
|
test='VerifyNTP',
|
||||||
result='success',
|
categories=["software"],
|
||||||
message=None
|
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:
|
def __init__(self) -> None:
|
||||||
|
|
287
anta/runner.py
287
anta/runner.py
|
@ -1,7 +1,6 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
# pylint: disable=too-many-branches
|
|
||||||
"""ANTA runner function."""
|
"""ANTA runner function."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
@ -10,31 +9,51 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import resource
|
import resource
|
||||||
from typing import TYPE_CHECKING
|
from collections import defaultdict
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from anta import GITHUB_SUGGESTION
|
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.logger import anta_log_exception, exc_to_str
|
||||||
from anta.models import AntaTest
|
from anta.models import AntaTest
|
||||||
|
from anta.tools import Catchtime, cprofile
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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.inventory import AntaInventory
|
||||||
from anta.result_manager import ResultManager
|
from anta.result_manager import ResultManager
|
||||||
|
from anta.result_manager.models import TestResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
DEFAULT_NOFILE = 16384
|
||||||
try:
|
|
||||||
__NOFILE__ = int(os.environ.get("ANTA_NOFILE", DEFAULT_NOFILE))
|
|
||||||
except ValueError as exception:
|
def adjust_rlimit_nofile() -> tuple[int, int]:
|
||||||
logger.warning("The ANTA_NOFILE environment variable value is invalid: %s\nDefault to %s.", exc_to_str(exception), DEFAULT_NOFILE)
|
"""Adjust the maximum number of open file descriptors for the ANTA process.
|
||||||
__NOFILE__ = DEFAULT_NOFILE
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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:
|
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)
|
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,
|
manager: ResultManager,
|
||||||
inventory: AntaInventory,
|
inventory: AntaInventory,
|
||||||
catalog: AntaCatalog,
|
catalog: AntaCatalog,
|
||||||
|
@ -65,6 +197,7 @@ async def main( # noqa: PLR0912 PLR0913 too-many-branches too-many-arguments -
|
||||||
tags: set[str] | None = None,
|
tags: set[str] | None = None,
|
||||||
*,
|
*,
|
||||||
established_only: bool = True,
|
established_only: bool = True,
|
||||||
|
dry_run: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
# pylint: disable=too-many-arguments
|
# pylint: disable=too-many-arguments
|
||||||
"""Run ANTA.
|
"""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.
|
manager: ResultManager object to populate with the test results.
|
||||||
inventory: AntaInventory object that includes the device(s).
|
inventory: AntaInventory object that includes the device(s).
|
||||||
catalog: AntaCatalog object that includes the list of tests.
|
catalog: AntaCatalog object that includes the list of tests.
|
||||||
devices: devices on which to run tests. None means all devices.
|
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.
|
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.
|
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).
|
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)
|
# Adjust the maximum number of open file descriptors for the ANTA process
|
||||||
logger.debug("Initial limit numbers for open file descriptors for the current ANTA process: Soft Limit: %s | Hard Limit: %s", limits[0], limits[1])
|
limits = adjust_rlimit_nofile()
|
||||||
nofile = __NOFILE__ if limits[1] > __NOFILE__ else limits[1]
|
|
||||||
logger.debug("Setting soft limit for open file descriptors for the current ANTA process to %s", nofile)
|
|
||||||
resource.setrlimit(resource.RLIMIT_NOFILE, (nofile, limits[1]))
|
|
||||||
limits = resource.getrlimit(resource.RLIMIT_NOFILE)
|
|
||||||
|
|
||||||
if not catalog.tests:
|
if not catalog.tests:
|
||||||
logger.info("The list of tests is empty, exiting")
|
logger.info("The list of tests is empty, exiting")
|
||||||
return
|
return
|
||||||
if len(inventory) == 0:
|
|
||||||
logger.info("The inventory is empty, exiting")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Filter the inventory based on tags and devices parameters
|
with Catchtime(logger=logger, message="Preparing ANTA NRFU Run"):
|
||||||
selected_inventory = inventory.get_inventory(
|
# Setup the inventory
|
||||||
tags=tags,
|
selected_inventory = inventory if dry_run else await setup_inventory(inventory, tags, devices, established_only=established_only)
|
||||||
devices=devices,
|
if selected_inventory is None:
|
||||||
)
|
return
|
||||||
await selected_inventory.connect_inventory()
|
|
||||||
|
|
||||||
# Remove devices that are unreachable
|
with Catchtime(logger=logger, message="Preparing the tests"):
|
||||||
inventory = selected_inventory.get_inventory(established_only=established_only)
|
selected_tests = prepare_tests(selected_inventory, catalog, tests, tags)
|
||||||
|
if selected_tests is None:
|
||||||
|
return
|
||||||
|
|
||||||
if not inventory.devices:
|
run_info = (
|
||||||
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 ""}'
|
"--- ANTA NRFU Run Information ---\n"
|
||||||
logger.warning(msg)
|
f"Number of devices: {len(inventory)} ({len(selected_inventory)} established)\n"
|
||||||
return
|
f"Total number of selected tests: {catalog.final_tests_count}\n"
|
||||||
coros = []
|
f"Maximum number of open file descriptors for the current ANTA process: {limits[0]}\n"
|
||||||
|
"---------------------------------"
|
||||||
# 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)
|
|
||||||
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"Maximum number of open file descriptors for the current ANTA process: {limits[0]}\n"
|
|
||||||
"---------------------------------"
|
|
||||||
)
|
|
||||||
logger.info(run_info)
|
|
||||||
if len(selected_tests) > limits[0]:
|
|
||||||
logger.warning(
|
|
||||||
"The number of concurrent tests is higher than the open file descriptors limit for this ANTA process.\n"
|
|
||||||
"Errors may occur while running the tests.\n"
|
|
||||||
"Please consult the ANTA FAQ."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for test_definition, device in selected_tests:
|
logger.info(run_info)
|
||||||
try:
|
|
||||||
test_instance = test_definition.test(device=device, inputs=test_definition.inputs)
|
|
||||||
|
|
||||||
coros.append(test_instance.test())
|
if catalog.final_tests_count > limits[0]:
|
||||||
except Exception as e: # pylint: disable=broad-exception-caught
|
logger.warning(
|
||||||
# An AntaTest instance is potentially user-defined code.
|
"The number of concurrent tests is higher than the open file descriptors limit for this ANTA process.\n"
|
||||||
# We need to catch everything and exit gracefully with an
|
"Errors may occur while running the tests.\n"
|
||||||
# error message
|
"Please consult the ANTA FAQ."
|
||||||
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)
|
|
||||||
|
coroutines = get_coroutines(selected_tests)
|
||||||
|
|
||||||
|
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:
|
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...")
|
with Catchtime(logger=logger, message="Running ANTA tests"):
|
||||||
test_results = await asyncio.gather(*coros)
|
test_results = await asyncio.gather(*coroutines)
|
||||||
for r in test_results:
|
for r in test_results:
|
||||||
manager.add(r)
|
manager.add(r)
|
||||||
|
|
||||||
log_cache_statistics(inventory.devices)
|
log_cache_statistics(selected_inventory.devices)
|
||||||
|
|
234
anta/tests/avt.py
Normal file
234
anta/tests/avt.py
Normal 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.")
|
|
@ -7,8 +7,10 @@
|
||||||
# mypy: disable-error-code=attr-defined
|
# mypy: disable-error-code=attr-defined
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
|
from anta.custom_types import RegexString
|
||||||
from anta.models import AntaCommand, AntaTest
|
from anta.models import AntaCommand, AntaTest
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
@ -75,3 +77,57 @@ class VerifyRunningConfigDiffs(AntaTest):
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
else:
|
else:
|
||||||
self.result.is_failure(command_output)
|
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))
|
||||||
|
|
|
@ -103,6 +103,11 @@ class VerifyFieldNotice44Resolution(AntaTest):
|
||||||
for component in command_output["details"]["components"]:
|
for component in command_output["details"]["components"]:
|
||||||
if component["name"] == "Aboot":
|
if component["name"] == "Aboot":
|
||||||
aboot_version = component["version"].split("-")[2]
|
aboot_version = component["version"].split("-")[2]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
self.result.is_failure("Aboot component not found")
|
||||||
|
return
|
||||||
|
|
||||||
self.result.is_success()
|
self.result.is_success()
|
||||||
incorrect_aboot_version = (
|
incorrect_aboot_version = (
|
||||||
aboot_version.startswith("4.0.")
|
aboot_version.startswith("4.0.")
|
||||||
|
@ -192,4 +197,3 @@ class VerifyFieldNotice72Resolution(AntaTest):
|
||||||
return
|
return
|
||||||
# We should never hit this point
|
# We should never hit this point
|
||||||
self.result.is_error("Error in running test - FixedSystemvrm1 not found")
|
self.result.is_error("Error in running test - FixedSystemvrm1 not found")
|
||||||
return
|
|
||||||
|
|
|
@ -14,10 +14,13 @@ from typing import Any, ClassVar, Literal
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from pydantic_extra_types.mac_address import MacAddress
|
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.decorators import skip_on_platforms
|
||||||
from anta.models import AntaCommand, AntaTemplate, AntaTest
|
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):
|
class VerifyInterfaceUtilization(AntaTest):
|
||||||
|
@ -427,7 +430,7 @@ class VerifyLoopbackCount(AntaTest):
|
||||||
self.result.is_failure()
|
self.result.is_failure()
|
||||||
if loopback_count != self.inputs.number:
|
if loopback_count != self.inputs.number:
|
||||||
self.result.is_failure(f"Found {loopback_count} Loopbacks when expecting {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}")
|
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:
|
for interface in self.inputs.interfaces:
|
||||||
if interface.name == intf:
|
if interface.name == intf:
|
||||||
input_interface_detail = interface
|
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)
|
input_primary_ip = str(input_interface_detail.primary_ip)
|
||||||
failed_messages = []
|
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.")
|
self.result.is_failure(f"IP virtual router MAC address `{self.inputs.mac_address}` is not configured.")
|
||||||
else:
|
else:
|
||||||
self.result.is_success()
|
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")
|
||||||
|
|
165
anta/tests/path_selection.py
Normal file
165
anta/tests/path_selection.py
Normal 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`."
|
||||||
|
)
|
|
@ -23,7 +23,7 @@ class VerifyPtpModeStatus(AntaTest):
|
||||||
----------------
|
----------------
|
||||||
* Success: The test will pass if the device is a BC.
|
* Success: The test will pass if the device is a BC.
|
||||||
* Failure: The test will fail if the device is not 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
|
Examples
|
||||||
--------
|
--------
|
||||||
|
@ -45,7 +45,7 @@ class VerifyPtpModeStatus(AntaTest):
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
|
|
||||||
if (ptp_mode := command_output.get("ptpMode")) is None:
|
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
|
return
|
||||||
|
|
||||||
if ptp_mode != "ptpBoundaryClock":
|
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.
|
* 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.
|
* 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
|
Examples
|
||||||
--------
|
--------
|
||||||
|
@ -92,7 +92,7 @@ class VerifyPtpGMStatus(AntaTest):
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
|
|
||||||
if (ptp_clock_summary := command_output.get("ptpClockSummary")) is None:
|
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
|
return
|
||||||
|
|
||||||
if ptp_clock_summary["gmClockIdentity"] != self.inputs.gmid:
|
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.
|
* 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.
|
* 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
|
Examples
|
||||||
--------
|
--------
|
||||||
|
@ -133,7 +133,7 @@ class VerifyPtpLockStatus(AntaTest):
|
||||||
command_output = self.instance_commands[0].json_output
|
command_output = self.instance_commands[0].json_output
|
||||||
|
|
||||||
if (ptp_clock_summary := command_output.get("ptpClockSummary")) is None:
|
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
|
return
|
||||||
|
|
||||||
time_difference = ptp_clock_summary["currentPtpSystemTime"] - ptp_clock_summary["lastSyncTime"]
|
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.
|
* 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.
|
* 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
|
Examples
|
||||||
--------
|
--------
|
||||||
|
|
|
@ -262,8 +262,8 @@ class VerifyBGPPeerCount(AntaTest):
|
||||||
command_output = command.json_output
|
command_output = command.json_output
|
||||||
|
|
||||||
afi = command.params.afi
|
afi = command.params.afi
|
||||||
safi = command.params.safi
|
safi = command.params.safi if hasattr(command.params, "safi") else None
|
||||||
afi_vrf = command.params.vrf or "default"
|
afi_vrf = command.params.vrf if hasattr(command.params, "vrf") else "default"
|
||||||
|
|
||||||
# Swapping AFI and SAFI in case of SR-TE
|
# Swapping AFI and SAFI in case of SR-TE
|
||||||
if afi == "sr-te":
|
if afi == "sr-te":
|
||||||
|
@ -400,12 +400,12 @@ class VerifyBGPPeersHealth(AntaTest):
|
||||||
command_output = command.json_output
|
command_output = command.json_output
|
||||||
|
|
||||||
afi = command.params.afi
|
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
|
# Swapping AFI and SAFI in case of SR-TE
|
||||||
if afi == "sr-te":
|
if afi == "sr-te":
|
||||||
afi, safi = safi, afi
|
afi, safi = safi, afi
|
||||||
afi_vrf = command.params.vrf or "default"
|
|
||||||
|
|
||||||
if not (vrfs := command_output.get("vrfs")):
|
if not (vrfs := command_output.get("vrfs")):
|
||||||
_add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue="Not Configured")
|
_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
|
command_output = command.json_output
|
||||||
|
|
||||||
afi = command.params.afi
|
afi = command.params.afi
|
||||||
safi = command.params.safi
|
safi = command.params.safi if hasattr(command.params, "safi") else None
|
||||||
afi_vrf = command.params.vrf or "default"
|
afi_vrf = command.params.vrf if hasattr(command.params, "vrf") else "default"
|
||||||
|
|
||||||
# Swapping AFI and SAFI in case of SR-TE
|
# Swapping AFI and SAFI in case of SR-TE
|
||||||
if afi == "sr-te":
|
if afi == "sr-te":
|
||||||
|
|
308
anta/tests/routing/isis.py
Normal file
308
anta/tests/routing/isis.py
Normal 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}")
|
|
@ -44,7 +44,11 @@ class VerifySSHStatus(AntaTest):
|
||||||
"""Main test function for VerifySSHStatus."""
|
"""Main test function for VerifySSHStatus."""
|
||||||
command_output = self.instance_commands[0].text_output
|
command_output = self.instance_commands[0].text_output
|
||||||
|
|
||||||
line = next(line for line in command_output.split("\n") if line.startswith("SSHD status"))
|
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]
|
status = line.split("is ")[1]
|
||||||
|
|
||||||
if status == "disabled":
|
if status == "disabled":
|
||||||
|
|
|
@ -188,6 +188,7 @@ class VerifySTPForwardingPorts(AntaTest):
|
||||||
if not (topologies := get_value(command.json_output, "topologies")):
|
if not (topologies := get_value(command.json_output, "topologies")):
|
||||||
not_configured.append(vlan_id)
|
not_configured.append(vlan_id)
|
||||||
else:
|
else:
|
||||||
|
interfaces_not_forwarding = []
|
||||||
for value in topologies.values():
|
for value in topologies.values():
|
||||||
if vlan_id and int(vlan_id) in value["vlans"]:
|
if vlan_id and int(vlan_id) in value["vlans"]:
|
||||||
interfaces_not_forwarding = [interface for interface, state in value["interfaces"].items() if state["state"] != "forwarding"]
|
interfaces_not_forwarding = [interface for interface, state in value["interfaces"].items() if state["state"] != "forwarding"]
|
||||||
|
|
124
anta/tools.py
124
anta/tools.py
|
@ -5,7 +5,26 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
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:
|
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():
|
for element, expected_data in expected_output.items():
|
||||||
actual_data = actual_output.get(element)
|
actual_data = actual_output.get(element)
|
||||||
|
|
||||||
|
if actual_data == expected_data:
|
||||||
|
continue
|
||||||
if actual_data is None:
|
if actual_data is None:
|
||||||
failed_logs.append(f"\nExpected `{expected_data}` as the {element}, but it was not found in the actual output.")
|
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
|
||||||
failed_logs.append(f"\nExpected `{expected_data}` as the {element}, but found `{actual_data}` instead.")
|
# 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)
|
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
|
# pylint: disable=too-many-arguments
|
||||||
def get_dict_superset(
|
def get_dict_superset(
|
||||||
list_of_dicts: list[dict[Any, Any]],
|
list_of_dicts: list[dict[Any, Any]],
|
||||||
|
@ -228,3 +268,81 @@ def get_item(
|
||||||
if required is True:
|
if required is True:
|
||||||
raise ValueError(custom_error_msg or var_name)
|
raise ValueError(custom_error_msg or var_name)
|
||||||
return default
|
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
12
asynceapi/__init__.py
Normal 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"]
|
58
asynceapi/aio_portcheck.py
Normal file
58
asynceapi/aio_portcheck.py
Normal 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
289
asynceapi/config_session.py
Normal 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
291
asynceapi/device.py
Normal 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
42
asynceapi/errors.py
Normal 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
|
|
@ -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 NRFU (Network Ready For Use) test on a preproduction network
|
||||||
- Automate tests on a live network (periodically or on demand)
|
- Automate tests on a live network (periodically or on demand)
|
||||||
- ANTA can be used with:
|
- 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
|
- 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)
|
![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
|
```bash
|
||||||
# Install ANTA CLI
|
# Install ANTA as a library
|
||||||
$ pip install anta
|
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
|
# Run ANTA CLI
|
||||||
$ anta --help
|
$ anta --help
|
||||||
|
@ -52,8 +69,11 @@ Commands:
|
||||||
nrfu Run ANTA tests on devices
|
nrfu Run ANTA tests on devices
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!WARNING]
|
You can also still choose to install it with directly with `pip`:
|
||||||
> 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.
|
|
||||||
|
```bash
|
||||||
|
$ pip install anta[cli]
|
||||||
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
@ -65,4 +85,6 @@ Contributions are welcome. Please refer to the [contribution guide](contribution
|
||||||
|
|
||||||
## Credits
|
## 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.
|
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.
|
||||||
|
|
|
@ -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) 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
|
### 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:
|
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
|
::: anta.models.AntaTest.Input
|
||||||
options:
|
options:
|
||||||
|
@ -114,7 +118,9 @@ The base definition of [AntaTest.Input](../api/models.md#anta.models.AntaTest.In
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
heading_level: 10
|
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
|
::: anta.models.AntaTest.Input.ResultOverwrite
|
||||||
options:
|
options:
|
||||||
|
|
9
docs/api/runner.md
Normal file
9
docs/api/runner.md
Normal 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
20
docs/api/tests.avt.md
Normal 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"
|
|
@ -13,6 +13,7 @@ This section describes all the available tests provided by the ANTA package.
|
||||||
Here are the tests that we currently provide:
|
Here are the tests that we currently provide:
|
||||||
|
|
||||||
- [AAA](tests.aaa.md)
|
- [AAA](tests.aaa.md)
|
||||||
|
- [Adaptive Virtual Topology](tests.avt.md)
|
||||||
- [BFD](tests.bfd.md)
|
- [BFD](tests.bfd.md)
|
||||||
- [Configuration](tests.configuration.md)
|
- [Configuration](tests.configuration.md)
|
||||||
- [Connectivity](tests.connectivity.md)
|
- [Connectivity](tests.connectivity.md)
|
||||||
|
@ -26,6 +27,7 @@ Here are the tests that we currently provide:
|
||||||
- [Multicast](tests.multicast.md)
|
- [Multicast](tests.multicast.md)
|
||||||
- [Profiles](tests.profiles.md)
|
- [Profiles](tests.profiles.md)
|
||||||
- [PTP](tests.ptp.md)
|
- [PTP](tests.ptp.md)
|
||||||
|
- [Router Path Selection](tests.path_selection.md)
|
||||||
- [Routing Generic](tests.routing.generic.md)
|
- [Routing Generic](tests.routing.generic.md)
|
||||||
- [Routing BGP](tests.routing.bgp.md)
|
- [Routing BGP](tests.routing.bgp.md)
|
||||||
- [Routing OSPF](tests.routing.ospf.md)
|
- [Routing OSPF](tests.routing.ospf.md)
|
||||||
|
|
20
docs/api/tests.path_selection.md
Normal file
20
docs/api/tests.path_selection.md
Normal 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"
|
20
docs/api/tests.routing.isis.md
Normal file
20
docs/api/tests.routing.isis.md
Normal 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"
|
|
@ -14,17 +14,28 @@ In large setups, it might be beneficial to construct your inventory based on you
|
||||||
$ anta get from-ansible --help
|
$ anta get from-ansible --help
|
||||||
Usage: anta get from-ansible [OPTIONS]
|
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:
|
Options:
|
||||||
-g, --ansible-group TEXT Ansible group to filter
|
-o, --output FILE Path to save inventory file [env var:
|
||||||
--ansible-inventory FILENAME
|
ANTA_INVENTORY; required]
|
||||||
Path to your ansible inventory file to read
|
--overwrite Do not prompt when overriding current inventory
|
||||||
-o, --output FILENAME Path to save inventory file
|
[env var: ANTA_GET_FROM_ANSIBLE_OVERWRITE]
|
||||||
-d, --inventory-directory PATH Directory to save inventory file
|
-g, --ansible-group TEXT Ansible group to filter
|
||||||
--help Show this message and exit.
|
--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:
|
The output is an inventory where the name of the container is added as a tag for each host:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|
|
@ -173,7 +173,7 @@ The `--output` option allows you to choose the path where the final report will
|
||||||
```bash
|
```bash
|
||||||
anta nrfu --tags LEAF tpl-report --template ./custom_template.j2
|
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:
|
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
|
* VerifyMlagConfigSanity is [green]SUCCESS[/green] for DC1-LEAF1A
|
||||||
* VerifyMlagReloadDelay 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)
|
||||||
|
|
|
@ -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`.
|
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
|
## Invoking ANTA CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
@ -21,12 +21,14 @@ $ cd anta
|
||||||
|
|
||||||
# Install ANTA in editable mode and its development tools
|
# Install ANTA in editable mode and its development tools
|
||||||
$ pip install -e .[dev]
|
$ pip install -e .[dev]
|
||||||
|
# To also install the CLI
|
||||||
|
$ pip install -e .[dev,cli]
|
||||||
|
|
||||||
# Verify installation
|
# Verify installation
|
||||||
$ pip list -e
|
$ pip list -e
|
||||||
Package Version Editable project location
|
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:
|
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.
|
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.
|
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 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.
|
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
|
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 `DATA` structure is a list of dictionaries used to parametrize the test. The list elements have the following keys:
|
||||||
The list elements have the following keys:
|
|
||||||
- `name` (str): Test name as displayed by Pytest.
|
- `name` (str): Test name as displayed by Pytest.
|
||||||
- `test` (AntaTest): An AntaTest subclass imported in the test module - e.g. VerifyUptime.
|
- `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.
|
- `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`.
|
- `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
|
- `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.
|
`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.
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
toc_depth: 4
|
toc_depth: 2
|
||||||
---
|
---
|
||||||
<!--
|
<!--
|
||||||
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
~ Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
|
@ -7,7 +7,7 @@ toc_depth: 4
|
||||||
~ that can be found in the LICENSE file.
|
~ that can be found in the LICENSE file.
|
||||||
-->
|
-->
|
||||||
<style>
|
<style>
|
||||||
h4 {
|
.md-typeset h2 {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
font-size: 0em;
|
font-size: 0em;
|
||||||
height: 0em;
|
height: 0em;
|
||||||
|
|
|
@ -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:
|
The easiest way to install ANTA package is to run Python (`>=3.9`) and its pip package to install:
|
||||||
|
|
||||||
```bash
|
```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.
|
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
|
## 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.
|
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.
|
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
|
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
|
```bash
|
||||||
anta nrfu \
|
anta nrfu \
|
||||||
|
@ -176,7 +184,7 @@ anta nrfu \
|
||||||
└───────────┴──────────────────────────┴─────────────┴──────────────────┴──────────────────────────────────────────────────────────────────────┴───────────────┘
|
└───────────┴──────────────────────────┴─────────────┴──────────────────┴──────────────────────────────────────────────────────────────────────┴───────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### Report in text mode
|
#### Report in text mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ anta nrfu \
|
$ anta nrfu \
|
||||||
|
@ -206,7 +214,7 @@ leaf01 :: VerifyMlagConfigSanity :: SKIPPED (MLAG is disabled)
|
||||||
[...]
|
[...]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Report in JSON format
|
#### Report in JSON format
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ anta nrfu \
|
$ anta nrfu \
|
||||||
|
|
127
docs/imgs/anta_nrfu___dry_run.svg
Normal file
127
docs/imgs/anta_nrfu___dry_run.svg
Normal 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 nrfu --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$ anta nrfu --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)">- ANTA Inventory contains 3 devices (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)">- Tests catalog contains 9 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 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    </text><text class="terminal-2602327173-r1" x="353.8" y="166.4" textLength="292.8" clip-path="url(#terminal-2602327173-line-6)">Preparing ANTA NRFU Run </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    </text><text class="terminal-2602327173-r1" x="353.8" y="190.8" textLength="244" clip-path="url(#terminal-2602327173-line-7)">Preparing the tests </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    </text><text class="terminal-2602327173-r1" x="353.8" y="215.2" textLength="414.8" clip-path="url(#terminal-2602327173-line-8)">Preparing the tests completed in: </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)">.                                </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    </text><text class="terminal-2602327173-r1" x="353.8" y="239.6" textLength="939.4" clip-path="url(#terminal-2602327173-line-9)">--- ANTA NRFU Run Information ---                                            </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 of devices: </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)"> 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 number of selected tests: </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 number of open file descriptors for the current ANTA process: </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)">---------------------------------                                            </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    </text><text class="terminal-2602327173-r1" x="353.8" y="361.6" textLength="463.6" clip-path="url(#terminal-2602327173-line-14)">Preparing ANTA NRFU Run completed in: </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)">.                            </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    </text><text class="terminal-2602327173-r1" x="353.8" y="386" textLength="939.4" clip-path="url(#terminal-2602327173-line-15)">Dry-run mode, exiting before running the tests.                              </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 |
|
@ -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.
|
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
|
```bash
|
||||||
pip install anta
|
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
|
### Install ANTA from github
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install git+https://github.com/arista-netdevops-community/anta.git
|
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:
|
# 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-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@<cool-feature-branch>#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@<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
|
### Check installation
|
||||||
|
|
||||||
|
@ -61,12 +93,12 @@ which anta
|
||||||
```bash
|
```bash
|
||||||
# Check ANTA version
|
# Check ANTA version
|
||||||
anta --version
|
anta --version
|
||||||
anta, version v0.14.0
|
anta, version v0.15.0
|
||||||
```
|
```
|
||||||
|
|
||||||
## EOS Requirements
|
## 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
|
```eos
|
||||||
configure
|
configure
|
||||||
|
|
|
@ -13,6 +13,7 @@ python generate_svg.py anta ...
|
||||||
# ruff: noqa: T201
|
# ruff: noqa: T201
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
|
@ -22,10 +23,17 @@ from importlib.metadata import entry_points
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
from rich.logging import RichHandler
|
||||||
|
|
||||||
from anta.cli.console import console
|
from anta.cli.console import console
|
||||||
from anta.cli.nrfu.utils import anta_progress_bar
|
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"
|
OUTPUT_DIR = pathlib.Path(__file__).parent.parent / "imgs"
|
||||||
|
|
||||||
|
|
||||||
|
@ -43,7 +51,7 @@ def custom_progress_bar() -> None:
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Sane rich size
|
# 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
|
# stolen from https://github.com/ewels/rich-click/blob/main/src/rich_click/cli.py
|
||||||
args = sys.argv[1:]
|
args = sys.argv[1:]
|
||||||
|
@ -64,6 +72,8 @@ if __name__ == "__main__":
|
||||||
print("Usage: python generate_svg.py anta <options>")
|
print("Usage: python generate_svg.py anta <options>")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# possibly-used-before-assignment - prog / function_name -> not understanding sys.exit here...
|
||||||
|
# pylint: disable=E0606
|
||||||
sys.argv = [prog, *args[1:]]
|
sys.argv = [prog, *args[1:]]
|
||||||
module = import_module(module_path)
|
module = import_module(module_path)
|
||||||
function = getattr(module, function_name)
|
function = getattr(module, function_name)
|
||||||
|
@ -71,22 +81,24 @@ if __name__ == "__main__":
|
||||||
# Console to captur everything
|
# Console to captur everything
|
||||||
new_console = Console(record=True)
|
new_console = Console(record=True)
|
||||||
|
|
||||||
# tweaks to record and redirect to a dummy file
|
|
||||||
pipe = io.StringIO()
|
pipe = io.StringIO()
|
||||||
console.record = True
|
console.record = True
|
||||||
console.file = pipe
|
console.file = pipe
|
||||||
|
with redirect_stdout(io.StringIO()) as f:
|
||||||
|
# tweaks to record and redirect to a dummy file
|
||||||
|
|
||||||
# Redirect stdout of the program towards another StringIO to capture help
|
console.print(f"ant@anthill$ {' '.join(sys.argv)}")
|
||||||
# that is not part or anta rich console
|
|
||||||
# redirect potential progress bar output to console by patching
|
# Redirect stdout of the program towards another StringIO to capture help
|
||||||
with redirect_stdout(io.StringIO()) as f, patch("anta.cli.nrfu.commands.anta_progress_bar", custom_progress_bar), suppress(SystemExit):
|
# that is not part or anta rich console
|
||||||
function()
|
# redirect potential progress bar output to console by patching
|
||||||
# print to our new console the output of anta console
|
with patch("anta.cli.nrfu.anta_progress_bar", custom_progress_bar), suppress(SystemExit):
|
||||||
new_console.print(console.export_text())
|
function()
|
||||||
# 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"{'_'.join(x.replace('/', '_').replace('-', '_').replace('.', '_') for x in args)}.svg"
|
||||||
filename = f"{OUTPUT_DIR}/{filename}"
|
filename = f"{OUTPUT_DIR}/{filename}"
|
||||||
print(f"File saved at {filename}")
|
print(f"File saved at {filename}")
|
||||||
new_console.save_svg(filename, title=" ".join(args))
|
console.save_svg(filename, title=" ".join(args))
|
||||||
|
|
|
@ -42,6 +42,10 @@ Options:
|
||||||
ANTA_NRFU_IGNORE_ERROR]
|
ANTA_NRFU_IGNORE_ERROR]
|
||||||
--hide [success|failure|error|skipped]
|
--hide [success|failure|error|skipped]
|
||||||
Group result by test or device.
|
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.
|
--help Show this message and exit.
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
|
|
|
@ -2,11 +2,15 @@
|
||||||
--md-hue: 210;
|
--md-hue: 210;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#page {
|
||||||
|
counter-reset: heading;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Color schema based on Arista Color Schema */
|
/* Color schema based on Arista Color Schema */
|
||||||
/* Default color shades */
|
/* Default color shades */
|
||||||
--md-default-fg-color: #000000;
|
--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--lighter: #FFFFFF;
|
||||||
--md-default-fg-color--lightest: #FFFFFF;
|
--md-default-fg-color--lightest: #FFFFFF;
|
||||||
--md-default-bg-color: #FFFFFF;
|
--md-default-bg-color: #FFFFFF;
|
||||||
|
@ -35,12 +39,8 @@
|
||||||
--md-code-bg-color: #E6E6E6;
|
--md-code-bg-color: #E6E6E6;
|
||||||
--md-code-border-color: #0000004f;
|
--md-code-border-color: #0000004f;
|
||||||
--block-code-bg-color: #e4e4e4;
|
--block-code-bg-color: #e4e4e4;
|
||||||
/* --md-code-fg-color: ...; */
|
|
||||||
|
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
/* min-height: 100%;
|
|
||||||
position: relative;
|
|
||||||
width: 100%; */
|
|
||||||
font-feature-settings: "kern","liga";
|
font-feature-settings: "kern","liga";
|
||||||
font-family: var(--md-text-font-family,_),-apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;
|
font-family: var(--md-text-font-family,_),-apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
@ -49,15 +49,16 @@
|
||||||
|
|
||||||
[data-md-color-scheme="slate"] {
|
[data-md-color-scheme="slate"] {
|
||||||
|
|
||||||
|
/* Default color shades */
|
||||||
|
--md-default-fg-color--light: #949393;
|
||||||
|
|
||||||
/* Link color */
|
/* Link color */
|
||||||
--md-typeset-a-color: #75aaf8;
|
--md-typeset-a-color: #75aaf8;
|
||||||
--md-typeset-a-color-fg: #FFFFFF;
|
--md-typeset-a-color-fg: #FFFFFF;
|
||||||
--md-typeset-a-color-bg: #27569B;
|
--md-typeset-a-color-bg: #27569B;
|
||||||
|
|
||||||
/* Code block color shades */
|
/* Code block color shades */
|
||||||
/* --md-code-bg-color: #E6E6E6; */
|
|
||||||
--md-code-border-color: #aec6db4f;
|
--md-code-border-color: #aec6db4f;
|
||||||
/* --block-code-bg-color: #e4e4e4; */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (min-width: 76.25em) {
|
@media only screen and (min-width: 76.25em) {
|
||||||
|
@ -76,6 +77,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen {
|
@media only screen {
|
||||||
|
|
||||||
.md-typeset a:hover {
|
.md-typeset a:hover {
|
||||||
background-color: var(--md-typeset-a-color-bg);
|
background-color: var(--md-typeset-a-color-bg);
|
||||||
color: var(--md-typeset-a-color-fg);
|
color: var(--md-typeset-a-color-fg);
|
||||||
|
@ -102,12 +104,56 @@
|
||||||
color: var(--md-default-fg-color--light);
|
color: var(--md-default-fg-color--light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-typeset h4 h5 h6 {
|
.md-typeset h2 {
|
||||||
font-size: 1.5rem;
|
line-height: 2em;
|
||||||
margin: 1em 0;
|
font-size: 1.5rem;
|
||||||
/* font-weight: 700; */
|
margin: 1em 0;
|
||||||
letter-spacing: -.01em;
|
/* font-weight: 700; */
|
||||||
line-height: 3em;
|
letter-spacing: -.01em;
|
||||||
|
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 {
|
.md-typeset table:not([class]) th {
|
||||||
|
@ -178,8 +224,6 @@
|
||||||
.md-typeset table:not([class]) th {
|
.md-typeset table:not([class]) th {
|
||||||
min-width: 5rem;
|
min-width: 5rem;
|
||||||
padding: .6rem .8rem;
|
padding: .6rem .8rem;
|
||||||
/* color: var(--md-primary-fg-color--light); */
|
|
||||||
bg: var(--md-footer-fg-color--lighter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-footer-copyright {
|
.md-footer-copyright {
|
||||||
|
@ -195,7 +239,6 @@
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
border-radius: 1%;
|
border-radius: 1%;
|
||||||
/* width: 50%; */
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -77,3 +77,14 @@ Example:
|
||||||
```bash
|
```bash
|
||||||
ANTA_DEBUG=true anta -l DEBUG --log-file anta.log nrfu --enable --username username --password arista --inventory inventory.yml -c nrfu.yml text
|
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"}'
|
||||||
|
```
|
||||||
|
|
|
@ -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 │
|
│ 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
67
examples/anta_runner.py
Normal 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)
|
|
@ -50,6 +50,18 @@ anta.tests.aaa:
|
||||||
- commands
|
- commands
|
||||||
- dot1x
|
- 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:
|
anta.tests.bfd:
|
||||||
- VerifyBFDSpecificPeers:
|
- VerifyBFDSpecificPeers:
|
||||||
bfd_peers:
|
bfd_peers:
|
||||||
|
@ -167,6 +179,18 @@ anta.tests.interfaces:
|
||||||
- 10.10.10.10/31
|
- 10.10.10.10/31
|
||||||
- VerifyIpVirtualRouterMac:
|
- VerifyIpVirtualRouterMac:
|
||||||
mac_address: 00:1c:73:00:dc:01
|
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:
|
anta.tests.lanz:
|
||||||
- VerifyLANZ:
|
- VerifyLANZ:
|
||||||
|
@ -210,6 +234,15 @@ anta.tests.multicast:
|
||||||
- VerifyIGMPSnoopingGlobal:
|
- VerifyIGMPSnoopingGlobal:
|
||||||
enabled: True
|
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:
|
anta.tests.profiles:
|
||||||
- VerifyUnifiedForwardingTableMode:
|
- VerifyUnifiedForwardingTableMode:
|
||||||
mode: 3
|
mode: 3
|
||||||
|
@ -515,3 +548,29 @@ anta.tests.routing:
|
||||||
- VerifyOSPFNeighborCount:
|
- VerifyOSPFNeighborCount:
|
||||||
number: 3
|
number: 3
|
||||||
- VerifyOSPFMaxLSA:
|
- 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
|
12
mkdocs.yml
12
mkdocs.yml
|
@ -145,10 +145,12 @@ markdown_extensions:
|
||||||
separator: "-"
|
separator: "-"
|
||||||
# permalink: "#"
|
# permalink: "#"
|
||||||
permalink: true
|
permalink: true
|
||||||
baselevel: 3
|
# baselevel: 3
|
||||||
- pymdownx.highlight
|
- pymdownx.highlight
|
||||||
- pymdownx.snippets:
|
- pymdownx.snippets:
|
||||||
base_path: docs/snippets
|
base_path:
|
||||||
|
- docs/snippets
|
||||||
|
- examples
|
||||||
- pymdownx.superfences
|
- pymdownx.superfences
|
||||||
- pymdownx.superfences
|
- pymdownx.superfences
|
||||||
- pymdownx.tabbed:
|
- pymdownx.tabbed:
|
||||||
|
@ -178,6 +180,7 @@ nav:
|
||||||
- Tests Documentation:
|
- Tests Documentation:
|
||||||
- Overview: api/tests.md
|
- Overview: api/tests.md
|
||||||
- AAA: api/tests.aaa.md
|
- AAA: api/tests.aaa.md
|
||||||
|
- Adaptive Virtual Topology: api/tests.avt.md
|
||||||
- BFD: api/tests.bfd.md
|
- BFD: api/tests.bfd.md
|
||||||
- Configuration: api/tests.configuration.md
|
- Configuration: api/tests.configuration.md
|
||||||
- Connectivity: api/tests.connectivity.md
|
- Connectivity: api/tests.connectivity.md
|
||||||
|
@ -191,10 +194,12 @@ nav:
|
||||||
- Multicast: api/tests.multicast.md
|
- Multicast: api/tests.multicast.md
|
||||||
- Profiles: api/tests.profiles.md
|
- Profiles: api/tests.profiles.md
|
||||||
- PTP: api/tests.ptp.md
|
- PTP: api/tests.ptp.md
|
||||||
|
- Router Path Selection: api/tests.path_selection.md
|
||||||
- Routing:
|
- Routing:
|
||||||
- Generic: api/tests.routing.generic.md
|
- Generic: api/tests.routing.generic.md
|
||||||
- BGP: api/tests.routing.bgp.md
|
- BGP: api/tests.routing.bgp.md
|
||||||
- OSPF: api/tests.routing.ospf.md
|
- OSPF: api/tests.routing.ospf.md
|
||||||
|
- ISIS: api/tests.routing.isis.md
|
||||||
- Security: api/tests.security.md
|
- Security: api/tests.security.md
|
||||||
- Services: api/tests.services.md
|
- Services: api/tests.services.md
|
||||||
- SNMP: api/tests.snmp.md
|
- SNMP: api/tests.snmp.md
|
||||||
|
@ -205,11 +210,11 @@ nav:
|
||||||
- VXLAN: api/tests.vxlan.md
|
- VXLAN: api/tests.vxlan.md
|
||||||
- VLAN: api/tests.vlan.md
|
- VLAN: api/tests.vlan.md
|
||||||
- API Documentation:
|
- API Documentation:
|
||||||
|
- Device: api/device.md
|
||||||
- Inventory:
|
- Inventory:
|
||||||
- Inventory module: api/inventory.md
|
- Inventory module: api/inventory.md
|
||||||
- Inventory models: api/inventory.models.input.md
|
- Inventory models: api/inventory.models.input.md
|
||||||
- Test Catalog: api/catalog.md
|
- Test Catalog: api/catalog.md
|
||||||
- Device: api/device.md
|
|
||||||
- Test:
|
- Test:
|
||||||
- Test models: api/models.md
|
- Test models: api/models.md
|
||||||
- Input Types: api/types.md
|
- Input Types: api/types.md
|
||||||
|
@ -217,6 +222,7 @@ nav:
|
||||||
- Result Manager module: api/result_manager.md
|
- Result Manager module: api/result_manager.md
|
||||||
- Result Manager models: api/result_manager_models.md
|
- Result Manager models: api/result_manager_models.md
|
||||||
- Report Manager: api/report_manager.md
|
- Report Manager: api/report_manager.md
|
||||||
|
- Runner: api/runner.md
|
||||||
- Troubleshooting: troubleshooting.md
|
- Troubleshooting: troubleshooting.md
|
||||||
- Contributions: contribution.md
|
- Contributions: contribution.md
|
||||||
- FAQ: faq.md
|
- FAQ: faq.md
|
||||||
|
|
|
@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "anta"
|
name = "anta"
|
||||||
version = "v0.14.0"
|
version = "v0.15.0"
|
||||||
readme = "docs/README.md"
|
readme = "docs/README.md"
|
||||||
authors = [{ name = "Khelil Sator", email = "ksator@arista.com" }]
|
authors = [{ name = "Khelil Sator", email = "ksator@arista.com" }]
|
||||||
maintainers = [
|
maintainers = [
|
||||||
|
@ -17,19 +17,17 @@ maintainers = [
|
||||||
description = "Arista Network Test Automation (ANTA) Framework"
|
description = "Arista Network Test Automation (ANTA) Framework"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiocache~=0.12.2",
|
"aiocache>=0.12.2",
|
||||||
"aio-eapi==0.6.3",
|
"asyncssh>=2.13.2",
|
||||||
"click~=8.1.6",
|
"cvprac>=1.3.1",
|
||||||
"click-help-colors~=0.9",
|
"eval-type-backport>=0.1.3", # Support newer typing features in older Python versions (required until Python 3.9 support is removed)
|
||||||
"cvprac~=1.3.1",
|
"Jinja2>=3.1.2",
|
||||||
"pydantic>=2.6.1,<2.8.0",
|
"pydantic>=2.7",
|
||||||
"pydantic-extra-types>=2.1.0",
|
"pydantic-extra-types>=2.3.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",
|
||||||
"PyYAML~=6.0",
|
"requests>=2.31.0",
|
||||||
"requests~=2.31.0",
|
"rich>=13.5.2,<14",
|
||||||
"rich>=13.5.2,<13.8.0",
|
"httpx>=0.27.0"
|
||||||
"asyncssh>=2.13.2,<2.15.0",
|
|
||||||
"Jinja2~=3.1.2",
|
|
||||||
]
|
]
|
||||||
keywords = ["test", "anta", "Arista", "network", "automation", "networking", "devops", "netdevops"]
|
keywords = ["test", "anta", "Arista", "network", "automation", "networking", "devops", "netdevops"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
|
@ -53,40 +51,44 @@ classifiers = [
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
cli = [
|
||||||
|
"click~=8.1.6",
|
||||||
|
"click-help-colors>=0.9",
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"bumpver==2023.1129",
|
"bumpver>=2023.1129",
|
||||||
"codespell~=2.2.6",
|
"codespell~=2.2.6",
|
||||||
"mypy~=1.9",
|
|
||||||
"mypy-extensions~=1.0",
|
"mypy-extensions~=1.0",
|
||||||
|
"mypy~=1.10",
|
||||||
"pre-commit>=3.3.3",
|
"pre-commit>=3.3.3",
|
||||||
|
"pylint-pydantic>=0.2.4",
|
||||||
"pylint>=2.17.5",
|
"pylint>=2.17.5",
|
||||||
"ruff~=0.3.5",
|
|
||||||
"pytest>=7.4.0",
|
|
||||||
"pytest-asyncio>=0.21.1",
|
"pytest-asyncio>=0.21.1",
|
||||||
"pytest-cov>=4.1.0",
|
"pytest-cov>=4.1.0",
|
||||||
"pytest-dependency",
|
"pytest-dependency",
|
||||||
"pytest-html>=3.2.0",
|
"pytest-html>=3.2.0",
|
||||||
"pytest-metadata>=3.0.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",
|
"tox>=4.10.0,<5.0.0",
|
||||||
"types-PyYAML",
|
"types-PyYAML",
|
||||||
"types-paramiko",
|
"types-pyOpenSSL",
|
||||||
"types-requests",
|
"types-requests",
|
||||||
"typing-extensions",
|
"typing-extensions",
|
||||||
"yamllint>=1.32.0",
|
"yamllint>=1.32.0",
|
||||||
]
|
]
|
||||||
doc = [
|
doc = [
|
||||||
"fontawesome_markdown",
|
"fontawesome_markdown",
|
||||||
"mkdocs>=1.3.1",
|
"griffe",
|
||||||
|
"mike==2.1.1",
|
||||||
"mkdocs-autorefs>=0.4.1",
|
"mkdocs-autorefs>=0.4.1",
|
||||||
"mkdocs-bootswatch>=1.1",
|
"mkdocs-bootswatch>=1.1",
|
||||||
"mkdocs-git-revision-date-localized-plugin>=1.1.0",
|
"mkdocs-git-revision-date-localized-plugin>=1.1.0",
|
||||||
"mkdocs-git-revision-date-plugin>=0.3.2",
|
"mkdocs-git-revision-date-plugin>=0.3.2",
|
||||||
"mkdocs-material>=8.3.9",
|
|
||||||
"mkdocs-material-extensions>=1.0.3",
|
"mkdocs-material-extensions>=1.0.3",
|
||||||
|
"mkdocs-material>=8.3.9",
|
||||||
|
"mkdocs>=1.3.1",
|
||||||
"mkdocstrings[python]>=0.20.0",
|
"mkdocstrings[python]>=0.20.0",
|
||||||
"mike==2.0.0",
|
|
||||||
"griffe",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
@ -101,14 +103,14 @@ anta = "anta.cli:cli"
|
||||||
# Tools
|
# Tools
|
||||||
################################
|
################################
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["anta*"]
|
include = ["anta*", "asynceapi*"]
|
||||||
namespaces = false
|
namespaces = false
|
||||||
|
|
||||||
################################
|
################################
|
||||||
# Version
|
# Version
|
||||||
################################
|
################################
|
||||||
[tool.bumpver]
|
[tool.bumpver]
|
||||||
current_version = "0.14.0"
|
current_version = "0.15.0"
|
||||||
version_pattern = "MAJOR.MINOR.PATCH"
|
version_pattern = "MAJOR.MINOR.PATCH"
|
||||||
commit_message = "bump: Version {old_version} -> {new_version}"
|
commit_message = "bump: Version {old_version} -> {new_version}"
|
||||||
commit = true
|
commit = true
|
||||||
|
@ -131,6 +133,8 @@ plugins = [
|
||||||
]
|
]
|
||||||
# Comment below for better type checking
|
# Comment below for better type checking
|
||||||
#follow_imports = "skip"
|
#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
|
ignore_missing_imports = true
|
||||||
warn_redundant_casts = true
|
warn_redundant_casts = true
|
||||||
# Note: tox find some unused type ignore which are required for pre-commit
|
# 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"
|
log_level = "WARNING"
|
||||||
render_collapsed = true
|
render_collapsed = true
|
||||||
testpaths = ["tests"]
|
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]
|
[tool.coverage.run]
|
||||||
branch = true
|
branch = true
|
||||||
source = ["anta"]
|
source = ["anta"]
|
||||||
parallel = true
|
parallel = true
|
||||||
omit= [
|
|
||||||
# omit aioeapi patch
|
|
||||||
"anta/aioeapi.py",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
# Regexes for lines to exclude from consideration
|
# Regexes for lines to exclude from consideration
|
||||||
|
@ -225,7 +234,9 @@ python =
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
description = Run pytest with {basepython}
|
description = Run pytest with {basepython}
|
||||||
extras = dev
|
extras =
|
||||||
|
dev
|
||||||
|
cli
|
||||||
# posargs allows to run only a specific test using
|
# posargs allows to run only a specific test using
|
||||||
# tox -e <env> -- path/to/my/test::test
|
# tox -e <env> -- path/to/my/test::test
|
||||||
commands =
|
commands =
|
||||||
|
@ -297,7 +308,6 @@ exclude = [
|
||||||
"site-packages",
|
"site-packages",
|
||||||
"venv",
|
"venv",
|
||||||
".github",
|
".github",
|
||||||
"aioeapi.py" # Remove this when https://github.com/jeremyschulman/aio-eapi/pull/13 is merged
|
|
||||||
]
|
]
|
||||||
|
|
||||||
line-length = 165
|
line-length = 165
|
||||||
|
@ -374,6 +384,9 @@ runtime-evaluated-base-classes = ["pydantic.BaseModel", "anta.models.AntaTest.In
|
||||||
"anta/cli/exec/utils.py" = [
|
"anta/cli/exec/utils.py" = [
|
||||||
"SLF001", # TODO: some private members, lets try to fix
|
"SLF001", # TODO: some private members, lets try to fix
|
||||||
]
|
]
|
||||||
|
"anta/cli/__init__.py" = [
|
||||||
|
"T201", # Allow print statements
|
||||||
|
]
|
||||||
"anta/cli/*" = [
|
"anta/cli/*" = [
|
||||||
"PLR0913", # Allow more than 5 input arguments in CLI functions
|
"PLR0913", # Allow more than 5 input arguments in CLI functions
|
||||||
"ANN401", # TODO: Check if we can update the Any type hints in the CLI
|
"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
|
"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
|
"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" = [
|
"anta/device.py" = [
|
||||||
"PLR0913", # Ok to have more than 5 arguments in the AntaDevice classes
|
"PLR0913", # Ok to have more than 5 arguments in the AntaDevice classes
|
||||||
]
|
]
|
||||||
"anta/inventory/__init__.py" = [
|
"anta/inventory/__init__.py" = [
|
||||||
"PLR0913", # Ok to have more than 5 arguments in the AntaInventory class
|
"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
|
# Pylint
|
||||||
|
@ -433,4 +447,5 @@ extension-pkg-whitelist="pydantic"
|
||||||
ignore-paths = [
|
ignore-paths = [
|
||||||
"^tests/units/anta_tests/.*/data.py$",
|
"^tests/units/anta_tests/.*/data.py$",
|
||||||
"^tests/units/anta_tests/routing/.*/data.py$",
|
"^tests/units/anta_tests/routing/.*/data.py$",
|
||||||
|
"^docs/scripts/anta_runner.py",
|
||||||
]
|
]
|
||||||
|
|
|
@ -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:
|
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
|
||||||
|
|
49
tests/data/ansible_inventory_unknown_yaml_tag.yml
Normal file
49
tests/data/ansible_inventory_unknown_yaml_tag.yml
Normal 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:
|
50
tests/data/ansible_inventory_with_vault.yml
Normal file
50
tests/data/ansible_inventory_with_vault.yml
Normal 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:
|
71690
tests/data/test_catalog_large.yml
Normal file
71690
tests/data/test_catalog_large.yml
Normal file
File diff suppressed because it is too large
Load diff
2078
tests/data/test_catalog_medium.yml
Normal file
2078
tests/data/test_catalog_medium.yml
Normal file
File diff suppressed because it is too large
Load diff
1
tests/data/test_empty_dict_catalog.yml
Normal file
1
tests/data/test_empty_dict_catalog.yml
Normal file
|
@ -0,0 +1 @@
|
||||||
|
{}
|
103
tests/data/test_inventory_large.yml
Normal file
103
tests/data/test_inventory_large.yml
Normal 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
|
14
tests/data/test_inventory_medium.yml
Normal file
14
tests/data/test_inventory_medium.yml
Normal 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
|
|
@ -13,7 +13,7 @@ from unittest.mock import patch
|
||||||
import pytest
|
import pytest
|
||||||
from click.testing import CliRunner, Result
|
from click.testing import CliRunner, Result
|
||||||
|
|
||||||
from anta import aioeapi
|
import asynceapi
|
||||||
from anta.cli.console import console
|
from anta.cli.console import console
|
||||||
from anta.device import AntaDevice, AsyncEOSDevice
|
from anta.device import AntaDevice, AsyncEOSDevice
|
||||||
from anta.inventory import AntaInventory
|
from anta.inventory import AntaInventory
|
||||||
|
@ -33,7 +33,7 @@ DEVICE_HW_MODEL = "pytest"
|
||||||
DEVICE_NAME = "pytest"
|
DEVICE_NAME = "pytest"
|
||||||
COMMAND_OUTPUT = "retrieved"
|
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": {
|
"show version": {
|
||||||
"modelName": "DCS-7280CR3-32P4-F",
|
"modelName": "DCS-7280CR3-32P4-F",
|
||||||
"version": "4.31.1F",
|
"version": "4.31.1F",
|
||||||
|
@ -41,7 +41,7 @@ MOCK_CLI_JSON: dict[str, aioeapi.EapiCommandError | dict[str, Any]] = {
|
||||||
"enable": {},
|
"enable": {},
|
||||||
"clear counters": {},
|
"clear counters": {},
|
||||||
"clear hardware counter drop": {},
|
"clear hardware counter drop": {},
|
||||||
"undefined": aioeapi.EapiCommandError(
|
"undefined": asynceapi.EapiCommandError(
|
||||||
passed=[],
|
passed=[],
|
||||||
failed="show version",
|
failed="show version",
|
||||||
errors=["Authorization denied for command '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",
|
"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": "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",
|
"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]:
|
def device(request: pytest.FixtureRequest) -> Iterator[AntaDevice]:
|
||||||
"""Return an AntaDevice instance with mocked abstract method."""
|
"""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
|
command.output = COMMAND_OUTPUT
|
||||||
|
|
||||||
kwargs = {"name": DEVICE_NAME, "hw_model": DEVICE_HW_MODEL}
|
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():
|
for mock_cmd, output in mock_cli.items():
|
||||||
if command == mock_cmd:
|
if command == mock_cmd:
|
||||||
logger.info("Mocking command %s", mock_cmd)
|
logger.info("Mocking command %s", mock_cmd)
|
||||||
if isinstance(output, aioeapi.EapiCommandError):
|
if isinstance(output, asynceapi.EapiCommandError):
|
||||||
raise output
|
raise output
|
||||||
return output
|
return output
|
||||||
message = f"Command '{command}' is not mocked"
|
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)
|
logger.debug("Mock output %s", res)
|
||||||
return 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 (
|
with (
|
||||||
patch("aioeapi.device.Device.check_connection", return_value=True),
|
patch("asynceapi.device.Device.check_connection", return_value=True),
|
||||||
patch("aioeapi.device.Device.cli", side_effect=cli),
|
patch("asynceapi.device.Device.cli", side_effect=cli),
|
||||||
patch("asyncssh.connect"),
|
patch("asyncssh.connect"),
|
||||||
patch(
|
patch(
|
||||||
"asyncssh.scp",
|
"asyncssh.scp",
|
||||||
|
|
|
@ -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]:
|
def default_anta_env() -> dict[str, str | None]:
|
||||||
|
|
|
@ -30,6 +30,7 @@ DATA: list[dict[str, Any]] = [
|
||||||
"name": "success",
|
"name": "success",
|
||||||
"test": VerifyBGPPeerCount,
|
"test": VerifyBGPPeerCount,
|
||||||
"eos_data": [
|
"eos_data": [
|
||||||
|
# Need to order the output as the commands would be sorted after template rendering.
|
||||||
{
|
{
|
||||||
"vrfs": {
|
"vrfs": {
|
||||||
"default": {
|
"default": {
|
||||||
|
@ -120,9 +121,10 @@ DATA: list[dict[str, Any]] = [
|
||||||
],
|
],
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"address_families": [
|
"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": "unicast", "vrf": "default", "num_peers": 2},
|
||||||
{"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT", "num_peers": 1},
|
{"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT", "num_peers": 1},
|
||||||
{"afi": "evpn", "num_peers": 2},
|
|
||||||
{"afi": "link-state", "num_peers": 2},
|
{"afi": "link-state", "num_peers": 2},
|
||||||
{"afi": "path-selection", "num_peers": 2},
|
{"afi": "path-selection", "num_peers": 2},
|
||||||
]
|
]
|
||||||
|
@ -652,9 +654,10 @@ DATA: list[dict[str, Any]] = [
|
||||||
],
|
],
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"address_families": [
|
"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": "unicast", "vrf": "default"},
|
||||||
{"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT"},
|
{"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT"},
|
||||||
{"afi": "path-selection"},
|
|
||||||
{"afi": "link-state"},
|
{"afi": "link-state"},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -1081,6 +1084,8 @@ DATA: list[dict[str, Any]] = [
|
||||||
],
|
],
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"address_families": [
|
"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",
|
"afi": "ipv4",
|
||||||
"safi": "unicast",
|
"safi": "unicast",
|
||||||
|
@ -1093,7 +1098,6 @@ DATA: list[dict[str, Any]] = [
|
||||||
"vrf": "MGMT",
|
"vrf": "MGMT",
|
||||||
"peers": ["10.1.255.10", "10.1.255.12"],
|
"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"]},
|
{"afi": "link-state", "peers": ["10.1.255.30", "10.1.255.32"]},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
570
tests/units/anta_tests/routing/test_isis.py
Normal file
570
tests/units/anta_tests/routing/test_isis.py
Normal 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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
|
@ -271,6 +271,18 @@ DATA: list[dict[str, Any]] = [
|
||||||
"inputs": {"methods": ["tacacs+", "local"], "types": ["commands", "exec"]},
|
"inputs": {"methods": ["tacacs+", "local"], "types": ["commands", "exec"]},
|
||||||
"expected": {"result": "success"},
|
"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",
|
"name": "failure-commands",
|
||||||
"test": VerifyAuthzMethods,
|
"test": VerifyAuthzMethods,
|
||||||
|
|
581
tests/units/anta_tests/test_avt.py
Normal file
581
tests/units/anta_tests/test_avt.py
Normal 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."]},
|
||||||
|
},
|
||||||
|
]
|
|
@ -7,7 +7,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
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
|
from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
|
||||||
|
|
||||||
DATA: list[dict[str, Any]] = [
|
DATA: list[dict[str, Any]] = [
|
||||||
|
@ -32,5 +32,42 @@ DATA: list[dict[str, Any]] = [
|
||||||
"inputs": None,
|
"inputs": None,
|
||||||
"expected": {"result": "success"},
|
"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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
|
@ -21,7 +21,7 @@ DATA: list[dict[str, Any]] = [
|
||||||
"modelName": "DCS-7280QRA-C36S",
|
"modelName": "DCS-7280QRA-C36S",
|
||||||
"details": {
|
"details": {
|
||||||
"deviations": [],
|
"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"],
|
"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",
|
"name": "success-JPE",
|
||||||
"test": VerifyFieldNotice72Resolution,
|
"test": VerifyFieldNotice72Resolution,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""Test inputs for anta.tests.hardware."""
|
"""Test inputs for anta.tests.interfaces."""
|
||||||
|
|
||||||
# pylint: disable=C0302
|
# pylint: disable=C0302
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
@ -14,6 +14,7 @@ from anta.tests.interfaces import (
|
||||||
VerifyInterfaceErrDisabled,
|
VerifyInterfaceErrDisabled,
|
||||||
VerifyInterfaceErrors,
|
VerifyInterfaceErrors,
|
||||||
VerifyInterfaceIPv4,
|
VerifyInterfaceIPv4,
|
||||||
|
VerifyInterfacesSpeed,
|
||||||
VerifyInterfacesStatus,
|
VerifyInterfacesStatus,
|
||||||
VerifyInterfaceUtilization,
|
VerifyInterfaceUtilization,
|
||||||
VerifyIPProxyARP,
|
VerifyIPProxyARP,
|
||||||
|
@ -1354,6 +1355,14 @@ DATA: list[dict[str, Any]] = [
|
||||||
"lineProtocolStatus": "up",
|
"lineProtocolStatus": "up",
|
||||||
"mtu": 65535,
|
"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"},
|
"expected": {"result": "success"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -2157,4 +2166,279 @@ DATA: list[dict[str, Any]] = [
|
||||||
"inputs": {"mac_address": "00:1c:73:00:dc:01"},
|
"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."]},
|
"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.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
|
@ -206,7 +206,9 @@ DATA: list[dict[str, Any]] = [
|
||||||
"eos_data": [
|
"eos_data": [
|
||||||
"",
|
"",
|
||||||
"2023-05-10T15:41:44.680813-05:00 NW-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_INFO: "
|
"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,
|
"inputs": None,
|
||||||
"expected": {"result": "success"},
|
"expected": {"result": "success"},
|
||||||
|
@ -222,6 +224,16 @@ DATA: list[dict[str, Any]] = [
|
||||||
"inputs": None,
|
"inputs": None,
|
||||||
"expected": {"result": "failure", "messages": ["Logs are not generated with the appropriate timestamp format"]},
|
"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",
|
"name": "success",
|
||||||
"test": VerifyLoggingAccounting,
|
"test": VerifyLoggingAccounting,
|
||||||
|
|
327
tests/units/anta_tests/test_path_selection.py
Normal file
327
tests/units/anta_tests/test_path_selection.py
Normal 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`.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
|
@ -42,11 +42,11 @@ DATA: list[dict[str, Any]] = [
|
||||||
"expected": {"result": "failure", "messages": ["The device is not configured as a PTP Boundary Clock: 'ptpDisabled'"]},
|
"expected": {"result": "failure", "messages": ["The device is not configured as a PTP Boundary Clock: 'ptpDisabled'"]},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "error",
|
"name": "skipped",
|
||||||
"test": VerifyPtpModeStatus,
|
"test": VerifyPtpModeStatus,
|
||||||
"eos_data": [{"ptpIntfSummaries": {}}],
|
"eos_data": [{"ptpIntfSummaries": {}}],
|
||||||
"inputs": None,
|
"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",
|
"name": "success",
|
||||||
|
@ -104,11 +104,11 @@ DATA: list[dict[str, Any]] = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "error",
|
"name": "skipped",
|
||||||
"test": VerifyPtpGMStatus,
|
"test": VerifyPtpGMStatus,
|
||||||
"eos_data": [{"ptpIntfSummaries": {}}],
|
"eos_data": [{"ptpIntfSummaries": {}}],
|
||||||
"inputs": {"gmid": "0xec:46:70:ff:fe:00:ff:a8"},
|
"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",
|
"name": "success",
|
||||||
|
@ -161,14 +161,14 @@ DATA: list[dict[str, Any]] = [
|
||||||
"expected": {"result": "failure", "messages": ["The device lock is more than 60s old: 157s"]},
|
"expected": {"result": "failure", "messages": ["The device lock is more than 60s old: 157s"]},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "error",
|
"name": "skipped",
|
||||||
"test": VerifyPtpLockStatus,
|
"test": VerifyPtpLockStatus,
|
||||||
"eos_data": [{"ptpIntfSummaries": {}}],
|
"eos_data": [{"ptpIntfSummaries": {}}],
|
||||||
"inputs": None,
|
"inputs": None,
|
||||||
"expected": {
|
"expected": {
|
||||||
"result": "error",
|
"result": "skipped",
|
||||||
"messages": [
|
"messages": [
|
||||||
"'ptpClockSummary' variable is not present in the command output",
|
"PTP is not configured",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -34,7 +34,14 @@ DATA: list[dict[str, Any]] = [
|
||||||
"expected": {"result": "success"},
|
"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,
|
"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"],
|
"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,
|
"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",
|
"name": "success",
|
||||||
"test": VerifyBannerLogin,
|
"test": VerifyBannerLogin,
|
||||||
|
|
|
@ -127,10 +127,12 @@ DATA: list[dict[str, Any]] = [
|
||||||
"name": "success",
|
"name": "success",
|
||||||
"test": VerifyErrdisableRecovery,
|
"test": VerifyErrdisableRecovery,
|
||||||
"eos_data": [
|
"eos_data": [
|
||||||
|
# Adding empty line on purpose to verify they are skipped
|
||||||
"""
|
"""
|
||||||
Errdisable Reason Timer Status Timer Interval
|
Errdisable Reason Timer Status Timer Interval
|
||||||
------------------------------ ----------------- --------------
|
------------------------------ ----------------- --------------
|
||||||
acl Enabled 300
|
acl Enabled 300
|
||||||
|
|
||||||
bpduguard Enabled 300
|
bpduguard Enabled 300
|
||||||
arp-inspection Enabled 30
|
arp-inspection Enabled 30
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -79,6 +79,7 @@ DATA: list[dict[str, Any]] = [
|
||||||
"inputs": {"versions": ["v1.17.1", "v1.18.1"]},
|
"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']"]},
|
"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",
|
"name": "success-no-extensions",
|
||||||
"test": VerifyEOSExtensions,
|
"test": VerifyEOSExtensions,
|
||||||
|
@ -89,6 +90,16 @@ DATA: list[dict[str, Any]] = [
|
||||||
"inputs": None,
|
"inputs": None,
|
||||||
"expected": {"result": "success"},
|
"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",
|
"name": "failure",
|
||||||
"test": VerifyEOSExtensions,
|
"test": VerifyEOSExtensions,
|
||||||
|
|
|
@ -25,6 +25,7 @@ if TYPE_CHECKING:
|
||||||
pytest.param("show version", None, "1", None, "dummy", False, id="version"),
|
pytest.param("show version", None, "1", None, "dummy", False, id="version"),
|
||||||
pytest.param("show version", None, None, 3, "dummy", False, id="revision"),
|
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, "dummy", True, id="command fails"),
|
||||||
|
pytest.param("undefined", None, None, None, "doesnotexist", True, id="Device does not exist"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_run_cmd(
|
def test_run_cmd(
|
||||||
|
|
|
@ -11,7 +11,7 @@ from unittest.mock import call, patch
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from anta.cli.exec.utils import (
|
from anta.cli.exec.utils import (
|
||||||
clear_counters_utils,
|
clear_counters,
|
||||||
)
|
)
|
||||||
from anta.models import AntaCommand
|
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,
|
caplog: pytest.LogCaptureFixture,
|
||||||
test_inventory: AntaInventory,
|
test_inventory: AntaInventory,
|
||||||
inventory_state: dict[str, Any],
|
inventory_state: dict[str, Any],
|
||||||
per_device_command_output: dict[str, Any],
|
per_device_command_output: dict[str, Any],
|
||||||
tags: set[str] | None,
|
tags: set[str] | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test anta.cli.exec.utils.clear_counters_utils."""
|
"""Test anta.cli.exec.utils.clear_counters."""
|
||||||
|
|
||||||
async def mock_connect_inventory() -> None:
|
async def mock_connect_inventory() -> None:
|
||||||
"""Mock connect_inventory coroutine."""
|
"""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.established = inventory_state[name].get("established", device.is_online)
|
||||||
device.hw_model = inventory_state[name].get("hw_model", "dummy")
|
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."""
|
"""Mock collect coroutine."""
|
||||||
command.output = per_device_command_output.get(self.name, "")
|
command.output = per_device_command_output.get(self.name, "")
|
||||||
|
|
||||||
# Need to patch the child device class
|
# Need to patch the child device class
|
||||||
with (
|
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(
|
patch(
|
||||||
"anta.inventory.AntaInventory.connect_inventory",
|
"anta.inventory.AntaInventory.connect_inventory",
|
||||||
side_effect=mock_connect_inventory,
|
side_effect=mock_connect_inventory,
|
||||||
) as mocked_connect_inventory,
|
) as mocked_connect_inventory,
|
||||||
):
|
):
|
||||||
mocked_collect.side_effect = dummy_collect
|
await clear_counters(test_inventory, tags=tags)
|
||||||
await clear_counters_utils(test_inventory, tags=tags)
|
|
||||||
|
|
||||||
mocked_connect_inventory.assert_awaited_once()
|
mocked_connect_inventory.assert_awaited_once()
|
||||||
devices_established = test_inventory.get_inventory(established_only=True, tags=tags).devices
|
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, ""),
|
output=per_device_command_output.get(device.name, ""),
|
||||||
errors=[],
|
errors=[],
|
||||||
),
|
),
|
||||||
|
collection_id=None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if device.hw_model not in ["cEOSLab", "vEOS-lab"]:
|
if device.hw_model not in ["cEOSLab", "vEOS-lab"]:
|
||||||
|
@ -130,6 +130,7 @@ async def test_clear_counters_utils(
|
||||||
ofmt="json",
|
ofmt="json",
|
||||||
output=per_device_command_output.get(device.name, ""),
|
output=per_device_command_output.get(device.name, ""),
|
||||||
),
|
),
|
||||||
|
collection_id=None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
mocked_collect.assert_has_awaits(calls)
|
mocked_collect.assert_has_awaits(calls)
|
||||||
|
|
|
@ -7,7 +7,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from anta.cli import anta
|
from anta.cli._main import anta
|
||||||
from anta.cli.utils import ExitCode
|
from anta.cli.utils import ExitCode
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
|
@ -13,7 +13,7 @@ from unittest.mock import ANY, patch
|
||||||
import pytest
|
import pytest
|
||||||
from cvprac.cvp_client_errors import CvpApiError
|
from cvprac.cvp_client_errors import CvpApiError
|
||||||
|
|
||||||
from anta.cli import anta
|
from anta.cli._main import anta
|
||||||
from anta.cli.utils import ExitCode
|
from anta.cli.utils import ExitCode
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
|
@ -81,14 +81,15 @@ def test_create_inventory_from_cvp(tmp_path: Path, inventory: list[dict[str, Any
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@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", None, nullcontext(), None, 7, id="no group"),
|
||||||
pytest.param("ansible_inventory.yml", "ATD_LEAFS", nullcontext(), 4, id="group found"),
|
pytest.param("ansible_inventory.yml", "ATD_LEAFS", nullcontext(), None, 4, id="group found"),
|
||||||
pytest.param(
|
pytest.param(
|
||||||
"ansible_inventory.yml",
|
"ansible_inventory.yml",
|
||||||
"DUMMY",
|
"DUMMY",
|
||||||
pytest.raises(ValueError, match="Group DUMMY not found in Ansible inventory"),
|
pytest.raises(ValueError, match="Group DUMMY not found in Ansible inventory"),
|
||||||
|
None,
|
||||||
0,
|
0,
|
||||||
id="group not found",
|
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",
|
"empty_ansible_inventory.yml",
|
||||||
None,
|
None,
|
||||||
pytest.raises(ValueError, match="Ansible inventory .* is empty"),
|
pytest.raises(ValueError, match="Ansible inventory .* is empty"),
|
||||||
|
None,
|
||||||
0,
|
0,
|
||||||
id="empty inventory",
|
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",
|
"wrong_ansible_inventory.yml",
|
||||||
None,
|
None,
|
||||||
pytest.raises(ValueError, match="Could not parse"),
|
pytest.raises(ValueError, match="Could not parse"),
|
||||||
|
None,
|
||||||
0,
|
0,
|
||||||
id="os error inventory",
|
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(
|
def test_create_inventory_from_ansible(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
inventory_filename: Path,
|
inventory_filename: Path,
|
||||||
ansible_group: str | None,
|
ansible_group: str | None,
|
||||||
expected_raise: AbstractContextManager[Exception],
|
expected_raise: AbstractContextManager[Exception],
|
||||||
|
expected_log: str | None,
|
||||||
expected_inv_length: int,
|
expected_inv_length: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test anta.get.utils.create_inventory_from_ansible."""
|
"""Test anta.get.utils.create_inventory_from_ansible."""
|
||||||
|
# pylint: disable=R0913
|
||||||
target_file = tmp_path / "inventory.yml"
|
target_file = tmp_path / "inventory.yml"
|
||||||
inventory_file_path = DATA_DIR / inventory_filename
|
inventory_file_path = DATA_DIR / inventory_filename
|
||||||
|
|
||||||
|
@ -130,3 +152,5 @@ def test_create_inventory_from_ansible(
|
||||||
assert len(inv) == expected_inv_length
|
assert len(inv) == expected_inv_length
|
||||||
if not isinstance(expected_raise, nullcontext):
|
if not isinstance(expected_raise, nullcontext):
|
||||||
assert not target_file.exists()
|
assert not target_file.exists()
|
||||||
|
if expected_log:
|
||||||
|
assert expected_log in caplog.text
|
||||||
|
|
|
@ -24,6 +24,14 @@ def test_anta_nrfu_help(click_runner: CliRunner) -> None:
|
||||||
assert "Usage: anta nrfu" in result.output
|
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:
|
def test_anta_nrfu(click_runner: CliRunner) -> None:
|
||||||
"""Test anta nrfu, catalog is given via env."""
|
"""Test anta nrfu, catalog is given via env."""
|
||||||
result = click_runner.invoke(anta, ["nrfu"])
|
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
|
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:
|
def test_anta_password_required(click_runner: CliRunner) -> None:
|
||||||
"""Test that password is provided."""
|
"""Test that password is provided."""
|
||||||
env = default_anta_env()
|
env = default_anta_env()
|
||||||
|
|
|
@ -54,6 +54,20 @@ def test_anta_nrfu_table(click_runner: CliRunner) -> None:
|
||||||
assert "dummy │ VerifyEOSVersion │ success" in result.output
|
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:
|
def test_anta_nrfu_text(click_runner: CliRunner) -> None:
|
||||||
"""Test anta nrfu, catalog is given via env."""
|
"""Test anta nrfu, catalog is given via env."""
|
||||||
result = click_runner.invoke(anta, ["nrfu", "text"])
|
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"])
|
result = click_runner.invoke(anta, ["nrfu", "json"])
|
||||||
assert result.exit_code == ExitCode.OK
|
assert result.exit_code == ExitCode.OK
|
||||||
assert "JSON results" in result.output
|
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
|
assert match is not None
|
||||||
result_list = json.loads(match.group())
|
result_list = json.loads(match.group())
|
||||||
for res in result_list:
|
for res in result_list:
|
||||||
|
|
|
@ -1,64 +1,55 @@
|
||||||
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
# Copyright (c) 2023-2024 Arista Networks, Inc.
|
||||||
# Use of this source code is governed by the Apache License 2.0
|
# Use of this source code is governed by the Apache License 2.0
|
||||||
# that can be found in the LICENSE file.
|
# that can be found in the LICENSE file.
|
||||||
"""Tests for anta.cli.__init__."""
|
"""Tests for anta.cli._main."""
|
||||||
|
|
||||||
from __future__ import annotations
|
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
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from anta.cli import anta, cli
|
import anta.cli
|
||||||
from anta.cli.utils import ExitCode
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from click.testing import CliRunner
|
from types import ModuleType
|
||||||
|
|
||||||
|
builtins_import = __import__
|
||||||
|
|
||||||
|
|
||||||
def test_anta(click_runner: CliRunner) -> None:
|
# Tried to achieve this with mock
|
||||||
"""Test anta main entrypoint."""
|
# http://materials-scientist.com/blog/2021/02/11/mocking-failing-module-import-python/
|
||||||
result = click_runner.invoke(anta)
|
def import_mock(name: str, *args: Any) -> ModuleType: # noqa: ANN401
|
||||||
assert result.exit_code == ExitCode.OK
|
"""Mock."""
|
||||||
assert "Usage" in result.output
|
if name == "click":
|
||||||
|
msg = "No module named 'click'"
|
||||||
|
raise ModuleNotFoundError(msg)
|
||||||
|
return builtins_import(name, *args)
|
||||||
|
|
||||||
|
|
||||||
def test_anta_help(click_runner: CliRunner) -> None:
|
def test_cli_error_missing(capsys: pytest.CaptureFixture[Any]) -> None:
|
||||||
"""Test anta --help."""
|
"""Test ANTA errors out when anta[cli] was not installed."""
|
||||||
result = click_runner.invoke(anta, ["--help"])
|
with patch.dict(sys.modules) as sys_modules, patch("builtins.__import__", import_mock):
|
||||||
assert result.exit_code == ExitCode.OK
|
del sys_modules["anta.cli._main"]
|
||||||
assert "Usage" in result.output
|
reload(anta.cli)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as e_info:
|
||||||
|
anta.cli.cli()
|
||||||
|
|
||||||
def test_anta_exec_help(click_runner: CliRunner) -> None:
|
captured = capsys.readouterr()
|
||||||
"""Test anta exec --help."""
|
assert "The ANTA command line client could not run because the required dependencies were not installed." in captured.out
|
||||||
result = click_runner.invoke(anta, ["exec", "--help"])
|
assert "Make sure you've installed everything with: pip install 'anta[cli]'" in captured.out
|
||||||
assert result.exit_code == ExitCode.OK
|
assert e_info.value.code == 1
|
||||||
assert "Usage: anta exec" in result.output
|
|
||||||
|
|
||||||
|
# setting ANTA_DEBUG
|
||||||
|
with pytest.raises(SystemExit) as e_info, patch("anta.cli.__DEBUG__", new=True):
|
||||||
|
anta.cli.cli()
|
||||||
|
|
||||||
def test_anta_debug_help(click_runner: CliRunner) -> None:
|
captured = capsys.readouterr()
|
||||||
"""Test anta debug --help."""
|
assert "The ANTA command line client could not run because the required dependencies were not installed." in captured.out
|
||||||
result = click_runner.invoke(anta, ["debug", "--help"])
|
assert "Make sure you've installed everything with: pip install 'anta[cli]'" in captured.out
|
||||||
assert result.exit_code == ExitCode.OK
|
assert "The caught exception was:" in captured.out
|
||||||
assert "Usage: anta debug" in result.output
|
assert e_info.value.code == 1
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
assert e_info.value.code == 1
|
|
||||||
|
|
64
tests/units/cli/test_main.py
Normal file
64
tests/units/cli/test_main.py
Normal 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
|
|
@ -5,13 +5,14 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Callable
|
from typing import TYPE_CHECKING, Callable
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from anta import RICH_COLOR_PALETTE
|
from anta import RICH_COLOR_PALETTE
|
||||||
from anta.reporter import ReportTable
|
from anta.reporter import ReportJinja, ReportTable
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from anta.custom_types import TestStatus
|
from anta.custom_types import TestStatus
|
||||||
|
@ -185,3 +186,14 @@ class TestReportTable:
|
||||||
assert isinstance(res, Table)
|
assert isinstance(res, Table)
|
||||||
assert res.title == (title or "Summary per device")
|
assert res.title == (title or "Summary per device")
|
||||||
assert res.row_count == expected_length
|
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"))
|
||||||
|
|
|
@ -12,7 +12,7 @@ import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from yaml import safe_load
|
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.models import AntaTest
|
||||||
from anta.tests.interfaces import VerifyL3MTU
|
from anta.tests.interfaces import VerifyL3MTU
|
||||||
from anta.tests.mlag import VerifyMlagStatus
|
from anta.tests.mlag import VerifyMlagStatus
|
||||||
|
@ -76,6 +76,11 @@ INIT_CATALOG_DATA: list[dict[str, Any]] = [
|
||||||
"filename": "test_empty_catalog.yml",
|
"filename": "test_empty_catalog.yml",
|
||||||
"tests": [],
|
"tests": [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "test_empty_dict_catalog",
|
||||||
|
"filename": "test_empty_dict_catalog.yml",
|
||||||
|
"tests": [],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
CATALOG_PARSE_FAIL_DATA: list[dict[str, Any]] = [
|
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",
|
"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]] = [
|
TESTS_SETTER_FAIL_DATA: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"name": "not_a_list",
|
"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))
|
@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:
|
def test_parse(self, catalog_data: dict[str, Any]) -> None:
|
||||||
"""Instantiate AntaCatalog from a file."""
|
"""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"])
|
assert len(catalog.tests) == len(catalog_data["tests"])
|
||||||
for test_id, (test, inputs_data) in enumerate(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:
|
def test_parse_fail(self, catalog_data: dict[str, Any]) -> None:
|
||||||
"""Errors when instantiating AntaCatalog from a file."""
|
"""Errors when instantiating AntaCatalog from a file."""
|
||||||
with pytest.raises((ValidationError, TypeError)) as exec_info:
|
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):
|
if isinstance(exec_info.value, ValidationError):
|
||||||
assert catalog_data["error"] in exec_info.value.errors()[0]["msg"]
|
assert catalog_data["error"] in exec_info.value.errors()[0]["msg"]
|
||||||
else:
|
else:
|
||||||
|
@ -230,7 +234,7 @@ class TestAntaCatalog:
|
||||||
def test_parse_fail_parsing(self, caplog: pytest.LogCaptureFixture) -> None:
|
def test_parse_fail_parsing(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
"""Errors when instantiating AntaCatalog from a file."""
|
"""Errors when instantiating AntaCatalog from a file."""
|
||||||
with pytest.raises(FileNotFoundError) as exec_info:
|
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 "No such file or directory" in str(exec_info)
|
||||||
assert len(caplog.record_tuples) >= 1
|
assert len(caplog.record_tuples) >= 1
|
||||||
_, _, message = caplog.record_tuples[0]
|
_, _, message = caplog.record_tuples[0]
|
||||||
|
@ -284,16 +288,79 @@ class TestAntaCatalog:
|
||||||
catalog.tests = catalog_data["tests"]
|
catalog.tests = catalog_data["tests"]
|
||||||
assert catalog_data["error"] in str(exec_info)
|
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:
|
def test_get_tests_by_tags(self) -> None:
|
||||||
"""Test AntaCatalog.get_tests_by_tags()."""
|
"""Test AntaCatalog.get_tests_by_tags()."""
|
||||||
catalog: AntaCatalog = AntaCatalog.parse(str(DATA_DIR / "test_catalog_with_tags.yml"))
|
catalog: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog_with_tags.yml")
|
||||||
tests: list[AntaTestDefinition] = catalog.get_tests_by_tags(tags={"leaf"})
|
catalog.build_indexes()
|
||||||
|
tests: set[AntaTestDefinition] = catalog.get_tests_by_tags(tags={"leaf"})
|
||||||
assert len(tests) == 3
|
assert len(tests) == 3
|
||||||
tests = catalog.get_tests_by_tags(tags={"leaf"}, strict=True)
|
tests = catalog.get_tests_by_tags(tags={"leaf", "spine"}, strict=True)
|
||||||
assert len(tests) == 2
|
assert len(tests) == 1
|
||||||
|
|
||||||
def test_get_tests_by_names(self) -> None:
|
def test_merge(self) -> None:
|
||||||
"""Test AntaCatalog.get_tests_by_tags()."""
|
"""Test AntaCatalog.merge()."""
|
||||||
catalog: AntaCatalog = AntaCatalog.parse(str(DATA_DIR / "test_catalog_with_tags.yml"))
|
catalog1: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog.yml")
|
||||||
tests: list[AntaTestDefinition] = catalog.get_tests_by_names(names={"VerifyUptime", "VerifyCoredump"})
|
assert len(catalog1.tests) == 1
|
||||||
assert len(tests) == 3
|
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()
|
||||||
|
|
264
tests/units/test_custom_types.py
Normal file
264
tests/units/test_custom_types.py
Normal 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"
|
|
@ -15,7 +15,7 @@ import pytest
|
||||||
from asyncssh import SSHClientConnection, SSHClientConnectionOptions
|
from asyncssh import SSHClientConnection, SSHClientConnectionOptions
|
||||||
from rich import print as rprint
|
from rich import print as rprint
|
||||||
|
|
||||||
from anta import aioeapi
|
import asynceapi
|
||||||
from anta.device import AntaDevice, AsyncEOSDevice
|
from anta.device import AntaDevice, AsyncEOSDevice
|
||||||
from anta.models import AntaCommand
|
from anta.models import AntaCommand
|
||||||
from tests.lib.fixture import COMMAND_OUTPUT
|
from tests.lib.fixture import COMMAND_OUTPUT
|
||||||
|
@ -128,7 +128,7 @@ EQUALITY_DATA: list[dict[str, Any]] = [
|
||||||
"expected": False,
|
"expected": False,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
AIOEAPI_COLLECT_DATA: list[dict[str, Any]] = [
|
ASYNCEAPI_COLLECT_DATA: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"name": "command",
|
"name": "command",
|
||||||
"device": {},
|
"device": {},
|
||||||
|
@ -350,12 +350,12 @@ AIOEAPI_COLLECT_DATA: list[dict[str, Any]] = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "aioeapi.EapiCommandError",
|
"name": "asynceapi.EapiCommandError",
|
||||||
"device": {},
|
"device": {},
|
||||||
"command": {
|
"command": {
|
||||||
"command": "show version",
|
"command": "show version",
|
||||||
"patch_kwargs": {
|
"patch_kwargs": {
|
||||||
"side_effect": aioeapi.EapiCommandError(
|
"side_effect": asynceapi.EapiCommandError(
|
||||||
passed=[],
|
passed=[],
|
||||||
failed="show version",
|
failed="show version",
|
||||||
errors=["Authorization denied for command '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"]},
|
"expected": {"output": None, "errors": ["ConnectError: Cannot open port"]},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
AIOEAPI_COPY_DATA: list[dict[str, Any]] = [
|
ASYNCEAPI_COPY_DATA: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"name": "from",
|
"name": "from",
|
||||||
"device": {},
|
"device": {},
|
||||||
|
@ -509,12 +509,12 @@ REFRESH_DATA: list[dict[str, Any]] = [
|
||||||
"expected": {"is_online": True, "established": False, "hw_model": None},
|
"expected": {"is_online": True, "established": False, "hw_model": None},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "aioeapi.EapiCommandError",
|
"name": "asynceapi.EapiCommandError",
|
||||||
"device": {},
|
"device": {},
|
||||||
"patch_kwargs": (
|
"patch_kwargs": (
|
||||||
{"return_value": True},
|
{"return_value": True},
|
||||||
{
|
{
|
||||||
"side_effect": aioeapi.EapiCommandError(
|
"side_effect": asynceapi.EapiCommandError(
|
||||||
passed=[],
|
passed=[],
|
||||||
failed="show version",
|
failed="show version",
|
||||||
errors=["Authorization denied for command 'show version'"],
|
errors=["Authorization denied for command 'show version'"],
|
||||||
|
@ -644,7 +644,7 @@ class TestAntaDevice:
|
||||||
assert current_cached_data == COMMAND_OUTPUT
|
assert current_cached_data == COMMAND_OUTPUT
|
||||||
assert device.cache.hit_miss_ratio["hits"] == 1
|
assert device.cache.hit_miss_ratio["hits"] == 1
|
||||||
else: # command is not allowed to use cache
|
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
|
assert command.output == COMMAND_OUTPUT
|
||||||
if expected_data["cache_hit"] is True:
|
if expected_data["cache_hit"] is True:
|
||||||
assert current_cached_data == cached_output
|
assert current_cached_data == cached_output
|
||||||
|
@ -652,7 +652,7 @@ class TestAntaDevice:
|
||||||
assert current_cached_data is None
|
assert current_cached_data is None
|
||||||
else: # device is disabled
|
else: # device is disabled
|
||||||
assert device.cache is None
|
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"])
|
@pytest.mark.parametrize(("device", "expected"), CACHE_STATS_DATA, indirect=["device"])
|
||||||
def test_cache_statistics(self, device: AntaDevice, expected: dict[str, Any] | None) -> None:
|
def test_cache_statistics(self, device: AntaDevice, expected: dict[str, Any] | None) -> None:
|
||||||
|
@ -705,9 +705,9 @@ class TestAsyncEOSDevice:
|
||||||
"""Test AsyncEOSDevice.refresh()."""
|
"""Test AsyncEOSDevice.refresh()."""
|
||||||
with patch.object(async_device._session, "check_connection", **patch_kwargs[0]), patch.object(async_device._session, "cli", **patch_kwargs[1]):
|
with patch.object(async_device._session, "check_connection", **patch_kwargs[0]), patch.object(async_device._session, "cli", **patch_kwargs[1]):
|
||||||
await async_device.refresh()
|
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"]:
|
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.is_online == expected["is_online"]
|
||||||
assert async_device.established == expected["established"]
|
assert async_device.established == expected["established"]
|
||||||
assert async_device.hw_model == expected["hw_model"]
|
assert async_device.hw_model == expected["hw_model"]
|
||||||
|
@ -715,8 +715,8 @@ class TestAsyncEOSDevice:
|
||||||
@pytest.mark.asyncio()
|
@pytest.mark.asyncio()
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("async_device", "command", "expected"),
|
("async_device", "command", "expected"),
|
||||||
((d["device"], d["command"], d["expected"]) for d in AIOEAPI_COLLECT_DATA),
|
((d["device"], d["command"], d["expected"]) for d in ASYNCEAPI_COLLECT_DATA),
|
||||||
ids=generate_test_ids_list(AIOEAPI_COLLECT_DATA),
|
ids=generate_test_ids_list(ASYNCEAPI_COLLECT_DATA),
|
||||||
indirect=["async_device"],
|
indirect=["async_device"],
|
||||||
)
|
)
|
||||||
async def test__collect(self, async_device: AsyncEOSDevice, command: dict[str, Any], expected: dict[str, Any]) -> None:
|
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()."""
|
"""Test AsyncEOSDevice._collect()."""
|
||||||
cmd = AntaCommand(command=command["command"], revision=command["revision"]) if "revision" in command else AntaCommand(command=command["command"])
|
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"]):
|
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]] = []
|
commands: list[dict[str, Any]] = []
|
||||||
if async_device.enable and async_device._enable_password is not None:
|
if async_device.enable and async_device._enable_password is not None:
|
||||||
commands.append(
|
commands.append(
|
||||||
|
@ -740,15 +741,15 @@ class TestAsyncEOSDevice:
|
||||||
commands.append({"cmd": cmd.command, "revision": cmd.revision})
|
commands.append({"cmd": cmd.command, "revision": cmd.revision})
|
||||||
else:
|
else:
|
||||||
commands.append({"cmd": cmd.command})
|
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.output == expected["output"]
|
||||||
assert cmd.errors == expected["errors"]
|
assert cmd.errors == expected["errors"]
|
||||||
|
|
||||||
@pytest.mark.asyncio()
|
@pytest.mark.asyncio()
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("async_device", "copy"),
|
("async_device", "copy"),
|
||||||
((d["device"], d["copy"]) for d in AIOEAPI_COPY_DATA),
|
((d["device"], d["copy"]) for d in ASYNCEAPI_COPY_DATA),
|
||||||
ids=generate_test_ids_list(AIOEAPI_COPY_DATA),
|
ids=generate_test_ids_list(ASYNCEAPI_COPY_DATA),
|
||||||
indirect=["async_device"],
|
indirect=["async_device"],
|
||||||
)
|
)
|
||||||
async def test_copy(self, async_device: AsyncEOSDevice, copy: dict[str, Any]) -> None:
|
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
Loading…
Add table
Reference in a new issue